package services import ( "context" "errors" "testing" "time" "nearle/models" "nearle/repositories" "nearle/services/tools" ) type recordingAudit struct { rows []models.AssistantAudit err error } func (r *recordingAudit) Record(entry models.AssistantAudit) error { if r.err != nil { return r.err } r.rows = append(r.rows, entry) return nil } func (r *recordingAudit) Recent(int, int) ([]models.AssistantAudit, error) { return r.rows, nil } func TestAnAuditRowKeepsWhatTheCallActuallyDid(t *testing.T) { repo := &recordingAudit{} sink := NewDBAudit(repo) sink.Write(context.Background(), tools.AuditEntry{ At: time.Now(), Agent: "orders", Tool: "stuck_orders", Scope: "read", Userid: 904, Tenantid: 1147, Outcome: tools.OutcomeOK, Rows: 3, Args: map[string]any{"minutes_waiting": 30}, Took: 12 * time.Millisecond, }) if len(repo.rows) != 1 { t.Fatalf("wrote %d rows", len(repo.rows)) } row := repo.rows[0] if row.Tool != "stuck_orders" || row.Tenantid != 1147 || row.Rows != 3 { t.Fatalf("the row does not describe the call: %+v", row) } if row.Tookms != 12 { t.Fatalf("duration stored as %d", row.Tookms) } } func TestARefusalIsKept(t *testing.T) { // The interesting rows. A trail of successes answers "did anything try to // read another tenant?" with silence, which reads the same as "no". repo := &recordingAudit{} NewDBAudit(repo).Write(context.Background(), tools.AuditEntry{ Agent: "orders", Tool: "stuck_orders", Outcome: tools.OutcomeRefused, Detail: "no tenant on the caller", Userid: 904, }) if len(repo.rows) != 1 || repo.rows[0].Outcome != tools.OutcomeRefused { t.Fatalf("a refusal was not recorded: %+v", repo.rows) } if repo.rows[0].Detail == "" { t.Fatal("the refusal does not say why") } } func TestALostAuditRowDoesNotTakeTheAssistantDown(t *testing.T) { // A full disk must not stop a merchant asking where their orders are. The // failure is logged; it is not allowed to become an error the caller sees. repo := &recordingAudit{err: errors.New("disk is full")} NewDBAudit(repo).Write(context.Background(), tools.AuditEntry{Tool: "stuck_orders"}) // Reaching here without a panic is the assertion. } func TestAnAuditSinkWithNoDatabaseStillLogs(t *testing.T) { // A deployment whose migration has not run yet keeps working. NewDBAudit(nil).Write(context.Background(), tools.AuditEntry{Tool: "stuck_orders"}) } func TestArgumentsAreStoredInAStableOrder(t *testing.T) { // Go randomises map iteration. Without sorting, the same call stores // different JSON every time and a query looking for one of them finds one // of them. args := map[string]any{"zulu": 1, "alpha": "two", "mike": true} first := repositories.EncodeAuditArgs(args) for range 20 { if got := repositories.EncodeAuditArgs(args); got != first { t.Fatalf("two encodings differ:\n%s\n%s", first, got) } } if first != `{"alpha":"two","mike":true,"zulu":1}` { t.Fatalf("unexpected encoding: %s", first) } } func TestNoArgumentsIsAnEmptyObjectNotEmptyText(t *testing.T) { // `""` is not valid jsonb and would fail the insert, losing the row. if got := repositories.EncodeAuditArgs(nil); got != "{}" { t.Fatalf("no arguments encoded as %q", got) } } func TestAnUnencodableArgumentDoesNotLoseTheRow(t *testing.T) { // Losing the whole audit row to save one bad argument is the wrong trade. got := repositories.EncodeAuditArgs(map[string]any{"bad": make(chan int)}) if got == "" { t.Fatal("an unencodable argument produced no JSON at all") } }