inou/lib/lib_test.go

1022 lines
25 KiB
Go

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