From 155d24ec2e39178c70a8dcda6aaafd2dfd804775 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 28 Feb 2026 07:20:44 -0500 Subject: [PATCH] Add comprehensive test suite: labs, entries, dossiers, readings Test coverage: - lib/lib_test.go (36 test functions): - Crypto: Pack/Unpack, CryptoEncrypt/Decrypt, deterministic encryption - Tokens: Create, Parse, expiration handling, invalid token handling - Lab references: LabScale conversions, MakeRefID, LabRefLookup with sex/age matching - Categories: FromString, CategoryName, CategoryKey, Categories() list - IDs: NewID uniqueness, FormatID/ParseID roundtrip - Entry CRUD: Write, Read, Delete, filter by type, filter by date range - Trackers: Add, Query, Respond, Dismiss - RBAC: CheckAccess for own dossier, system user, granted access, no access - Dossier helpers: DossierFromEntry, SexKey - Normalize: normalizeKey test name cleaning - JSON marshaling: Tracker data, DossierPreferences - api/api_test.go (23 test functions): - Helper functions: intPtr, getString, seriesPriority - v2 helpers: deterministicID, metricLabel - Version endpoint - Error/JSON responses - Request/Response JSON marshaling: Entry, Dossier, Tracker, Parse, Readings - Method handling: token endpoint - Categories endpoint - isLocalhost detection - Schedule calculation All tests use in-memory SQLite for isolation. No external dependencies required. --- api/api_test.go | 594 +++++++++++++++++++++++++++ lib/lib_test.go | 1021 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1615 insertions(+) create mode 100644 api/api_test.go create mode 100644 lib/lib_test.go diff --git a/api/api_test.go b/api/api_test.go new file mode 100644 index 0000000..8736291 --- /dev/null +++ b/api/api_test.go @@ -0,0 +1,594 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "inou/lib" +) + +func TestMain(m *testing.M) { + os.Setenv("TEST_MODE", "1") + code := m.Run() + os.Exit(code) +} + +// ============================================================================= +// Test Helpers +// ============================================================================= + +// makeRequest creates a test HTTP request with optional auth token +func makeRequest(method, path string, body interface{}, token string) *http.Request { + var bodyReader *bytes.Reader + if body != nil { + b, _ := json.Marshal(body) + bodyReader = bytes.NewReader(b) + } else { + bodyReader = bytes.NewReader(nil) + } + + req := httptest.NewRequest(method, path, bodyReader) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + return req +} + +// parseResponse parses JSON response body +func parseResponse(t *testing.T, w *httptest.ResponseRecorder, v interface{}) { + t.Helper() + if err := json.Unmarshal(w.Body.Bytes(), v); err != nil { + t.Fatalf("Failed to parse response: %v\nBody: %s", err, w.Body.String()) + } +} + +// ============================================================================= +// v1 Helper Function Tests +// ============================================================================= + +func TestIntPtr(t *testing.T) { + p := intPtr(42) + if *p != 42 { + t.Errorf("intPtr(42) = %d, want 42", *p) + } +} + +func TestGetString(t *testing.T) { + m := map[string]interface{}{ + "name": "Test Study", + "number": 123, + "nested": map[string]interface{}{"inner": "value"}, + } + + if got := getString(m, "name"); got != "Test Study" { + t.Errorf("getString(name) = %q, want 'Test Study'", got) + } + + if got := getString(m, "number"); got != "" { + t.Errorf("getString(number) = %q, want '' (not string)", got) + } + + if got := getString(m, "missing"); got != "" { + t.Errorf("getString(missing) = %q, want ''", got) + } +} + +func TestSeriesPriority(t *testing.T) { + tests := []struct { + desc string + expected int + }{ + {"AXIAL T2", 1}, // T2 non-contrast first + {"SAGITTAL T2+", 2}, // T2 contrast + {"CORONAL T1", 3}, // T1 non-contrast + {"AXIAL T1+ GD", 4}, // T1 contrast + {"FLAIR", 5}, // FLAIR non-contrast + {"FLAIR+", 6}, // FLAIR contrast + {"DWI", 99}, // Other + {"OTHER SEQUENCE", 99}, // Unknown + } + + for _, tc := range tests { + got := seriesPriority(tc.desc) + if got != tc.expected { + t.Errorf("seriesPriority(%q) = %d, want %d", tc.desc, got, tc.expected) + } + } +} + +// ============================================================================= +// v2 Readings Helper Tests +// ============================================================================= + +func TestDeterministicID(t *testing.T) { + id1 := deterministicID("dossier1", "cat", "3") + id2 := deterministicID("dossier1", "cat", "3") + id3 := deterministicID("dossier2", "cat", "3") + + if id1 != id2 { + t.Error("deterministicID should be deterministic for same inputs") + } + + if id1 == id3 { + t.Error("deterministicID should differ for different inputs") + } + + if len(id1) != 16 { + t.Errorf("deterministicID length = %d, want 16", len(id1)) + } +} + +func TestMetricLabel(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"heart_rate", "Heart Rate"}, + {"blood_pressure", "Blood Pressure"}, + {"weight", "Weight"}, + {"spo2", "Spo2"}, + {"body_mass_index", "Body Mass Index"}, + } + + for _, tc := range tests { + got := metricLabel(tc.input) + if got != tc.expected { + t.Errorf("metricLabel(%q) = %q, want %q", tc.input, got, tc.expected) + } + } +} + +// ============================================================================= +// Version Endpoint Test +// ============================================================================= + +func TestVersionEndpoint(t *testing.T) { + w := httptest.NewRecorder() + r := makeRequest("GET", "/api/version", nil, "") + + handleVersion(w, r) + + if w.Code != http.StatusOK { + t.Errorf("Status = %d, want 200", w.Code) + } + + var resp map[string]interface{} + parseResponse(t, w, &resp) + + // Check it has the expected fields + if _, ok := resp["api_version"]; !ok { + t.Error("Response should contain 'api_version' field") + } + if _, ok := resp["latest_bridge_version"]; !ok { + t.Error("Response should contain 'latest_bridge_version' field") + } +} + +// ============================================================================= +// Error Response Tests +// ============================================================================= + +func TestV1Error(t *testing.T) { + w := httptest.NewRecorder() + v1Error(w, "test error message", http.StatusBadRequest) + + if w.Code != http.StatusBadRequest { + t.Errorf("Status = %d, want 400", w.Code) + } + + var resp map[string]string + parseResponse(t, w, &resp) + + if resp["error"] != "test error message" { + t.Errorf("error = %q, want 'test error message'", resp["error"]) + } +} + +func TestV1JSON(t *testing.T) { + w := httptest.NewRecorder() + data := map[string]interface{}{ + "id": "abc123", + "name": "Test", + "count": 42, + } + + v1JSON(w, data) + + if w.Header().Get("Content-Type") != "application/json" { + t.Errorf("Content-Type = %q, want 'application/json'", w.Header().Get("Content-Type")) + } + + var resp map[string]interface{} + parseResponse(t, w, &resp) + + if resp["id"] != "abc123" { + t.Errorf("id = %q, want 'abc123'", resp["id"]) + } +} + +// ============================================================================= +// Request/Response Structure Tests +// ============================================================================= + +func TestEntryRequestJSON(t *testing.T) { + jsonStr := `{ + "dossier": "abc123def456gh78", + "category": "lab", + "type": "WBC", + "value": "8.5", + "timestamp": 1704067200, + "data": "{\"unit\":\"x10^9/L\"}" + }` + + var req EntryRequest + if err := json.Unmarshal([]byte(jsonStr), &req); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + + if req.Dossier != "abc123def456gh78" { + t.Errorf("Dossier = %q, want 'abc123def456gh78'", req.Dossier) + } + if req.Category != "lab" { + t.Errorf("Category = %q, want 'lab'", req.Category) + } + if req.Value != "8.5" { + t.Errorf("Value = %q, want '8.5'", req.Value) + } + if req.Timestamp != 1704067200 { + t.Errorf("Timestamp = %d, want 1704067200", req.Timestamp) + } +} + +func TestEntryResponseJSON(t *testing.T) { + resp := EntryResponse{ + ID: "entry123", + Dossier: "dossier456", + Category: "lab", + Type: "WBC", + Value: "8.5", + Timestamp: 1704067200, + } + + data, err := json.Marshal(resp) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + + var got EntryResponse + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + + if got.ID != "entry123" { + t.Errorf("ID = %q, want 'entry123'", got.ID) + } +} + +func TestDossierResponseJSON(t *testing.T) { + resp := DossierResponse{ + ID: "abc123def456gh78", + Name: "Test User", + DOB: "1990-05-15", + Found: true, + } + + data, err := json.Marshal(resp) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + + // Should have correct JSON field names + var m map[string]interface{} + json.Unmarshal(data, &m) + + if m["id"] != "abc123def456gh78" { + t.Errorf("id = %v, want 'abc123def456gh78'", m["id"]) + } + if m["name"] != "Test User" { + t.Errorf("name = %v, want 'Test User'", m["name"]) + } + if m["found"] != true { + t.Errorf("found = %v, want true", m["found"]) + } +} + +func TestTrackerResponseJSON(t *testing.T) { + resp := TrackerResponse{ + ID: "tracker123", + Category: "vital", + Type: "blood_pressure", + Question: "What is your blood pressure?", + InputType: "blood_pressure", + Active: true, + IsDue: true, + } + + data, err := json.Marshal(resp) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + + var m map[string]interface{} + json.Unmarshal(data, &m) + + if m["id"] != "tracker123" { + t.Errorf("id = %v, want 'tracker123'", m["id"]) + } + if m["is_due"] != true { + t.Errorf("is_due = %v, want true", m["is_due"]) + } +} + +func TestParseRequestJSON(t *testing.T) { + jsonStr := `{"input": "I took my blood pressure and it was 120/80"}` + + var req ParseRequest + if err := json.Unmarshal([]byte(jsonStr), &req); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + + if req.Input != "I took my blood pressure and it was 120/80" { + t.Errorf("Input = %q, unexpected value", req.Input) + } +} + +func TestReadingsRequestJSON(t *testing.T) { + jsonStr := `{ + "dossier_id": "abc123", + "category": "vital", + "readings": [ + {"source": "manual", "metric": "heart_rate", "value": 72, "unit": "bpm", "timestamp": 1704067200}, + {"source": "apple_health", "metric": "weight", "value": 75.5, "unit": "kg", "timestamp": 1704067200} + ] + }` + + var req readingsRequest + if err := json.Unmarshal([]byte(jsonStr), &req); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + + if req.DossierID != "abc123" { + t.Errorf("DossierID = %q, want 'abc123'", req.DossierID) + } + if len(req.Readings) != 2 { + t.Errorf("len(Readings) = %d, want 2", len(req.Readings)) + } + if req.Readings[0].Metric != "heart_rate" { + t.Errorf("Readings[0].Metric = %q, want 'heart_rate'", req.Readings[0].Metric) + } + if req.Readings[1].Value != 75.5 { + t.Errorf("Readings[1].Value = %f, want 75.5", req.Readings[1].Value) + } +} + +func TestDossierCreateRequestJSON(t *testing.T) { + jsonStr := `{"email": "test@example.com", "invited_by": "abc123"}` + + var req DossierCreateRequest + if err := json.Unmarshal([]byte(jsonStr), &req); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + + if req.Email != "test@example.com" { + t.Errorf("Email = %q, want 'test@example.com'", req.Email) + } + if req.InvitedBy != "abc123" { + t.Errorf("InvitedBy = %q, want 'abc123'", req.InvitedBy) + } +} + +// ============================================================================= +// Health Response Structure Test +// ============================================================================= + +func TestHealthResponseJSON(t *testing.T) { + resp := HealthResponse{ + Status: "ok", + Time: time.Now().Unix(), + Version: "1.0.0", + Checks: map[string]string{"db": "ok", "auth": "ok"}, + } + + data, err := json.Marshal(resp) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + + var got HealthResponse + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + + if got.Status != "ok" { + t.Errorf("Status = %q, want 'ok'", got.Status) + } + if got.Version != "1.0.0" { + t.Errorf("Version = %q, want '1.0.0'", got.Version) + } + if got.Checks["db"] != "ok" { + t.Errorf("Checks['db'] = %q, want 'ok'", got.Checks["db"]) + } +} + +// ============================================================================= +// Tracker Respond Request Test +// ============================================================================= + +func TestTrackerRespondRequestJSON(t *testing.T) { + jsonStr := `{ + "tracker_id": "tracker123", + "response": "{\"systolic\":120,\"diastolic\":80}", + "response_raw": "120/80 mmHg", + "action": "respond" + }` + + var req TrackerRespondRequest + if err := json.Unmarshal([]byte(jsonStr), &req); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + + if req.TrackerID != "tracker123" { + t.Errorf("TrackerID = %q, want 'tracker123'", req.TrackerID) + } + if req.Action != "respond" { + t.Errorf("Action = %q, want 'respond'", req.Action) + } + if req.ResponseRaw != "120/80 mmHg" { + t.Errorf("ResponseRaw = %q, want '120/80 mmHg'", req.ResponseRaw) + } +} + +// ============================================================================= +// API Route Handler Tests (Method Not Allowed) +// ============================================================================= + +func TestV1TokenMethodNotAllowed(t *testing.T) { + w := httptest.NewRecorder() + r := makeRequest("GET", "/api/v1/token", nil, "test-token") + + v1Token(w, r) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("GET /api/v1/token should return 405, got %d", w.Code) + } +} + +// ============================================================================= +// Entry to Response Conversion Test +// ============================================================================= + +func TestEntryToResponse(t *testing.T) { + entry := &lib.Entry{ + EntryID: "entry123", + DossierID: "dossier456", + Category: lib.CategoryLab, + Type: "WBC", + Value: "8.5", + Timestamp: 1704067200, + Data: `{"unit":"x10^9/L"}`, + } + + resp := entryToResponse(entry, "en") + + if resp.ID != "entry123" { + t.Errorf("ID = %q, want 'entry123'", resp.ID) + } + if resp.Dossier != "dossier456" { + t.Errorf("Dossier = %q, want 'dossier456'", resp.Dossier) + } + if resp.Type != "WBC" { + t.Errorf("Type = %q, want 'WBC'", resp.Type) + } + if resp.Value != "8.5" { + t.Errorf("Value = %q, want '8.5'", resp.Value) + } +} + +// ============================================================================= +// Categories API Test +// ============================================================================= + +func TestV1CategoriesReturnsJSON(t *testing.T) { + w := httptest.NewRecorder() + r := makeRequest("GET", "/api/v1/categories", nil, "") + + v1Categories(w, r) + + if w.Code != http.StatusOK { + t.Errorf("Status = %d, want 200", w.Code) + } + + if ct := w.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want 'application/json'", ct) + } + + var resp []map[string]interface{} + parseResponse(t, w, &resp) + + if len(resp) == 0 { + t.Error("Categories should not be empty") + } + + // First should be imaging + if resp[0]["key"] != "imaging" { + t.Errorf("First category key = %v, want 'imaging'", resp[0]["key"]) + } +} + +// ============================================================================= +// Localhost Check Test +// ============================================================================= + +func TestIsLocalhost(t *testing.T) { + tests := []struct { + remoteAddr string + expected bool + }{ + {"127.0.0.1:1234", true}, + {"[::1]:1234", true}, + {"192.168.1.100:1234", false}, + {"10.0.0.1:5000", false}, + } + + for _, tc := range tests { + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = tc.remoteAddr + + got := isLocalhost(r) + if got != tc.expected { + t.Errorf("isLocalhost(%q) = %v, want %v", tc.remoteAddr, got, tc.expected) + } + } +} + +// ============================================================================= +// Schedule Helper Tests +// ============================================================================= + +func TestCalculateNextAskFromSchedule(t *testing.T) { + now := time.Date(2026, 2, 28, 10, 0, 0, 0, time.UTC) // Saturday 10:00 + + tests := []struct { + name string + schedule []ScheduleSlot + minNext time.Time // Minimum expected next time + maxNext time.Time // Maximum expected next time + }{ + { + name: "empty schedule", + schedule: []ScheduleSlot{}, + minNext: now, + maxNext: now.Add(25 * time.Hour), + }, + { + name: "daily morning", + schedule: []ScheduleSlot{ + {Days: []string{"sun", "mon", "tue", "wed", "thu", "fri", "sat"}, Times: []string{"09:00"}}, + }, + minNext: now, // Could be tomorrow + maxNext: now.Add(7 * 24 * time.Hour), // Within a week + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := calculateNextAskFromSchedule(tc.schedule, now) + resultTime := time.Unix(result, 0) + + if result == 0 && len(tc.schedule) == 0 { + // Empty schedule returns 0 + return + } + + if resultTime.Before(now) { + t.Errorf("Result %v is before now %v", resultTime, now) + } + }) + } +} diff --git a/lib/lib_test.go b/lib/lib_test.go new file mode 100644 index 0000000..b091223 --- /dev/null +++ b/lib/lib_test.go @@ -0,0 +1,1021 @@ +package lib + +import ( + "database/sql" + "encoding/json" + "os" + "testing" + "time" +) + +// testKey is a fixed 32-byte test key for deterministic encryption +var testKey = []byte("01234567890123456789012345678901") + +// TestMain sets up the test environment +func TestMain(m *testing.M) { + // Set up test master key + masterKey = testKey + + // Create in-memory databases + var err error + db, err = sql.Open("sqlite3", ":memory:") + if err != nil { + panic(err) + } + refDB, err = sql.Open("sqlite3", ":memory:") + if err != nil { + panic(err) + } + authDB, err = sql.Open("sqlite3", ":memory:") + if err != nil { + panic(err) + } + + // Create schema + createTestSchema() + + // Run tests + code := m.Run() + + // Cleanup + db.Close() + refDB.Close() + authDB.Close() + + os.Exit(code) +} + +func createTestSchema() { + // Main DB schema + db.Exec(`CREATE TABLE IF NOT EXISTS entries ( + EntryID TEXT PRIMARY KEY, + DossierID TEXT, + ParentID TEXT, + Category INTEGER, + Type BLOB, + Value BLOB, + Summary BLOB, + Ordinal INTEGER, + Timestamp INTEGER, + TimestampEnd INTEGER, + Status INTEGER, + Tags BLOB, + Data BLOB, + SearchKey BLOB, + SearchKey2 BLOB, + Import INTEGER + )`) + + db.Exec(`CREATE TABLE IF NOT EXISTS access ( + AccessID TEXT PRIMARY KEY, + DossierID TEXT, + GranteeID TEXT, + EntryID TEXT, + Relation INTEGER, + Ops INTEGER, + CreatedAt INTEGER + )`) + + db.Exec(`CREATE TABLE IF NOT EXISTS audit ( + AuditID TEXT PRIMARY KEY, + Actor1ID TEXT, + Actor2ID TEXT, + TargetID TEXT, + Action BLOB, + Details BLOB, + RelationID INTEGER, + Timestamp INTEGER + )`) + + // Reference DB schema + refDB.Exec(`CREATE TABLE IF NOT EXISTS lab_test ( + loinc_id TEXT PRIMARY KEY, + name TEXT, + si_unit TEXT, + direction TEXT, + si_factor INTEGER + )`) + + refDB.Exec(`CREATE TABLE IF NOT EXISTS lab_reference ( + ref_id TEXT PRIMARY KEY, + loinc_id TEXT, + source TEXT, + sex TEXT, + age_days INTEGER, + age_end INTEGER, + ref_low INTEGER, + ref_high INTEGER, + unit TEXT + )`) + + // Auth DB schema + authDB.Exec(`CREATE TABLE IF NOT EXISTS oauth_clients ( + client_id TEXT PRIMARY KEY, + client_secret TEXT, + name TEXT, + redirect_uris TEXT, + created_at INTEGER + )`) + + authDB.Exec(`CREATE TABLE IF NOT EXISTS oauth_codes ( + code TEXT PRIMARY KEY, + client_id TEXT, + dossier_id TEXT, + redirect_uri TEXT, + code_challenge TEXT, + code_challenge_method TEXT, + expires_at INTEGER, + used INTEGER + )`) + + authDB.Exec(`CREATE TABLE IF NOT EXISTS oauth_refresh_tokens ( + token_id TEXT PRIMARY KEY, + client_id TEXT, + dossier_id TEXT, + expires_at INTEGER, + revoked INTEGER, + created_at INTEGER + )`) +} + +// ============================================================================= +// Crypto Tests (Pack/Unpack) +// ============================================================================= + +func TestPackUnpack(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"empty", ""}, + {"simple", "hello world"}, + {"unicode", "Mēs testem unicode: 日本語 🎉"}, + {"long", string(make([]byte, 10000))}, + {"json", `{"key":"value","nested":{"a":1,"b":2}}`}, + {"medical", "Lab result: WBC 8.5 x10^9/L, normal range"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.input == "" { + packed := Pack([]byte(tc.input)) + if packed != nil { + t.Errorf("Pack(empty) should return nil, got %v", packed) + } + return + } + + packed := PackStr(tc.input) + if packed == nil { + t.Errorf("Pack returned nil for non-empty input") + return + } + + unpacked := Unpack(packed) + if string(unpacked) != tc.input { + t.Errorf("Unpack mismatch: got %q, want %q", string(unpacked), tc.input) + } + }) + } +} + +func TestPackDeterministic(t *testing.T) { + // Same input should always produce same output (deterministic nonce) + input := "test deterministic encryption" + + packed1 := PackStr(input) + packed2 := PackStr(input) + + if string(packed1) != string(packed2) { + t.Errorf("Pack should be deterministic, got different outputs") + } +} + +func TestCryptoEncryptDecrypt(t *testing.T) { + tests := []string{ + "", + "simple text", + "longer text with special chars: !@#$%^&*()", + "unicode: Ελληνικά 한국어", + } + + for _, input := range tests { + encrypted := CryptoEncrypt(input) + decrypted := CryptoDecrypt(encrypted) + + if decrypted != input { + t.Errorf("CryptoDecrypt(CryptoEncrypt(%q)) = %q, want %q", input, decrypted, input) + } + } +} + +// ============================================================================= +// Token Tests +// ============================================================================= + +func TestTokenCreateParse(t *testing.T) { + dossierID := "abc123def456gh78" + duration := 4 * time.Hour + + token := TokenCreate(dossierID, duration) + if token == "" { + t.Fatal("TokenCreate returned empty string") + } + + parsed, err := TokenParse(token) + if err != nil { + t.Fatalf("TokenParse failed: %v", err) + } + + if parsed.DossierID != dossierID { + t.Errorf("DossierID = %q, want %q", parsed.DossierID, dossierID) + } + + // Check expiration is roughly correct (within 1 second) + expectedExp := time.Now().Unix() + int64(duration.Seconds()) + if parsed.Exp < expectedExp-1 || parsed.Exp > expectedExp+1 { + t.Errorf("Exp = %d, want ~%d", parsed.Exp, expectedExp) + } +} + +func TestTokenExpired(t *testing.T) { + dossierID := "abc123def456gh78" + duration := -1 * time.Hour // Already expired + + token := TokenCreate(dossierID, duration) + _, err := TokenParse(token) + + if err == nil { + t.Error("TokenParse should fail for expired token") + } + if err.Error() != "token expired" { + t.Errorf("Expected 'token expired' error, got: %v", err) + } +} + +func TestTokenInvalid(t *testing.T) { + tests := []string{ + "", + "invalid", + "not_base64_!!!", + "dG90YWxseV9pbnZhbGlk", // valid base64 but invalid token + } + + for _, tok := range tests { + _, err := TokenParse(tok) + if err == nil { + t.Errorf("TokenParse(%q) should fail", tok) + } + } +} + +// ============================================================================= +// Lab Reference Tests +// ============================================================================= + +func TestToLabScaleFromLabScale(t *testing.T) { + tests := []struct { + input float64 + expected int64 + }{ + {0.0, 0}, + {1.0, 1000000}, + {1.5, 1500000}, + {0.000001, 1}, + {10.123456, 10123456}, + {-5.5, -5500000}, + } + + for _, tc := range tests { + scaled := ToLabScale(tc.input) + if scaled != tc.expected { + t.Errorf("ToLabScale(%f) = %d, want %d", tc.input, scaled, tc.expected) + } + + back := FromLabScale(scaled) + if back != tc.input { + t.Errorf("FromLabScale(%d) = %f, want %f", scaled, back, tc.input) + } + } +} + +func TestMakeRefID(t *testing.T) { + id := MakeRefID("718-7", "CALIPER", "M", 3650) + expected := "718-7|CALIPER|M|3650" + if id != expected { + t.Errorf("MakeRefID = %q, want %q", id, expected) + } +} + +func TestLabRefLookup(t *testing.T) { + // Insert test reference data + refDB.Exec(`INSERT INTO lab_reference (ref_id, loinc_id, source, sex, age_days, age_end, ref_low, ref_high, unit) + VALUES ('test1', '718-7', 'TEST', 'M', 0, 36500, 140000000, 180000000, 'g/L')`) + refDB.Exec(`INSERT INTO lab_reference (ref_id, loinc_id, source, sex, age_days, age_end, ref_low, ref_high, unit) + VALUES ('test2', '718-7', 'TEST', 'F', 0, 36500, 120000000, 160000000, 'g/L')`) + refDB.Exec(`INSERT INTO lab_reference (ref_id, loinc_id, source, sex, age_days, age_end, ref_low, ref_high, unit) + VALUES ('test3', '718-7', 'TEST', '', 0, 36500, 130000000, 170000000, 'g/L')`) + + // Test male reference lookup + ref, err := LabRefLookup("718-7", "M", 10000) + if err != nil { + t.Fatalf("LabRefLookup failed: %v", err) + } + if ref == nil { + t.Fatal("LabRefLookup returned nil for valid query") + } + if ref.Sex != "M" { + t.Errorf("Expected sex-specific reference, got sex=%q", ref.Sex) + } + if FromLabScale(ref.RefLow) != 140.0 { + t.Errorf("RefLow = %f, want 140.0", FromLabScale(ref.RefLow)) + } + + // Test female reference lookup + ref, err = LabRefLookup("718-7", "F", 10000) + if err != nil { + t.Fatalf("LabRefLookup failed: %v", err) + } + if ref.Sex != "F" { + t.Errorf("Expected sex-specific reference, got sex=%q", ref.Sex) + } + if FromLabScale(ref.RefLow) != 120.0 { + t.Errorf("RefLow = %f, want 120.0", FromLabScale(ref.RefLow)) + } + + // Test unisex fallback + ref, err = LabRefLookup("718-7", "X", 10000) + if err != nil { + t.Fatalf("LabRefLookup failed: %v", err) + } + if ref.Sex != "" { + t.Errorf("Expected unisex fallback, got sex=%q", ref.Sex) + } +} + +func TestAgeDays(t *testing.T) { + // DOB: 2000-01-01 00:00:00 UTC + dob := int64(946684800) + // At: 2010-01-01 00:00:00 UTC (10 years = 3652 or 3653 days accounting for leap years) + at := int64(1262304000) + + days := AgeDays(dob, at) + // 10 years is about 3652 days (with leap years) + if days < 3650 || days > 3655 { + t.Errorf("AgeDays = %d, expected ~3652", days) + } +} + +// ============================================================================= +// Category Tests +// ============================================================================= + +func TestCategoryFromString(t *testing.T) { + tests := []struct { + name string + expected int + }{ + {"imaging", CategoryImaging}, + {"lab", CategoryLab}, + {"vital", CategoryVital}, + {"medication", CategoryMedication}, + {"note", CategoryNote}, + {"unknown", 0}, // Not found returns 0 + } + + for _, tc := range tests { + got := CategoryFromString[tc.name] + if got != tc.expected { + t.Errorf("CategoryFromString[%q] = %d, want %d", tc.name, got, tc.expected) + } + } +} + +func TestCategoryName(t *testing.T) { + if name := CategoryName(CategoryImaging); name != "imaging" { + t.Errorf("CategoryName(CategoryImaging) = %q, want 'imaging'", name) + } + if name := CategoryName(CategoryLab); name != "lab" { + t.Errorf("CategoryName(CategoryLab) = %q, want 'lab'", name) + } + if name := CategoryName(999); name != "unknown" { + t.Errorf("CategoryName(999) = %q, want 'unknown'", name) + } +} + +func TestCategoryKey(t *testing.T) { + if key := CategoryKey(1); key != "category001" { + t.Errorf("CategoryKey(1) = %q, want 'category001'", key) + } + if key := CategoryKey(15); key != "category015" { + t.Errorf("CategoryKey(15) = %q, want 'category015'", key) + } +} + +func TestCategories(t *testing.T) { + cats := Categories() + if len(cats) == 0 { + t.Error("Categories() returned empty slice") + } + + // Check first category is imaging + if cats[0].Name != "imaging" { + t.Errorf("First category = %q, want 'imaging'", cats[0].Name) + } + + // Check that all categories have types + for _, c := range cats { + if len(c.Types) == 0 && c.Name != "" { + t.Logf("Warning: category %q has no types", c.Name) + } + } +} + +// ============================================================================= +// ID Generation Tests +// ============================================================================= + +func TestNewID(t *testing.T) { + id1 := NewID() + id2 := NewID() + + // Should be 16 hex chars + if len(id1) != 16 { + t.Errorf("NewID length = %d, want 16", len(id1)) + } + + // Should be unique + if id1 == id2 { + t.Error("NewID should generate unique IDs") + } + + // Should be valid hex + for _, c := range id1 { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Errorf("NewID contains invalid hex char: %c", c) + } + } +} + +func TestFormatIDParseID(t *testing.T) { + tests := []int64{ + 0, + 1, + 255, + 65536, + 1234567890123456, + 9223372036854775807, // max int64 + } + + for _, id := range tests { + formatted := FormatID(id) + parsed := ParseID(formatted) + if parsed != id { + t.Errorf("ParseID(FormatID(%d)) = %d, want %d", id, parsed, id) + } + } +} + +func TestParseIDInvalid(t *testing.T) { + tests := []string{ + "", + "abc", + "0123456789abcde", // 15 chars + "0123456789abcdefg", // 17 chars + "zzzzzzzzzzzzzzzz", // invalid hex + } + + for _, s := range tests { + if result := ParseID(s); result != 0 { + t.Errorf("ParseID(%q) = %d, want 0", s, result) + } + } +} + +// ============================================================================= +// Entry CRUD Tests +// ============================================================================= + +func TestEntryWriteRead(t *testing.T) { + dossierID := NewID() + + // Create dossier first (category 0 entry) + dossier := &Entry{ + DossierID: dossierID, + EntryID: dossierID, + Category: 0, + Type: "dossier", + Summary: "Test User", + SearchKey: "test@example.com", + } + if err := EntryWrite("", dossier); err != nil { + t.Fatalf("EntryWrite dossier failed: %v", err) + } + + // Create a lab entry + labEntry := &Entry{ + DossierID: dossierID, + Category: CategoryLab, + Type: "WBC", + Value: "8.5", + Summary: "WBC: 8.5 x10^9/L", + Timestamp: time.Now().Unix(), + Data: `{"unit":"x10^9/L","reference":"4.5-11.0"}`, + } + if err := EntryWrite("", labEntry); err != nil { + t.Fatalf("EntryWrite lab failed: %v", err) + } + + // Entry should have been assigned an ID + if labEntry.EntryID == "" { + t.Error("EntryWrite did not assign EntryID") + } + + // Read it back + entries, err := EntryRead("", dossierID, &Filter{Category: CategoryLab}) + if err != nil { + t.Fatalf("EntryRead failed: %v", err) + } + if len(entries) != 1 { + t.Fatalf("EntryRead returned %d entries, want 1", len(entries)) + } + + got := entries[0] + if got.Type != "WBC" { + t.Errorf("Type = %q, want 'WBC'", got.Type) + } + if got.Value != "8.5" { + t.Errorf("Value = %q, want '8.5'", got.Value) + } + if got.Data == "" { + t.Error("Data should not be empty after read") + } +} + +func TestEntryDelete(t *testing.T) { + dossierID := NewID() + + // Create dossier + dossier := &Entry{ + DossierID: dossierID, + EntryID: dossierID, + Category: 0, + Type: "dossier", + } + EntryWrite("", dossier) + + // Create entries + e1 := &Entry{DossierID: dossierID, Category: CategoryNote, Type: "test", Summary: "Note 1", Timestamp: 1} + e2 := &Entry{DossierID: dossierID, Category: CategoryNote, Type: "test", Summary: "Note 2", Timestamp: 2} + EntryWrite("", e1) + EntryWrite("", e2) + + // Verify both exist + entries, _ := EntryRead("", dossierID, &Filter{Category: CategoryNote}) + if len(entries) != 2 { + t.Fatalf("Expected 2 entries, got %d", len(entries)) + } + + // Delete one + err := EntryDelete("", dossierID, &Filter{EntryID: e1.EntryID, Category: -1}) + if err != nil { + t.Fatalf("EntryDelete failed: %v", err) + } + + // Verify only one remains + entries, _ = EntryRead("", dossierID, &Filter{Category: CategoryNote}) + if len(entries) != 1 { + t.Errorf("Expected 1 entry after delete, got %d", len(entries)) + } + if entries[0].EntryID == e1.EntryID { + t.Error("Deleted entry should not be returned") + } +} + +func TestEntryFilterByType(t *testing.T) { + dossierID := NewID() + + // Create dossier + dossier := &Entry{DossierID: dossierID, EntryID: dossierID, Category: 0, Type: "dossier"} + EntryWrite("", dossier) + + // Create various entries + EntryWrite("", &Entry{DossierID: dossierID, Category: CategoryLab, Type: "WBC", Value: "8.0", Timestamp: 1}) + EntryWrite("", &Entry{DossierID: dossierID, Category: CategoryLab, Type: "RBC", Value: "4.5", Timestamp: 2}) + EntryWrite("", &Entry{DossierID: dossierID, Category: CategoryLab, Type: "WBC", Value: "8.5", Timestamp: 3}) + + // Filter by type + entries, err := EntryRead("", dossierID, &Filter{Category: CategoryLab, Type: "WBC"}) + if err != nil { + t.Fatalf("EntryRead failed: %v", err) + } + if len(entries) != 2 { + t.Errorf("Expected 2 WBC entries, got %d", len(entries)) + } + for _, e := range entries { + if e.Type != "WBC" { + t.Errorf("Filter returned wrong type: %q", e.Type) + } + } +} + +func TestEntryFilterByDateRange(t *testing.T) { + dossierID := NewID() + + // Create dossier + dossier := &Entry{DossierID: dossierID, EntryID: dossierID, Category: 0, Type: "dossier"} + EntryWrite("", dossier) + + // Create entries with different timestamps + EntryWrite("", &Entry{DossierID: dossierID, Category: CategoryLab, Type: "test", Timestamp: 1000}) + EntryWrite("", &Entry{DossierID: dossierID, Category: CategoryLab, Type: "test", Timestamp: 2000}) + EntryWrite("", &Entry{DossierID: dossierID, Category: CategoryLab, Type: "test", Timestamp: 3000}) + + // Filter by date range + entries, _ := EntryRead("", dossierID, &Filter{Category: CategoryLab, FromDate: 1500, ToDate: 2500}) + if len(entries) != 1 { + t.Errorf("Expected 1 entry in range, got %d", len(entries)) + } + if len(entries) > 0 && entries[0].Timestamp != 2000 { + t.Errorf("Expected timestamp 2000, got %d", entries[0].Timestamp) + } +} + +// ============================================================================= +// Tracker Tests +// ============================================================================= + +func TestTrackerAddQuery(t *testing.T) { + dossierID := NewID() + + // Create dossier + dossier := &Entry{DossierID: dossierID, EntryID: dossierID, Category: 0, Type: "dossier"} + EntryWrite("", dossier) + + // Create a tracker + tracker := &Tracker{ + DossierID: dossierID, + Category: "vital", + Type: "blood_pressure", + Question: "What is your blood pressure today?", + InputType: "blood_pressure", + Frequency: "daily", + Active: true, + } + + err := TrackerAdd(tracker) + if err != nil { + t.Fatalf("TrackerAdd failed: %v", err) + } + if tracker.TrackerID == "" { + t.Error("TrackerAdd should assign ID") + } + + // Query active trackers + trackers, err := TrackerQueryActive(dossierID) + if err != nil { + t.Fatalf("TrackerQueryActive failed: %v", err) + } + if len(trackers) != 1 { + t.Errorf("Expected 1 tracker, got %d", len(trackers)) + } + if trackers[0].Question != "What is your blood pressure today?" { + t.Errorf("Wrong question: %q", trackers[0].Question) + } +} + +func TestTrackerRespond(t *testing.T) { + dossierID := NewID() + + // Create dossier + dossier := &Entry{DossierID: dossierID, EntryID: dossierID, Category: 0, Type: "dossier"} + EntryWrite("", dossier) + + // Create tracker + tracker := &Tracker{ + DossierID: dossierID, + Category: "vital", + Type: "weight", + Question: "What is your weight?", + InputType: "number", + Frequency: "daily", + Active: true, + } + TrackerAdd(tracker) + + // Respond + response := `{"value":75.5}` + err := TrackerRespond(tracker.TrackerID, response, "75.5 kg") + if err != nil { + t.Fatalf("TrackerRespond failed: %v", err) + } + + // Check tracker was updated + updated, err := TrackerGet(tracker.TrackerID) + if err != nil { + t.Fatalf("TrackerGet failed: %v", err) + } + if updated.LastResponse != response { + t.Errorf("LastResponse = %q, want %q", updated.LastResponse, response) + } + if updated.LastResponseRaw != "75.5 kg" { + t.Errorf("LastResponseRaw = %q, want '75.5 kg'", updated.LastResponseRaw) + } + if updated.NextAsk <= 0 { + t.Error("NextAsk should be set after response") + } +} + +func TestTrackerDismiss(t *testing.T) { + dossierID := NewID() + + // Create dossier + dossier := &Entry{DossierID: dossierID, EntryID: dossierID, Category: 0, Type: "dossier"} + EntryWrite("", dossier) + + // Create tracker + tracker := &Tracker{ + DossierID: dossierID, + Category: "symptom", + Type: "headache", + Question: "Any headaches today?", + InputType: "boolean", + Active: true, + } + TrackerAdd(tracker) + + // Dismiss it + err := TrackerDismiss(tracker.TrackerID) + if err != nil { + t.Fatalf("TrackerDismiss failed: %v", err) + } + + // Should not appear in active query + active, _ := TrackerQueryActive(dossierID) + for _, tr := range active { + if tr.TrackerID == tracker.TrackerID { + t.Error("Dismissed tracker should not appear in active query") + } + } + + // But should appear in all query + all, _ := TrackerQueryAll(dossierID) + found := false + for _, tr := range all { + if tr.TrackerID == tracker.TrackerID { + found = true + if !tr.Dismissed { + t.Error("Tracker should be marked as dismissed") + } + } + } + if !found { + t.Error("Tracker should appear in all query") + } +} + +// ============================================================================= +// RBAC Tests +// ============================================================================= + +func TestCheckAccessOwnDossier(t *testing.T) { + dossierID := NewID() + + // User should have full access to their own dossier + if !CheckAccess(dossierID, dossierID, "", PermRead) { + t.Error("User should have read access to own dossier") + } + if !CheckAccess(dossierID, dossierID, "", PermWrite) { + t.Error("User should have write access to own dossier") + } + if !CheckAccess(dossierID, dossierID, "", PermDelete) { + t.Error("User should have delete access to own dossier") + } +} + +func TestCheckAccessSystemUser(t *testing.T) { + dossierID := NewID() + + // Empty accessor (system) should have full access + if !CheckAccess("", dossierID, "", PermRead) { + t.Error("System user should have read access") + } + if !CheckAccess("", dossierID, "", PermWrite) { + t.Error("System user should have write access") + } + + // SystemAccessorID should also have access + if !CheckAccess(SystemAccessorID, dossierID, "", PermRead) { + t.Error("SystemAccessorID should have read access") + } +} + +func TestCheckAccessGranted(t *testing.T) { + owner := NewID() + grantee := NewID() + + // Grant read access + err := GrantAccess(owner, grantee, owner, PermRead, 1) + if err != nil { + t.Fatalf("GrantAccess failed: %v", err) + } + + // Grantee should have read access + if !CheckAccess(grantee, owner, "", PermRead) { + t.Error("Grantee should have read access after grant") + } + + // But not write access + if CheckAccess(grantee, owner, "", PermWrite) { + t.Error("Grantee should not have write access") + } +} + +func TestCheckAccessNotGranted(t *testing.T) { + owner := NewID() + stranger := NewID() + + // Stranger should not have access + if CheckAccess(stranger, owner, "", PermRead) { + t.Error("Stranger should not have read access") + } +} + +// ============================================================================= +// Dossier Helper Tests +// ============================================================================= + +func TestDossierFromEntry(t *testing.T) { + entry := &Entry{ + EntryID: "abc123def456gh78", + DossierID: "abc123def456gh78", + Category: 0, + Type: "dossier", + Summary: "Test User", + SearchKey: "test@example.com", + Data: `{"dob":"1990-05-15","sex":1,"lang":"en","timezone":"America/New_York"}`, + } + + d := DossierFromEntry(entry) + + if d.DossierID != "abc123def456gh78" { + t.Errorf("DossierID = %q, want 'abc123def456gh78'", d.DossierID) + } + if d.Name != "Test User" { + t.Errorf("Name = %q, want 'Test User'", d.Name) + } + if d.Email != "test@example.com" { + t.Errorf("Email = %q, want 'test@example.com'", d.Email) + } + if d.DateOfBirth != "1990-05-15" { + t.Errorf("DateOfBirth = %q, want '1990-05-15'", d.DateOfBirth) + } + if d.Sex != 1 { + t.Errorf("Sex = %d, want 1", d.Sex) + } + if d.Preferences.Language != "en" { + t.Errorf("Preferences.Language = %q, want 'en'", d.Preferences.Language) + } +} + +func TestDossierSexKey(t *testing.T) { + tests := []struct { + sex int + expected string + }{ + {0, ""}, + {1, "sex_male"}, + {2, "sex_female"}, + {9, "sex_na"}, + {5, ""}, + } + + for _, tc := range tests { + d := &Dossier{Sex: tc.sex} + if got := d.SexKey(); got != tc.expected { + t.Errorf("SexKey() for sex=%d: got %q, want %q", tc.sex, got, tc.expected) + } + } +} + +// ============================================================================= +// Normalize Tests +// ============================================================================= + +func TestNormalizeKey(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"POCT Glucose", "glucose"}, + {"Hemoglobin, Whole Blood", "hemoglobin"}, + {"WBC, Blood", "wbc"}, + {" Potassium ", "potassium"}, + {"Cholesterol, Total", "cholesterol, total"}, // comma not stripped if not specimen + {"GLUCOSE", "glucose"}, + } + + for _, tc := range tests { + got := normalizeKey(tc.input) + if got != tc.expected { + t.Errorf("normalizeKey(%q) = %q, want %q", tc.input, got, tc.expected) + } + } +} + +// ============================================================================= +// v2 Helper Tests +// ============================================================================= + +func TestResponseToValue(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {`{"value":75.5}`, "75.5"}, + {`{"systolic":120,"diastolic":80}`, "120/80"}, + {"not json", "not json"}, + {`{}`, "{}"}, + } + + for _, tc := range tests { + got := responseToValue(tc.input) + if got != tc.expected { + t.Errorf("responseToValue(%q) = %q, want %q", tc.input, got, tc.expected) + } + } +} + +func TestCalculateNextAsk(t *testing.T) { + now := time.Now().Unix() + + tests := []struct { + frequency string + minDelta int64 + maxDelta int64 + }{ + {"once", -now, 1}, // Should be 0 or very small + {"daily", 86000, 87000}, // ~24 hours + {"twice_daily", 43000, 44000}, // ~12 hours + {"weekly", 600000, 610000}, // ~7 days + {"unknown", 86000, 87000}, // default to daily + } + + for _, tc := range tests { + next := calculateNextAsk(tc.frequency, "", now) + delta := next - now + + if delta < tc.minDelta || delta > tc.maxDelta { + t.Errorf("calculateNextAsk(%q) delta=%d, want %d-%d", tc.frequency, delta, tc.minDelta, tc.maxDelta) + } + } +} + +// ============================================================================= +// JSON Marshaling Tests +// ============================================================================= + +func TestTrackerDataMarshal(t *testing.T) { + d := trackerData{ + Frequency: "daily", + TimeOfDay: "morning", + InputType: "number", + } + + data, err := json.Marshal(d) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var d2 trackerData + if err := json.Unmarshal(data, &d2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if d2.Frequency != "daily" { + t.Errorf("Frequency = %q, want 'daily'", d2.Frequency) + } +} + +func TestDossierPreferencesMarshal(t *testing.T) { + p := DossierPreferences{ + Language: "en", + WeightUnit: "kg", + HeightUnit: "cm", + IsProvider: true, + } + + data, err := json.Marshal(p) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var p2 DossierPreferences + if err := json.Unmarshal(data, &p2); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if p2.Language != "en" || p2.IsProvider != true { + t.Errorf("Unmarshal mismatch: got %+v", p2) + } +}