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) } }