mirror of
https://github.com/AmanTahiliani/FHIR-Sandbox.git
synced 2026-08-07 19:56:17 -04:00
chore: remove DB and binary from repo, add .gitignore to exclude artifacts
This commit is contained in:
340
app/db/clinical.go
Normal file
340
app/db/clinical.go
Normal file
@@ -0,0 +1,340 @@
|
||||
// clinical.go provides persistence operations for FHIR clinical resources:
|
||||
// Observation, Condition, DocumentReference, and PatientSync.
|
||||
//
|
||||
// All upsert methods use (fhir_id, ehr_url) as the natural deduplication key,
|
||||
// matching the same pattern used for users. On conflict the stored row is
|
||||
// overwritten with the latest data from the EHR and synced_at is updated.
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Observation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// UpsertObservation inserts or replaces an Observation record keyed on
|
||||
// (fhir_id, ehr_url). Returns the internal UUID.
|
||||
func (s *Store) UpsertObservation(o *models.Observation) (string, error) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
var existingID string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id FROM observations WHERE fhir_id = ? AND ehr_url = ?`,
|
||||
o.FHIRID, o.EHRURL,
|
||||
).Scan(&existingID)
|
||||
|
||||
if err == nil {
|
||||
_, err = s.db.Exec(`
|
||||
UPDATE observations SET
|
||||
patient_fhir_id = ?,
|
||||
status = ?,
|
||||
category = ?,
|
||||
code_text = ?,
|
||||
code_system = ?,
|
||||
code_code = ?,
|
||||
effective_date = ?,
|
||||
value_quantity = ?,
|
||||
value_unit = ?,
|
||||
value_string = ?,
|
||||
synced_at = ?
|
||||
WHERE id = ?`,
|
||||
o.PatientFHIRID, o.Status, o.Category,
|
||||
o.CodeText, o.CodeSystem, o.CodeCode,
|
||||
o.EffectiveDate, o.ValueQuantity, o.ValueUnit, o.ValueString,
|
||||
now, existingID,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("db: update observation %s: %w", existingID, err)
|
||||
}
|
||||
return existingID, nil
|
||||
}
|
||||
|
||||
id := uuid.NewString()
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO observations (
|
||||
id, fhir_id, ehr_url, patient_fhir_id, status, category,
|
||||
code_text, code_system, code_code, effective_date,
|
||||
value_quantity, value_unit, value_string, synced_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, o.FHIRID, o.EHRURL, o.PatientFHIRID, o.Status, o.Category,
|
||||
o.CodeText, o.CodeSystem, o.CodeCode, o.EffectiveDate,
|
||||
o.ValueQuantity, o.ValueUnit, o.ValueString, now,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("db: insert observation fhir_id=%s: %w", o.FHIRID, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ListObservations returns all Observations for the given patient, newest first.
|
||||
func (s *Store) ListObservations(patientFHIRID, ehrURL string) ([]models.Observation, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, fhir_id, ehr_url, patient_fhir_id, status, category,
|
||||
code_text, code_system, code_code, effective_date,
|
||||
value_quantity, value_unit, value_string, synced_at
|
||||
FROM observations
|
||||
WHERE patient_fhir_id = ? AND ehr_url = ?
|
||||
ORDER BY effective_date DESC`,
|
||||
patientFHIRID, ehrURL,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: list observations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Observation
|
||||
for rows.Next() {
|
||||
var o models.Observation
|
||||
if err := rows.Scan(
|
||||
&o.ID, &o.FHIRID, &o.EHRURL, &o.PatientFHIRID, &o.Status, &o.Category,
|
||||
&o.CodeText, &o.CodeSystem, &o.CodeCode, &o.EffectiveDate,
|
||||
&o.ValueQuantity, &o.ValueUnit, &o.ValueString, &o.SyncedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("db: scan observation: %w", err)
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Condition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// UpsertCondition inserts or updates a Condition record keyed on (fhir_id, ehr_url).
|
||||
func (s *Store) UpsertCondition(c *models.Condition) (string, error) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
var existingID string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id FROM conditions WHERE fhir_id = ? AND ehr_url = ?`,
|
||||
c.FHIRID, c.EHRURL,
|
||||
).Scan(&existingID)
|
||||
|
||||
if err == nil {
|
||||
_, err = s.db.Exec(`
|
||||
UPDATE conditions SET
|
||||
patient_fhir_id = ?,
|
||||
clinical_status = ?,
|
||||
verification_status = ?,
|
||||
category = ?,
|
||||
code_text = ?,
|
||||
code_system = ?,
|
||||
code_code = ?,
|
||||
onset_date = ?,
|
||||
recorded_date = ?,
|
||||
synced_at = ?
|
||||
WHERE id = ?`,
|
||||
c.PatientFHIRID, c.ClinicalStatus, c.VerificationStatus,
|
||||
c.Category, c.CodeText, c.CodeSystem, c.CodeCode,
|
||||
c.OnsetDate, c.RecordedDate, now, existingID,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("db: update condition %s: %w", existingID, err)
|
||||
}
|
||||
return existingID, nil
|
||||
}
|
||||
|
||||
id := uuid.NewString()
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO conditions (
|
||||
id, fhir_id, ehr_url, patient_fhir_id,
|
||||
clinical_status, verification_status, category,
|
||||
code_text, code_system, code_code,
|
||||
onset_date, recorded_date, synced_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, c.FHIRID, c.EHRURL, c.PatientFHIRID,
|
||||
c.ClinicalStatus, c.VerificationStatus, c.Category,
|
||||
c.CodeText, c.CodeSystem, c.CodeCode,
|
||||
c.OnsetDate, c.RecordedDate, now,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("db: insert condition fhir_id=%s: %w", c.FHIRID, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ListConditions returns all Conditions for the given patient, newest first.
|
||||
func (s *Store) ListConditions(patientFHIRID, ehrURL string) ([]models.Condition, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, fhir_id, ehr_url, patient_fhir_id,
|
||||
clinical_status, verification_status, category,
|
||||
code_text, code_system, code_code,
|
||||
onset_date, recorded_date, synced_at
|
||||
FROM conditions
|
||||
WHERE patient_fhir_id = ? AND ehr_url = ?
|
||||
ORDER BY recorded_date DESC`,
|
||||
patientFHIRID, ehrURL,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: list conditions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Condition
|
||||
for rows.Next() {
|
||||
var c models.Condition
|
||||
if err := rows.Scan(
|
||||
&c.ID, &c.FHIRID, &c.EHRURL, &c.PatientFHIRID,
|
||||
&c.ClinicalStatus, &c.VerificationStatus, &c.Category,
|
||||
&c.CodeText, &c.CodeSystem, &c.CodeCode,
|
||||
&c.OnsetDate, &c.RecordedDate, &c.SyncedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("db: scan condition: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DocumentReference
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// UpsertDocumentReference inserts or updates a DocumentReference record.
|
||||
func (s *Store) UpsertDocumentReference(d *models.DocumentReference) (string, error) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
var existingID string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id FROM document_references WHERE fhir_id = ? AND ehr_url = ?`,
|
||||
d.FHIRID, d.EHRURL,
|
||||
).Scan(&existingID)
|
||||
|
||||
if err == nil {
|
||||
_, err = s.db.Exec(`
|
||||
UPDATE document_references SET
|
||||
patient_fhir_id = ?,
|
||||
status = ?,
|
||||
doc_status = ?,
|
||||
type_text = ?,
|
||||
type_system = ?,
|
||||
type_code = ?,
|
||||
category = ?,
|
||||
date = ?,
|
||||
description = ?,
|
||||
content_type = ?,
|
||||
content_url = ?,
|
||||
content_data = ?,
|
||||
synced_at = ?
|
||||
WHERE id = ?`,
|
||||
d.PatientFHIRID, d.Status, d.DocStatus,
|
||||
d.TypeText, d.TypeSystem, d.TypeCode,
|
||||
d.Category, d.Date, d.Description,
|
||||
d.ContentType, d.ContentURL, d.ContentData,
|
||||
now, existingID,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("db: update document_reference %s: %w", existingID, err)
|
||||
}
|
||||
return existingID, nil
|
||||
}
|
||||
|
||||
id := uuid.NewString()
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO document_references (
|
||||
id, fhir_id, ehr_url, patient_fhir_id,
|
||||
status, doc_status, type_text, type_system, type_code,
|
||||
category, date, description,
|
||||
content_type, content_url, content_data, synced_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, d.FHIRID, d.EHRURL, d.PatientFHIRID,
|
||||
d.Status, d.DocStatus, d.TypeText, d.TypeSystem, d.TypeCode,
|
||||
d.Category, d.Date, d.Description,
|
||||
d.ContentType, d.ContentURL, d.ContentData, now,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("db: insert document_reference fhir_id=%s: %w", d.FHIRID, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ListDocumentReferences returns all DocumentReferences for the given patient,
|
||||
// newest first.
|
||||
func (s *Store) ListDocumentReferences(patientFHIRID, ehrURL string) ([]models.DocumentReference, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, fhir_id, ehr_url, patient_fhir_id,
|
||||
status, doc_status, type_text, type_system, type_code,
|
||||
category, date, description,
|
||||
content_type, content_url, content_data, synced_at
|
||||
FROM document_references
|
||||
WHERE patient_fhir_id = ? AND ehr_url = ?
|
||||
ORDER BY date DESC`,
|
||||
patientFHIRID, ehrURL,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: list document_references: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.DocumentReference
|
||||
for rows.Next() {
|
||||
var d models.DocumentReference
|
||||
if err := rows.Scan(
|
||||
&d.ID, &d.FHIRID, &d.EHRURL, &d.PatientFHIRID,
|
||||
&d.Status, &d.DocStatus, &d.TypeText, &d.TypeSystem, &d.TypeCode,
|
||||
&d.Category, &d.Date, &d.Description,
|
||||
&d.ContentType, &d.ContentURL, &d.ContentData, &d.SyncedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("db: scan document_reference: %w", err)
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PatientSync
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RecordSync inserts a new sync event for a patient.
|
||||
func (s *Store) RecordSync(patientFHIRID, ehrURL string, obsCount, condCount, docCount int) (*models.PatientSync, error) {
|
||||
ps := &models.PatientSync{
|
||||
ID: uuid.NewString(),
|
||||
PatientFHIRID: patientFHIRID,
|
||||
EHRURL: ehrURL,
|
||||
SyncedAt: time.Now().UTC(),
|
||||
ObsCount: obsCount,
|
||||
CondCount: condCount,
|
||||
DocCount: docCount,
|
||||
}
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO patient_syncs (id, patient_fhir_id, ehr_url, synced_at, obs_count, cond_count, doc_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
ps.ID, ps.PatientFHIRID, ps.EHRURL, ps.SyncedAt, ps.ObsCount, ps.CondCount, ps.DocCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: record sync for patient %s: %w", patientFHIRID, err)
|
||||
}
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
// LatestSync retrieves the most recent sync event for a patient.
|
||||
// Returns nil, nil when no sync has ever been performed.
|
||||
func (s *Store) LatestSync(patientFHIRID, ehrURL string) (*models.PatientSync, error) {
|
||||
ps := &models.PatientSync{}
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, patient_fhir_id, ehr_url, synced_at, obs_count, cond_count, doc_count
|
||||
FROM patient_syncs
|
||||
WHERE patient_fhir_id = ? AND ehr_url = ?
|
||||
ORDER BY synced_at DESC
|
||||
LIMIT 1`,
|
||||
patientFHIRID, ehrURL,
|
||||
).Scan(
|
||||
&ps.ID, &ps.PatientFHIRID, &ps.EHRURL, &ps.SyncedAt,
|
||||
&ps.ObsCount, &ps.CondCount, &ps.DocCount,
|
||||
)
|
||||
if err != nil {
|
||||
if err.Error() == "sql: no rows in result set" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("db: latest sync for patient %s: %w", patientFHIRID, err)
|
||||
}
|
||||
return ps, nil
|
||||
}
|
||||
345
app/db/clinical_test.go
Normal file
345
app/db/clinical_test.go
Normal file
@@ -0,0 +1,345 @@
|
||||
package db_test
|
||||
|
||||
// clinical_test.go — tests for clinical resource persistence (Observation,
|
||||
// Condition, DocumentReference, PatientSync).
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
|
||||
)
|
||||
|
||||
const (
|
||||
testEHRURL = "https://ehr.example.com/fhir"
|
||||
testPatientID = "patient-clinical-001"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Observation tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestUpsertObservation_NewAndUpdate(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
qty := 98.6
|
||||
obs := &models.Observation{
|
||||
FHIRID: "obs-001",
|
||||
EHRURL: testEHRURL,
|
||||
PatientFHIRID: testPatientID,
|
||||
Status: "final",
|
||||
Category: "vital-signs",
|
||||
CodeText: "Body Temperature",
|
||||
CodeSystem: "http://loinc.org",
|
||||
CodeCode: "8310-5",
|
||||
EffectiveDate: "2024-01-15",
|
||||
ValueQuantity: &qty,
|
||||
ValueUnit: "°F",
|
||||
}
|
||||
|
||||
id1, err := store.UpsertObservation(obs)
|
||||
if err != nil {
|
||||
t.Fatalf("initial UpsertObservation: %v", err)
|
||||
}
|
||||
if id1 == "" {
|
||||
t.Fatal("expected non-empty ID")
|
||||
}
|
||||
|
||||
// Update: change value and status.
|
||||
newQty := 99.1
|
||||
obs.ValueQuantity = &newQty
|
||||
obs.Status = "amended"
|
||||
id2, err := store.UpsertObservation(obs)
|
||||
if err != nil {
|
||||
t.Fatalf("update UpsertObservation: %v", err)
|
||||
}
|
||||
|
||||
// ID must be stable across upserts.
|
||||
if id1 != id2 {
|
||||
t.Errorf("ID changed on upsert: was %q, got %q", id1, id2)
|
||||
}
|
||||
|
||||
// Read back and confirm updated fields.
|
||||
rows, err := store.ListObservations(testPatientID, testEHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ListObservations: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 observation, got %d", len(rows))
|
||||
}
|
||||
got := rows[0]
|
||||
if got.Status != "amended" {
|
||||
t.Errorf("Status: got %q, want %q", got.Status, "amended")
|
||||
}
|
||||
if got.ValueQuantity == nil || *got.ValueQuantity != 99.1 {
|
||||
t.Errorf("ValueQuantity: got %v, want 99.1", got.ValueQuantity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertObservation_TenantIsolation(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
makeObs := func(ehrURL string) *models.Observation {
|
||||
return &models.Observation{
|
||||
FHIRID: "obs-shared",
|
||||
EHRURL: ehrURL,
|
||||
PatientFHIRID: testPatientID,
|
||||
Status: "final",
|
||||
}
|
||||
}
|
||||
|
||||
id1, err := store.UpsertObservation(makeObs("https://ehr-a.example.com/fhir"))
|
||||
if err != nil {
|
||||
t.Fatalf("upsert A: %v", err)
|
||||
}
|
||||
id2, err := store.UpsertObservation(makeObs("https://ehr-b.example.com/fhir"))
|
||||
if err != nil {
|
||||
t.Fatalf("upsert B: %v", err)
|
||||
}
|
||||
if id1 == id2 {
|
||||
t.Error("expected different IDs for same fhir_id at different ehr_urls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListObservations_Empty(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
rows, err := store.ListObservations("no-such-patient", testEHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ListObservations: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("expected 0 rows, got %d", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListObservations_OrderedNewestFirst(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
for _, item := range []struct {
|
||||
id string
|
||||
date string
|
||||
}{
|
||||
{"obs-a", "2024-01-01"},
|
||||
{"obs-b", "2024-06-15"},
|
||||
{"obs-c", "2023-12-31"},
|
||||
} {
|
||||
_, err := store.UpsertObservation(&models.Observation{
|
||||
FHIRID: item.id,
|
||||
EHRURL: testEHRURL,
|
||||
PatientFHIRID: testPatientID,
|
||||
Status: "final",
|
||||
EffectiveDate: item.date,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertObservation %s: %v", item.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := store.ListObservations(testPatientID, testEHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ListObservations: %v", err)
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("expected 3 rows, got %d", len(rows))
|
||||
}
|
||||
// Newest first: obs-b (Jun) > obs-a (Jan) > obs-c (Dec 2023)
|
||||
if rows[0].FHIRID != "obs-b" {
|
||||
t.Errorf("first row: got %q, want obs-b", rows[0].FHIRID)
|
||||
}
|
||||
if rows[2].FHIRID != "obs-c" {
|
||||
t.Errorf("last row: got %q, want obs-c", rows[2].FHIRID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Condition tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestUpsertCondition_NewAndUpdate(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
cond := &models.Condition{
|
||||
FHIRID: "cond-001",
|
||||
EHRURL: testEHRURL,
|
||||
PatientFHIRID: testPatientID,
|
||||
ClinicalStatus: "active",
|
||||
VerificationStatus: "confirmed",
|
||||
Category: "problem-list-item",
|
||||
CodeText: "Hypertension",
|
||||
CodeSystem: "http://snomed.info/sct",
|
||||
CodeCode: "38341003",
|
||||
OnsetDate: "2020-03-01",
|
||||
RecordedDate: "2020-03-05",
|
||||
}
|
||||
|
||||
id1, err := store.UpsertCondition(cond)
|
||||
if err != nil {
|
||||
t.Fatalf("initial UpsertCondition: %v", err)
|
||||
}
|
||||
|
||||
// Update clinical status to resolved.
|
||||
cond.ClinicalStatus = "resolved"
|
||||
id2, err := store.UpsertCondition(cond)
|
||||
if err != nil {
|
||||
t.Fatalf("update UpsertCondition: %v", err)
|
||||
}
|
||||
|
||||
if id1 != id2 {
|
||||
t.Errorf("ID changed on upsert: was %q, got %q", id1, id2)
|
||||
}
|
||||
|
||||
rows, err := store.ListConditions(testPatientID, testEHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ListConditions: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 condition, got %d", len(rows))
|
||||
}
|
||||
if rows[0].ClinicalStatus != "resolved" {
|
||||
t.Errorf("ClinicalStatus: got %q, want resolved", rows[0].ClinicalStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListConditions_Empty(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
rows, err := store.ListConditions("no-such-patient", testEHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ListConditions: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("expected 0, got %d", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DocumentReference tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestUpsertDocumentReference_NewAndUpdate(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
doc := &models.DocumentReference{
|
||||
FHIRID: "doc-001",
|
||||
EHRURL: testEHRURL,
|
||||
PatientFHIRID: testPatientID,
|
||||
Status: "current",
|
||||
DocStatus: "final",
|
||||
TypeText: "Discharge Summary",
|
||||
TypeSystem: "http://loinc.org",
|
||||
TypeCode: "18842-5",
|
||||
Date: "2024-02-10",
|
||||
Description: "Hospital discharge summary",
|
||||
ContentType: "text/plain",
|
||||
ContentURL: "https://ehr.example.com/fhir/Binary/bin-001",
|
||||
}
|
||||
|
||||
id1, err := store.UpsertDocumentReference(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertDocumentReference: %v", err)
|
||||
}
|
||||
|
||||
// Supersede the document.
|
||||
doc.Status = "superseded"
|
||||
id2, err := store.UpsertDocumentReference(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("update UpsertDocumentReference: %v", err)
|
||||
}
|
||||
|
||||
if id1 != id2 {
|
||||
t.Errorf("ID changed on upsert: was %q, got %q", id1, id2)
|
||||
}
|
||||
|
||||
rows, err := store.ListDocumentReferences(testPatientID, testEHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDocumentReferences: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 doc, got %d", len(rows))
|
||||
}
|
||||
if rows[0].Status != "superseded" {
|
||||
t.Errorf("Status: got %q, want superseded", rows[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDocumentReferences_Empty(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
rows, err := store.ListDocumentReferences("no-such-patient", testEHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDocumentReferences: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("expected 0, got %d", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PatientSync tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRecordAndLatestSync(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
// No sync yet.
|
||||
ps, err := store.LatestSync(testPatientID, testEHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("LatestSync (empty): %v", err)
|
||||
}
|
||||
if ps != nil {
|
||||
t.Errorf("expected nil before any sync, got %+v", ps)
|
||||
}
|
||||
|
||||
// Record first sync.
|
||||
recorded, err := store.RecordSync(testPatientID, testEHRURL, 10, 3, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordSync: %v", err)
|
||||
}
|
||||
if recorded.ID == "" {
|
||||
t.Fatal("expected non-empty ID")
|
||||
}
|
||||
if recorded.ObsCount != 10 || recorded.CondCount != 3 || recorded.DocCount != 1 {
|
||||
t.Errorf("counts wrong: %+v", recorded)
|
||||
}
|
||||
|
||||
// LatestSync should now return that record.
|
||||
latest, err := store.LatestSync(testPatientID, testEHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("LatestSync: %v", err)
|
||||
}
|
||||
if latest == nil {
|
||||
t.Fatal("expected non-nil latest sync")
|
||||
}
|
||||
if latest.ID != recorded.ID {
|
||||
t.Errorf("ID mismatch: got %q, want %q", latest.ID, recorded.ID)
|
||||
}
|
||||
|
||||
// Record a second sync with higher counts; LatestSync must return the newer one.
|
||||
_, err = store.RecordSync(testPatientID, testEHRURL, 20, 5, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordSync second: %v", err)
|
||||
}
|
||||
|
||||
latest2, err := store.LatestSync(testPatientID, testEHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("LatestSync second: %v", err)
|
||||
}
|
||||
if latest2.ObsCount != 20 {
|
||||
t.Errorf("expected ObsCount 20 from latest, got %d", latest2.ObsCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestSync_TenantIsolation(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
_, err := store.RecordSync(testPatientID, "https://ehr-a.example.com/fhir", 5, 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordSync EHR-A: %v", err)
|
||||
}
|
||||
|
||||
// Querying for a different EHR URL should return nil.
|
||||
ps, err := store.LatestSync(testPatientID, "https://ehr-b.example.com/fhir")
|
||||
if err != nil {
|
||||
t.Fatalf("LatestSync EHR-B: %v", err)
|
||||
}
|
||||
if ps != nil {
|
||||
t.Errorf("expected nil for different EHR URL, got %+v", ps)
|
||||
}
|
||||
}
|
||||
421
app/db/db.go
Normal file
421
app/db/db.go
Normal file
@@ -0,0 +1,421 @@
|
||||
// Package db provides the SQLite-backed persistence layer for the platform.
|
||||
//
|
||||
// Architecture notes:
|
||||
// - Uses the standard database/sql interface with a pure-Go SQLite driver
|
||||
// (modernc.org/sqlite), requiring no CGO.
|
||||
// - Schema versioning is handled via the schema_migrations table, enabling
|
||||
// forward-only incremental migrations as the platform grows.
|
||||
// - All public functions accept a *Store receiver, keeping the DB handle
|
||||
// encapsulated and allowing easy substitution (e.g., for testing with
|
||||
// an in-memory SQLite instance).
|
||||
// - Upsert semantics for users use (fhir_id, ehr_url) as the natural key,
|
||||
// preventing duplicates across EHR tenants while allowing the same FHIR
|
||||
// ID to exist at different servers.
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
|
||||
"github.com/google/uuid"
|
||||
_ "modernc.org/sqlite" // Register the sqlite driver under the name "sqlite".
|
||||
)
|
||||
|
||||
// Store wraps a database/sql.DB and exposes all persistence operations
|
||||
// for the platform. It is the single point of DB access — no raw *sql.DB
|
||||
// handles should escape this package.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// New opens (or creates) the SQLite database at the given path, applies
|
||||
// all pending migrations, and returns a ready-to-use Store.
|
||||
//
|
||||
// Use path ":memory:" in tests to get a disposable in-memory database.
|
||||
func New(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: open: %w", err)
|
||||
}
|
||||
|
||||
// SQLite is file-based; a small pool is sufficient.
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
db.SetConnMaxLifetime(0)
|
||||
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("db: migrate: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Close releases the underlying database connection.
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema migrations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// migration represents a single, versioned, forward-only DDL statement.
|
||||
// New tables and columns must be added as new migrations — never alter
|
||||
// existing ones, to preserve upgrade safety.
|
||||
type migration struct {
|
||||
version int
|
||||
sql string
|
||||
}
|
||||
|
||||
var migrations = []migration{
|
||||
{
|
||||
version: 1,
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
fhir_resource_type TEXT NOT NULL,
|
||||
fhir_id TEXT NOT NULL,
|
||||
ehr_url TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
first_name TEXT NOT NULL DEFAULT '',
|
||||
middle_name TEXT NOT NULL DEFAULT '',
|
||||
last_name TEXT NOT NULL DEFAULT '',
|
||||
dob TEXT NOT NULL DEFAULT '',
|
||||
gender TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(fhir_id, ehr_url)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
access_token TEXT NOT NULL DEFAULT '',
|
||||
ehr_url TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_fhir ON users(fhir_id, ehr_url);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
sql: `
|
||||
ALTER TABLE sessions ADD COLUMN patient_fhir_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE sessions ADD COLUMN id_token TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE sessions ADD COLUMN scope TEXT NOT NULL DEFAULT '';
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS observations (
|
||||
id TEXT PRIMARY KEY,
|
||||
fhir_id TEXT NOT NULL,
|
||||
ehr_url TEXT NOT NULL,
|
||||
patient_fhir_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
code_text TEXT NOT NULL DEFAULT '',
|
||||
code_system TEXT NOT NULL DEFAULT '',
|
||||
code_code TEXT NOT NULL DEFAULT '',
|
||||
effective_date TEXT NOT NULL DEFAULT '',
|
||||
value_quantity REAL,
|
||||
value_unit TEXT NOT NULL DEFAULT '',
|
||||
value_string TEXT NOT NULL DEFAULT '',
|
||||
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(fhir_id, ehr_url)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conditions (
|
||||
id TEXT PRIMARY KEY,
|
||||
fhir_id TEXT NOT NULL,
|
||||
ehr_url TEXT NOT NULL,
|
||||
patient_fhir_id TEXT NOT NULL,
|
||||
clinical_status TEXT NOT NULL DEFAULT '',
|
||||
verification_status TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
code_text TEXT NOT NULL DEFAULT '',
|
||||
code_system TEXT NOT NULL DEFAULT '',
|
||||
code_code TEXT NOT NULL DEFAULT '',
|
||||
onset_date TEXT NOT NULL DEFAULT '',
|
||||
recorded_date TEXT NOT NULL DEFAULT '',
|
||||
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(fhir_id, ehr_url)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_references (
|
||||
id TEXT PRIMARY KEY,
|
||||
fhir_id TEXT NOT NULL,
|
||||
ehr_url TEXT NOT NULL,
|
||||
patient_fhir_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
doc_status TEXT NOT NULL DEFAULT '',
|
||||
type_text TEXT NOT NULL DEFAULT '',
|
||||
type_system TEXT NOT NULL DEFAULT '',
|
||||
type_code TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
date TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
content_type TEXT NOT NULL DEFAULT '',
|
||||
content_url TEXT NOT NULL DEFAULT '',
|
||||
content_data TEXT NOT NULL DEFAULT '',
|
||||
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(fhir_id, ehr_url)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS patient_syncs (
|
||||
id TEXT PRIMARY KEY,
|
||||
patient_fhir_id TEXT NOT NULL,
|
||||
ehr_url TEXT NOT NULL,
|
||||
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
obs_count INTEGER NOT NULL DEFAULT 0,
|
||||
cond_count INTEGER NOT NULL DEFAULT 0,
|
||||
doc_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_patient ON observations(patient_fhir_id, ehr_url);
|
||||
CREATE INDEX IF NOT EXISTS idx_conditions_patient ON conditions(patient_fhir_id, ehr_url);
|
||||
CREATE INDEX IF NOT EXISTS idx_docrefs_patient ON document_references(patient_fhir_id, ehr_url);
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_syncs_patient ON patient_syncs(patient_fhir_id, ehr_url);
|
||||
`,
|
||||
},
|
||||
// Future migrations: append new entries here with incrementing version numbers.
|
||||
// Example:
|
||||
// {
|
||||
// version: 2,
|
||||
// sql: `ALTER TABLE users ADD COLUMN phone TEXT NOT NULL DEFAULT '';`,
|
||||
// },
|
||||
}
|
||||
|
||||
// migrate applies any migrations that have not yet been run, in order.
|
||||
func (s *Store) migrate() error {
|
||||
// Enable WAL mode for better concurrent read performance.
|
||||
if _, err := s.db.Exec(`PRAGMA journal_mode=WAL;`); err != nil {
|
||||
return fmt.Errorf("set WAL mode: %w", err)
|
||||
}
|
||||
// Enable foreign key enforcement (off by default in SQLite).
|
||||
if _, err := s.db.Exec(`PRAGMA foreign_keys=ON;`); err != nil {
|
||||
return fmt.Errorf("enable foreign keys: %w", err)
|
||||
}
|
||||
|
||||
// Bootstrap the migrations table if it doesn't exist yet.
|
||||
if _, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);`); err != nil {
|
||||
return fmt.Errorf("bootstrap schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
var count int
|
||||
row := s.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, m.version)
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return fmt.Errorf("check migration v%d: %w", m.version, err)
|
||||
}
|
||||
if count > 0 {
|
||||
continue // Already applied.
|
||||
}
|
||||
|
||||
if _, err := s.db.Exec(m.sql); err != nil {
|
||||
return fmt.Errorf("apply migration v%d: %w", m.version, err)
|
||||
}
|
||||
if _, err := s.db.Exec(`INSERT INTO schema_migrations(version) VALUES(?)`, m.version); err != nil {
|
||||
return fmt.Errorf("record migration v%d: %w", m.version, err)
|
||||
}
|
||||
log.Printf("db: applied migration v%d", m.version)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// UpsertUser inserts a new user or updates the demographics of an existing one,
|
||||
// matched on the (fhir_id, ehr_url) natural key.
|
||||
//
|
||||
// Returns the user's internal UUID (which may be the existing one if the user
|
||||
// already existed).
|
||||
func (s *Store) UpsertUser(u *models.User) (string, error) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Check if the user already exists to preserve the original created_at
|
||||
// and internal ID.
|
||||
var existingID string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id FROM users WHERE fhir_id = ? AND ehr_url = ?`,
|
||||
u.FHIRID, u.EHRURL,
|
||||
).Scan(&existingID)
|
||||
|
||||
if err == nil {
|
||||
// User exists — update demographics but preserve ID and created_at.
|
||||
_, err = s.db.Exec(`
|
||||
UPDATE users SET
|
||||
first_name = ?,
|
||||
middle_name = ?,
|
||||
last_name = ?,
|
||||
dob = ?,
|
||||
gender = ?,
|
||||
email = ?,
|
||||
fhir_resource_type = ?,
|
||||
role = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`,
|
||||
u.FirstName, u.MiddleName, u.LastName,
|
||||
u.DOB, u.Gender, u.Email,
|
||||
u.FHIRResourceType, string(u.Role),
|
||||
now, existingID,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("db: update user %s: %w", existingID, err)
|
||||
}
|
||||
return existingID, nil
|
||||
}
|
||||
|
||||
if err != sql.ErrNoRows {
|
||||
return "", fmt.Errorf("db: lookup user (%s, %s): %w", u.FHIRID, u.EHRURL, err)
|
||||
}
|
||||
|
||||
// New user — generate a fresh internal UUID.
|
||||
id := uuid.NewString()
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO users (
|
||||
id, fhir_resource_type, fhir_id, ehr_url, role,
|
||||
first_name, middle_name, last_name, dob, gender, email,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, u.FHIRResourceType, u.FHIRID, u.EHRURL, string(u.Role),
|
||||
u.FirstName, u.MiddleName, u.LastName, u.DOB, u.Gender, u.Email,
|
||||
now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("db: insert user (fhir_id=%s): %w", u.FHIRID, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetUserByFHIRID retrieves a user by their FHIR ID and originating EHR URL.
|
||||
// Returns sql.ErrNoRows if no matching user is found.
|
||||
func (s *Store) GetUserByFHIRID(fhirID, ehrURL string) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, fhir_resource_type, fhir_id, ehr_url, role,
|
||||
first_name, middle_name, last_name, dob, gender, email,
|
||||
created_at, updated_at
|
||||
FROM users WHERE fhir_id = ? AND ehr_url = ?`,
|
||||
fhirID, ehrURL,
|
||||
).Scan(
|
||||
&u.ID, &u.FHIRResourceType, &u.FHIRID, &u.EHRURL, &u.Role,
|
||||
&u.FirstName, &u.MiddleName, &u.LastName, &u.DOB, &u.Gender, &u.Email,
|
||||
&u.CreatedAt, &u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// GetUserByID retrieves a user by their internal UUID primary key.
|
||||
func (s *Store) GetUserByID(id string) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, fhir_resource_type, fhir_id, ehr_url, role,
|
||||
first_name, middle_name, last_name, dob, gender, email,
|
||||
created_at, updated_at
|
||||
FROM users WHERE id = ?`, id,
|
||||
).Scan(
|
||||
&u.ID, &u.FHIRResourceType, &u.FHIRID, &u.EHRURL, &u.Role,
|
||||
&u.FirstName, &u.MiddleName, &u.LastName, &u.DOB, &u.Gender, &u.Email,
|
||||
&u.CreatedAt, &u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CreateSession creates a new authenticated session for the given user,
|
||||
// storing the FHIR access token and EHR context. Sessions expire after
|
||||
// the provided duration from now.
|
||||
func (s *Store) CreateSession(userID, patientFHIRID, accessToken, idToken, scope, ehrURL string, ttl time.Duration) (*models.Session, error) {
|
||||
now := time.Now().UTC()
|
||||
sess := &models.Session{
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
PatientFHIRID: patientFHIRID,
|
||||
AccessToken: accessToken,
|
||||
IDToken: idToken,
|
||||
Scope: scope,
|
||||
EHRURL: ehrURL,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(ttl),
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO sessions (id, user_id, patient_fhir_id, access_token, id_token, scope, ehr_url, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
sess.ID, sess.UserID, sess.PatientFHIRID, sess.AccessToken, sess.IDToken, sess.Scope, sess.EHRURL,
|
||||
sess.CreatedAt, sess.ExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: create session for user %s: %w", userID, err)
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// GetSession retrieves a session by its token ID.
|
||||
// Returns sql.ErrNoRows if the session does not exist.
|
||||
func (s *Store) GetSession(id string) (*models.Session, error) {
|
||||
sess := &models.Session{}
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, user_id, patient_fhir_id, access_token, id_token, scope, ehr_url, created_at, expires_at
|
||||
FROM sessions WHERE id = ?`, id,
|
||||
).Scan(
|
||||
&sess.ID, &sess.UserID, &sess.PatientFHIRID, &sess.AccessToken, &sess.IDToken, &sess.Scope, &sess.EHRURL,
|
||||
&sess.CreatedAt, &sess.ExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// DeleteSession removes a session by its token ID. Used during logout.
|
||||
func (s *Store) DeleteSession(id string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM sessions WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: delete session %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteExpiredSessions removes all sessions whose expiry time has passed.
|
||||
// This should be called periodically (e.g., on startup or via a background
|
||||
// goroutine) to keep the sessions table lean.
|
||||
func (s *Store) DeleteExpiredSessions() (int64, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, time.Now().UTC())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("db: purge expired sessions: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
256
app/db/db_test.go
Normal file
256
app/db/db_test.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/db"
|
||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
|
||||
)
|
||||
|
||||
// newTestStore creates a disposable in-memory SQLite store for testing.
|
||||
func newTestStore(t *testing.T) *db.Store {
|
||||
t.Helper()
|
||||
store, err := db.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("newTestStore: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
return store
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestUpsertUser_NewUser(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
u := &models.User{
|
||||
FHIRResourceType: "Patient",
|
||||
FHIRID: "patient-abc",
|
||||
EHRURL: "https://ehr.example.com/fhir",
|
||||
Role: models.RolePatient,
|
||||
FirstName: "Alice",
|
||||
MiddleName: "Marie",
|
||||
LastName: "Smith",
|
||||
DOB: "1990-04-22",
|
||||
Gender: "female",
|
||||
Email: "alice@example.com",
|
||||
}
|
||||
|
||||
id, err := store.UpsertUser(u)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertUser: %v", err)
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatal("expected non-empty ID for new user")
|
||||
}
|
||||
|
||||
// Fetch back and verify.
|
||||
got, err := store.GetUserByFHIRID(u.FHIRID, u.EHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByFHIRID: %v", err)
|
||||
}
|
||||
if got.FirstName != "Alice" {
|
||||
t.Errorf("FirstName: got %q, want %q", got.FirstName, "Alice")
|
||||
}
|
||||
if got.Role != models.RolePatient {
|
||||
t.Errorf("Role: got %q, want %q", got.Role, models.RolePatient)
|
||||
}
|
||||
if got.Email != "alice@example.com" {
|
||||
t.Errorf("Email: got %q, want %q", got.Email, "alice@example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertUser_UpdateExisting(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
u := &models.User{
|
||||
FHIRResourceType: "Patient",
|
||||
FHIRID: "patient-xyz",
|
||||
EHRURL: "https://ehr.example.com/fhir",
|
||||
Role: models.RolePatient,
|
||||
FirstName: "Bob",
|
||||
LastName: "Jones",
|
||||
DOB: "1985-01-01",
|
||||
Gender: "male",
|
||||
}
|
||||
|
||||
id1, err := store.UpsertUser(u)
|
||||
if err != nil {
|
||||
t.Fatalf("initial UpsertUser: %v", err)
|
||||
}
|
||||
|
||||
// Update demographics.
|
||||
u.Email = "bob.updated@example.com"
|
||||
u.FirstName = "Robert"
|
||||
id2, err := store.UpsertUser(u)
|
||||
if err != nil {
|
||||
t.Fatalf("update UpsertUser: %v", err)
|
||||
}
|
||||
|
||||
// Internal ID must remain stable across upserts.
|
||||
if id1 != id2 {
|
||||
t.Errorf("ID changed on upsert: got %q, was %q", id2, id1)
|
||||
}
|
||||
|
||||
got, err := store.GetUserByFHIRID(u.FHIRID, u.EHRURL)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByFHIRID after update: %v", err)
|
||||
}
|
||||
if got.FirstName != "Robert" {
|
||||
t.Errorf("updated FirstName: got %q, want %q", got.FirstName, "Robert")
|
||||
}
|
||||
if got.Email != "bob.updated@example.com" {
|
||||
t.Errorf("updated Email: got %q, want %q", got.Email, "bob.updated@example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserByFHIRID_NotFound(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
_, err := store.GetUserByFHIRID("nonexistent", "https://ehr.example.com/fhir")
|
||||
if err != sql.ErrNoRows {
|
||||
t.Errorf("expected sql.ErrNoRows, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertUser_TenantIsolation(t *testing.T) {
|
||||
// The same FHIR ID at two different EHR URLs must produce two separate records.
|
||||
store := newTestStore(t)
|
||||
|
||||
makeUser := func(ehrURL string) *models.User {
|
||||
return &models.User{
|
||||
FHIRResourceType: "Patient",
|
||||
FHIRID: "shared-fhir-id",
|
||||
EHRURL: ehrURL,
|
||||
Role: models.RolePatient,
|
||||
FirstName: "Carol",
|
||||
LastName: "Tenant",
|
||||
}
|
||||
}
|
||||
|
||||
id1, err := store.UpsertUser(makeUser("https://ehr-a.example.com/fhir"))
|
||||
if err != nil {
|
||||
t.Fatalf("upsert EHR-A: %v", err)
|
||||
}
|
||||
id2, err := store.UpsertUser(makeUser("https://ehr-b.example.com/fhir"))
|
||||
if err != nil {
|
||||
t.Fatalf("upsert EHR-B: %v", err)
|
||||
}
|
||||
|
||||
if id1 == id2 {
|
||||
t.Error("expected separate internal IDs for same FHIR ID at different EHR URLs")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCreateAndGetSession(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
// Create a practitioner user first.
|
||||
practUser := &models.User{
|
||||
FHIRResourceType: "Practitioner",
|
||||
FHIRID: "pract-001",
|
||||
EHRURL: "https://ehr.example.com/fhir",
|
||||
Role: models.RolePractitioner,
|
||||
FirstName: "Dr. Emily",
|
||||
LastName: "Chen",
|
||||
}
|
||||
userID, err := store.UpsertUser(practUser)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertUser practitioner: %v", err)
|
||||
}
|
||||
|
||||
// Create a session.
|
||||
sess, err := store.CreateSession(userID, "patient-001", "access-token-xyz", "id-token-abc", "openid profile", "https://ehr.example.com/fhir", 8*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
if sess.ID == "" {
|
||||
t.Fatal("expected non-empty session ID")
|
||||
}
|
||||
|
||||
// Retrieve the session.
|
||||
got, err := store.GetSession(sess.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if got.UserID != userID {
|
||||
t.Errorf("UserID: got %q, want %q", got.UserID, userID)
|
||||
}
|
||||
if got.AccessToken != "access-token-xyz" {
|
||||
t.Errorf("AccessToken: got %q, want %q", got.AccessToken, "access-token-xyz")
|
||||
}
|
||||
if got.ExpiresAt.Before(time.Now()) {
|
||||
t.Error("session should not be expired immediately after creation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSession(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
userID, err := store.UpsertUser(&models.User{
|
||||
FHIRResourceType: "Practitioner",
|
||||
FHIRID: "pract-delete",
|
||||
EHRURL: "https://ehr.example.com/fhir",
|
||||
Role: models.RolePractitioner,
|
||||
FirstName: "Test",
|
||||
LastName: "Delete",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertUser: %v", err)
|
||||
}
|
||||
|
||||
sess, err := store.CreateSession(userID, "pat", "tok", "id", "scope", "https://ehr.example.com/fhir", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
if err := store.DeleteSession(sess.ID); err != nil {
|
||||
t.Fatalf("DeleteSession: %v", err)
|
||||
}
|
||||
|
||||
_, err = store.GetSession(sess.ID)
|
||||
if err != sql.ErrNoRows {
|
||||
t.Errorf("expected sql.ErrNoRows after deletion, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteExpiredSessions(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
userID, err := store.UpsertUser(&models.User{
|
||||
FHIRResourceType: "Practitioner",
|
||||
FHIRID: "pract-expire",
|
||||
EHRURL: "https://ehr.example.com/fhir",
|
||||
Role: models.RolePractitioner,
|
||||
FirstName: "Expire",
|
||||
LastName: "Test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertUser: %v", err)
|
||||
}
|
||||
|
||||
// Create one valid and one already-expired session.
|
||||
_, err = store.CreateSession(userID, "pat", "valid-tok", "id", "scope", "https://ehr.example.com/fhir", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession (valid): %v", err)
|
||||
}
|
||||
_, err = store.CreateSession(userID, "pat", "expired-tok", "id", "scope", "https://ehr.example.com/fhir", -1*time.Second) // already past
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession (expired): %v", err)
|
||||
}
|
||||
|
||||
n, err := store.DeleteExpiredSessions()
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteExpiredSessions: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("deleted %d expired sessions, want 1", n)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user