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:
James 2026-02-28 07:20:44 -05:00
parent 93643d285b
commit 155d24ec2e
2 changed files with 1615 additions and 0 deletions

594
api/api_test.go Normal file
View File

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

1021
lib/lib_test.go Normal file

File diff suppressed because it is too large Load Diff