1022 lines
25 KiB
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)
|
|
}
|
|
}
|