package capture import ( "context" "errors" "strings" "testing" "time" "github.com/mathiasbq/hyperguild/ingestion/internal/classification" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // --- fakes --- type fakeBrain struct { writes []Note updates []Note gets []string failOn func(Note) error // nil = always succeed hashSeq int } func (f *fakeBrain) ref(prefix string, n Note, superseded bool) Ref { f.hashSeq++ path := "wiki/" + n.Wing + "/" + n.Hall + "/" + n.Filename + ".md" return Ref{ID: path, Path: path, ContentHash: prefix + string(rune('0'+f.hashSeq)), Superseded: superseded} } func (f *fakeBrain) Write(_ context.Context, n Note) (Ref, error) { if f.failOn != nil { if err := f.failOn(n); err != nil { return Ref{}, err } } f.writes = append(f.writes, n) return f.ref("w", n, false), nil } func (f *fakeBrain) Update(_ context.Context, slug string, n Note) (Ref, error) { if f.failOn != nil { if err := f.failOn(n); err != nil { return Ref{}, err } } n.Filename = slug f.updates = append(f.updates, n) return f.ref("u", n, true), nil } func (f *fakeBrain) Get(_ context.Context, id string) (StoredNote, error) { f.gets = append(f.gets, id) return StoredNote{ID: id, Path: id}, nil } type fakeTracker struct { created []string closed []int comments []int err error } func (f *fakeTracker) CreateIssue(_ context.Context, repo, title, _ string) (IssueRef, error) { if f.err != nil { return IssueRef{}, f.err } f.created = append(f.created, repo+":"+title) return IssueRef{Repo: repo, Number: 100 + len(f.created), URL: "https://git/" + repo + "/issues/x"}, nil } func (f *fakeTracker) CloseIssue(_ context.Context, repo string, number int, _ string) (IssueRef, error) { if f.err != nil { return IssueRef{}, f.err } f.closed = append(f.closed, number) return IssueRef{Repo: repo, Number: number}, nil } func (f *fakeTracker) CommentIssue(_ context.Context, repo string, number int, _ string) (IssueRef, error) { if f.err != nil { return IssueRef{}, f.err } f.comments = append(f.comments, number) return IssueRef{Repo: repo, Number: number}, nil } type fakeSummary struct { paths []string content []string err error } func (f *fakeSummary) WriteFile(_ context.Context, _, path, content string) error { if f.err != nil { return f.err } f.paths = append(f.paths, path) f.content = append(f.content, content) return nil } // fakePolicy derives from an explicit map; default Internal so tests pin // behaviour without depending on the real defaulting. type fakePolicy struct{ tags map[string]classification.Level } func (p fakePolicy) Derive(t classification.Target) classification.Level { if lvl, ok := p.tags[t.Name]; ok { return lvl } return classification.Internal } type fakeAudit struct { entries []AuditEntry err error } func (f *fakeAudit) Record(_ context.Context, e AuditEntry) error { if f.err != nil { return f.err } f.entries = append(f.entries, e) return nil } // --- helpers --- func newSvc(b BrainStore, tr IssueTracker, sw SummaryWriter, p ClassificationPolicy, a AuditSink) *Service { s := NewService(b, tr, sw, p, a) s.now = func() time.Time { return time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC) } return s } func baseCtx() CaptureContext { return CaptureContext{Harness: "claude-code", Actor: "mathias", Principal: "mathias", Classification: "internal"} } // --- scenarios --- func TestCaptureHappyPath(t *testing.T) { b := &fakeBrain{} tr := &fakeTracker{} au := &fakeAudit{} svc := newSvc(b, tr, nil, fakePolicy{}, au) rec, err := svc.Capture(context.Background(), CaptureInput{ Context: baseCtx(), Insights: []Insight{ {Text: "a", Wing: "hyperguild", Hall: "decisions", SupersedeSlug: ""}, {Text: "b", Wing: "hyperguild", Hall: "facts"}, }, Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "do x", Body: "y"}}, }) require.NoError(t, err) require.Len(t, rec.Insights, 2) for _, r := range rec.Insights { assert.True(t, r.OK) assert.NotEmpty(t, r.ContentHash, "read-after-write hash returned") } require.Len(t, rec.Tickets, 1) assert.True(t, rec.Tickets[0].OK) assert.Equal(t, 2, len(b.writes)) assert.Empty(t, rec.Errors) // Audit emitted naming principal/harness + items that landed. require.Len(t, au.entries, 1) assert.Equal(t, "mathias", au.entries[0].Principal) assert.Equal(t, "claude-code", au.entries[0].Harness) assert.Len(t, au.entries[0].Items, 3) } func TestCaptureSupersedeNotDuplicate(t *testing.T) { b := &fakeBrain{} svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{}) rec, err := svc.Capture(context.Background(), CaptureInput{ Context: baseCtx(), Insights: []Insight{{Text: "revised", Wing: "hyperguild", Hall: "facts", SupersedeSlug: "prior-note"}}, }) require.NoError(t, err) assert.Empty(t, b.writes, "supersede must not create") require.Len(t, b.updates, 1) assert.Equal(t, "prior-note", b.updates[0].Filename) assert.True(t, rec.Insights[0].Superseded) } func TestCaptureValidationFailClosed(t *testing.T) { b := &fakeBrain{} tr := &fakeTracker{} au := &fakeAudit{} svc := newSvc(b, tr, nil, fakePolicy{}, au) _, err := svc.Capture(context.Background(), CaptureInput{ Context: baseCtx(), Insights: []Insight{ {Text: "ok", Wing: "hyperguild", Hall: "facts"}, {Text: "bad", Wing: "hyperguild", Hall: "garbage-hall"}, // invalid hall }, Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "t"}}, }) require.Error(t, err) // Nothing written anywhere. assert.Empty(t, b.writes) assert.Empty(t, b.updates) assert.Empty(t, tr.created) assert.Empty(t, au.entries) } func TestCaptureValidationRejectsBadTicket(t *testing.T) { svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{}) _, err := svc.Capture(context.Background(), CaptureInput{ Context: baseCtx(), Tickets: []Ticket{{Repo: "hyperguild", Action: "frobnicate"}}, // bad action }) require.Error(t, err) _, err = svc.Capture(context.Background(), CaptureInput{ Context: baseCtx(), Tickets: []Ticket{{Repo: "hyperguild", Action: "close"}}, // close needs number }) require.Error(t, err) } func TestCapturePartialFailureBestEffort(t *testing.T) { b := &fakeBrain{failOn: func(n Note) error { if strings.Contains(n.Content, "FAIL") { return errors.New("disk full") } return nil }} tr := &fakeTracker{} au := &fakeAudit{} svc := newSvc(b, tr, nil, fakePolicy{}, au) rec, err := svc.Capture(context.Background(), CaptureInput{ Context: baseCtx(), Insights: []Insight{ {Text: "good one", Wing: "hyperguild", Hall: "facts"}, {Text: "FAIL here", Wing: "hyperguild", Hall: "facts"}, }, Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "t"}}, }) require.NoError(t, err, "partial failure is not a request-level error") assert.True(t, rec.Insights[0].OK) assert.False(t, rec.Insights[1].OK) assert.True(t, rec.Tickets[0].OK, "ticket still persisted; no rollback") require.Len(t, rec.Errors, 1) assert.Equal(t, "insight[1]", rec.Errors[0].Item) // Audit reflects exactly what landed: 1 insight + 1 ticket. require.Len(t, au.entries, 1) assert.Len(t, au.entries[0].Items, 2) } func TestCaptureDryRunWritesNothing(t *testing.T) { b := &fakeBrain{} tr := &fakeTracker{} au := &fakeAudit{} svc := newSvc(b, tr, nil, fakePolicy{}, au) rec, err := svc.Capture(context.Background(), CaptureInput{ Context: baseCtx(), DryRun: true, Insights: []Insight{{Text: "a", Wing: "hyperguild", Hall: "facts"}}, Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "t"}}, }) require.NoError(t, err) assert.True(t, rec.DryRun) assert.Len(t, rec.Insights, 1) assert.True(t, rec.Insights[0].OK, "would-be receipt marks planned items ok") // Nothing written anywhere, including audit. assert.Empty(t, b.writes) assert.Empty(t, tr.created) assert.Empty(t, au.entries) } func TestCaptureStricterClassificationWins(t *testing.T) { // Caller declares internal; target wing tagged confidential → effective confidential + security event. b := &fakeBrain{} au := &fakeAudit{} pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}} svc := newSvc(b, &fakeTracker{}, nil, pol, au) ctx := baseCtx() ctx.Classification = "internal" rec, err := svc.Capture(context.Background(), CaptureInput{ Context: ctx, Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}}, }) require.NoError(t, err) assert.Equal(t, "confidential", rec.EffectiveClassification) require.Len(t, au.entries, 1) assert.NotEmpty(t, au.entries[0].SecurityEvents, "under-declaration logged as security event") assert.Equal(t, "confidential", au.entries[0].EffectiveClassification) } func TestCaptureCallerRaisingSensitivityHonoured(t *testing.T) { // Caller declares confidential; target internal → effective confidential, NOT a security event. au := &fakeAudit{} pol := fakePolicy{tags: map[string]classification.Level{"hyperguild": classification.Internal}} svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, pol, au) ctx := baseCtx() ctx.Classification = "confidential" rec, err := svc.Capture(context.Background(), CaptureInput{ Context: ctx, Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}}, }) require.NoError(t, err) assert.Equal(t, "confidential", rec.EffectiveClassification) assert.Empty(t, au.entries[0].SecurityEvents, "raising sensitivity is honoured, not flagged") } func TestCaptureSummaryPathAndFidelity(t *testing.T) { sw := &fakeSummary{} svc := newSvc(&fakeBrain{}, &fakeTracker{}, sw, fakePolicy{}, &fakeAudit{}) ctx := baseCtx() ctx.Fidelity = "transcript-parse" ctx.SessionRef = "abc123def456" rec, err := svc.Capture(context.Background(), CaptureInput{ Context: ctx, Summary: &Summary{Title: "Session Wrap", Body: "did stuff", ReposTouched: []string{"hyperguild"}}, }) require.NoError(t, err) require.NotNil(t, rec.Summary) assert.True(t, rec.Summary.OK) require.Len(t, sw.paths, 1) assert.True(t, strings.HasPrefix(sw.paths[0], "summaries/claude-code/2026-06/"), "path: %s", sw.paths[0]) assert.Contains(t, sw.paths[0], "session-wrap") assert.Contains(t, sw.content[0], "fidelity: transcript-parse", "fidelity stamped in frontmatter") } // --- I1 sovereignty gate (#53) --- func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) { b := &fakeBrain{} tr := &fakeTracker{} au := &fakeAudit{} pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}} svc := newSvc(b, tr, nil, pol, au) ctx := baseCtx() ctx.Classification = "confidential" ctx.Origin = ZoneUSNexus _, err := svc.Capture(context.Background(), CaptureInput{ Context: ctx, Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}}, }) require.Error(t, err) assert.ErrorIs(t, err, ErrSovereigntyRefused) // Refused before any write. assert.Empty(t, b.writes) assert.Empty(t, tr.created) // Refusal is audited. require.Len(t, au.entries, 1) assert.Empty(t, au.entries[0].Items, "no items landed on refusal") } func TestCaptureAllowsConfidentialViaSovereign(t *testing.T) { b := &fakeBrain{} pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}} svc := newSvc(b, &fakeTracker{}, nil, pol, &fakeAudit{}) ctx := baseCtx() ctx.Classification = "confidential" ctx.Origin = ZoneSovereign rec, err := svc.Capture(context.Background(), CaptureInput{ Context: ctx, Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}}, }) require.NoError(t, err) assert.True(t, rec.Insights[0].OK) assert.Len(t, b.writes, 1) } func TestCaptureAssertedLabelIgnoredAndLogged(t *testing.T) { // Caller asserts harness "sovereign-soil" but principal resolves to // us-nexus; confidential ⇒ refused, and the discrepancy is a security event. au := &fakeAudit{} pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}} svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, pol, au) ctx := baseCtx() ctx.Harness = "sovereign-soil" // asserted ctx.Origin = ZoneUSNexus // server-derived ctx.Classification = "confidential" _, err := svc.Capture(context.Background(), CaptureInput{ Context: ctx, Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}}, }) require.ErrorIs(t, err, ErrSovereigntyRefused) require.Len(t, au.entries, 1) joined := strings.Join(au.entries[0].SecurityEvents, " | ") assert.Contains(t, joined, "asserted-vs-derived origin mismatch") assert.Contains(t, joined, "I1 refusal") } func TestCaptureInternalViaUSNexusAllowed(t *testing.T) { // us-nexus origin is fine for non-confidential data. b := &fakeBrain{} svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{}) ctx := baseCtx() ctx.Origin = ZoneUSNexus // internal classification, so gate doesn't fire rec, err := svc.Capture(context.Background(), CaptureInput{ Context: ctx, Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}}, }) require.NoError(t, err) assert.True(t, rec.Insights[0].OK) }