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.
This commit is contained in:
parent
93643d285b
commit
155d24ec2e
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue