package chat import ( "context" "sync" "sync/atomic" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/infra/llm" "github.com/yan1h/agent-coding-workflow/internal/infra/notify" ) // ========== streamer-specific fakes ========== // streamerFakeRepo is a test-only Repository whose relevant methods record calls // instead of panicking. All unneeded methods are covered by the embedded // panicRepo base. type streamerFakeRepo struct { panicRepo // embed the panic-everything base defined below mu sync.Mutex conversations map[uuid.UUID]*Conversation messages map[int64]*Message usageRecords []UsageRecord updateOKCalled bool updateOKParams UpdateMessageOKParams updateErrorCalled bool updateErrorID int64 updateErrorMsg *string updateCancelledCalled bool updateCancelledID int64 insertUsageCalled bool insertUsageRecord UsageRecord } func newStreamerFakeRepo() *streamerFakeRepo { return &streamerFakeRepo{ conversations: make(map[uuid.UUID]*Conversation), messages: make(map[int64]*Message), } } func (r *streamerFakeRepo) GetConversation(_ context.Context, id uuid.UUID) (*Conversation, error) { r.mu.Lock() defer r.mu.Unlock() c, ok := r.conversations[id] if !ok { // Return a conversation with no title so title generation is triggered. return &Conversation{ID: id}, nil } return c, nil } func (r *streamerFakeRepo) GetTitleGeneratorModel(_ context.Context) (*LLMModel, error) { return nil, nil // no title model => GenerateTitle returns early } func (r *streamerFakeRepo) GetFirstUserMessage(_ context.Context, _ uuid.UUID) (*Message, error) { return nil, nil } func (r *streamerFakeRepo) UpdateMessageOK(_ context.Context, p UpdateMessageOKParams) error { r.mu.Lock() defer r.mu.Unlock() r.updateOKCalled = true r.updateOKParams = p return nil } func (r *streamerFakeRepo) UpdateMessageError(_ context.Context, id int64, msg *string) error { r.mu.Lock() defer r.mu.Unlock() r.updateErrorCalled = true r.updateErrorID = id r.updateErrorMsg = msg return nil } func (r *streamerFakeRepo) UpdateMessageCancelled(_ context.Context, id int64) error { r.mu.Lock() defer r.mu.Unlock() r.updateCancelledCalled = true r.updateCancelledID = id return nil } func (r *streamerFakeRepo) InsertUsage(_ context.Context, rec UsageRecord) error { r.mu.Lock() defer r.mu.Unlock() r.insertUsageCalled = true r.insertUsageRecord = rec r.usageRecords = append(r.usageRecords, rec) return nil } func (r *streamerFakeRepo) UpdateConversationTitle(_ context.Context, _ uuid.UUID, _ *string) error { return nil } // WithTx returns the same repo (mutations are visible) plus a no-op fakeTx. func (r *streamerFakeRepo) WithTx(_ context.Context) (Repository, Tx, error) { return r, &fakeTx{}, nil } // ===== fakeTx ===== type fakeTx struct { committed atomic.Bool rolledBack atomic.Bool } func (tx *fakeTx) Commit(_ context.Context) error { tx.committed.Store(true) return nil } func (tx *fakeTx) Rollback(_ context.Context) error { tx.rolledBack.Store(true) return nil } // ===== fakeClientGetter ===== type fakeClientGetter struct { client llm.LLMClient } func (g *fakeClientGetter) GetClient(_ context.Context, _ uuid.UUID) (llm.LLMClient, error) { return g.client, nil } // ===== fakeNotifyDispatcher ===== type fakeNotifyDispatcher struct { mu sync.Mutex messages []notify.Message } func (d *fakeNotifyDispatcher) Dispatch(_ context.Context, msg notify.Message) error { d.mu.Lock() defer d.mu.Unlock() d.messages = append(d.messages, msg) return nil } func (d *fakeNotifyDispatcher) received() []notify.Message { d.mu.Lock() defer d.mu.Unlock() return append([]notify.Message(nil), d.messages...) } // ===== panicRepo ===== // panicRepo satisfies Repository and panics on every method. // streamerFakeRepo embeds it and overrides only what it needs. type panicRepo struct{} func (panicRepo) InsertConversation(_ context.Context, _ InsertConversationParams) (*Conversation, error) { panic("not implemented") } func (panicRepo) ListConversationsByUser(_ context.Context, _ uuid.UUID, _ ConversationFilter) ([]Conversation, error) { panic("not implemented") } func (panicRepo) SetConversationTitleManual(_ context.Context, _ uuid.UUID, _ *string) error { panic("not implemented") } func (panicRepo) SoftDeleteConversation(_ context.Context, _ uuid.UUID) error { panic("not implemented") } func (panicRepo) InsertMessage(_ context.Context, _ uuid.UUID, _ MessageRole, _ string, _ MessageStatus) (*Message, error) { panic("not implemented") } func (panicRepo) GetMessage(_ context.Context, _ int64) (*Message, error) { panic("not implemented") } func (panicRepo) ListMessagesByConversation(_ context.Context, _ uuid.UUID, _ *int64, _ int) ([]Message, error) { panic("not implemented") } func (panicRepo) ListMessagesAscending(_ context.Context, _ uuid.UUID) ([]Message, error) { panic("not implemented") } func (panicRepo) MarkPendingAsErrorOnStartup(_ context.Context) error { panic("not implemented") } func (panicRepo) DeleteMessage(_ context.Context, _ int64) error { panic("not implemented") } func (panicRepo) InsertAttachment(_ context.Context, _ InsertAttachmentParams) (*Attachment, error) { panic("not implemented") } func (panicRepo) GetAttachment(_ context.Context, _ uuid.UUID) (*Attachment, error) { panic("not implemented") } func (panicRepo) AttachToMessage(_ context.Context, _ []uuid.UUID, _ int64) error { panic("not implemented") } func (panicRepo) ListAttachmentsForMessage(_ context.Context, _ int64) ([]Attachment, error) { panic("not implemented") } func (panicRepo) ListOrphanAttachmentsForUser(_ context.Context, _ uuid.UUID, _ []uuid.UUID) ([]Attachment, error) { panic("not implemented") } func (panicRepo) ListExpiredOrphans(_ context.Context, _ time.Duration) ([]AttachmentRef, error) { panic("not implemented") } func (panicRepo) ListAttachmentsForSoftDeletedConversations(_ context.Context, _ time.Duration) ([]AttachmentRef, error) { panic("not implemented") } func (panicRepo) DeleteAttachment(_ context.Context, _ uuid.UUID) error { panic("not implemented") } func (panicRepo) DeleteAttachments(_ context.Context, _ []uuid.UUID) error { panic("not implemented") } func (panicRepo) InsertPromptTemplate(_ context.Context, _ InsertTemplateParams) (*PromptTemplate, error) { panic("not implemented") } func (panicRepo) GetPromptTemplate(_ context.Context, _ uuid.UUID) (*PromptTemplate, error) { panic("not implemented") } func (panicRepo) ListPromptTemplatesForUser(_ context.Context, _ uuid.UUID) ([]PromptTemplate, error) { panic("not implemented") } func (panicRepo) UpdatePromptTemplate(_ context.Context, _ uuid.UUID, _, _ string) error { panic("not implemented") } func (panicRepo) DeletePromptTemplate(_ context.Context, _ uuid.UUID) error { panic("not implemented") } func (panicRepo) InsertLLMEndpoint(_ context.Context, _ InsertEndpointParams) (*LLMEndpoint, error) { panic("not implemented") } func (panicRepo) GetLLMEndpoint(_ context.Context, _ uuid.UUID) (*LLMEndpoint, error) { panic("not implemented") } func (panicRepo) ListLLMEndpoints(_ context.Context) ([]LLMEndpoint, error) { panic("not implemented") } func (panicRepo) UpdateLLMEndpoint(_ context.Context, _ uuid.UUID, _ UpdateEndpointInput) error { panic("not implemented") } func (panicRepo) DeleteLLMEndpoint(_ context.Context, _ uuid.UUID) error { panic("not implemented") } func (panicRepo) InsertLLMModel(_ context.Context, _ InsertModelParams) (*LLMModel, error) { panic("not implemented") } func (panicRepo) GetLLMModel(_ context.Context, _ uuid.UUID) (*LLMModel, error) { panic("not implemented") } func (panicRepo) ListLLMModels(_ context.Context, _ bool) ([]LLMModel, error) { panic("not implemented") } func (panicRepo) UpdateLLMModel(_ context.Context, _ uuid.UUID, _ UpdateModelInput) error { panic("not implemented") } func (panicRepo) DeleteLLMModel(_ context.Context, _ uuid.UUID) error { panic("not implemented") } func (panicRepo) SummarizeUsageByUser(_ context.Context, _, _ time.Time) ([]UserUsageRow, error) { panic("not implemented") } func (panicRepo) SummarizeUsageByModel(_ context.Context, _, _ time.Time) ([]ModelUsageRow, error) { panic("not implemented") } func (panicRepo) SummarizeUsageByDay(_ context.Context, _, _ time.Time) ([]DayUsageRow, error) { panic("not implemented") } func (panicRepo) SummarizeUserDaily(_ context.Context, _ uuid.UUID, _, _ time.Time) ([]DayUsageRow, error) { panic("not implemented") } func (panicRepo) GetConversation(_ context.Context, _ uuid.UUID) (*Conversation, error) { panic("not implemented") } func (panicRepo) UpdateMessageOK(_ context.Context, _ UpdateMessageOKParams) error { panic("not implemented") } func (panicRepo) UpdateMessageError(_ context.Context, _ int64, _ *string) error { panic("not implemented") } func (panicRepo) UpdateMessageCancelled(_ context.Context, _ int64) error { panic("not implemented") } func (panicRepo) InsertUsage(_ context.Context, _ UsageRecord) error { panic("not implemented") } func (panicRepo) WithTx(_ context.Context) (Repository, Tx, error) { panic("not implemented") } func (panicRepo) UpdateConversationTitle(_ context.Context, _ uuid.UUID, _ *string) error { panic("not implemented") } func (panicRepo) GetTitleGeneratorModel(_ context.Context) (*LLMModel, error) { panic("not implemented") } func (panicRepo) GetFirstUserMessage(_ context.Context, _ uuid.UUID) (*Message, error) { panic("not implemented") } // ===== test helpers ===== // buildStreamerHub builds a hub with controllable fakes. func buildStreamerHub( t *testing.T, repo *streamerFakeRepo, fake *llm.FakeClient, dispatcher *fakeNotifyDispatcher, ) *streamerHub { t.Helper() getter := &fakeClientGetter{client: fake} hub := &streamerHub{ byMsgID: make(map[int64]*streamer), repo: repo, registry: getter, dispatch: dispatcher, auditor: noopAuditor{}, convSvc: nil, // title generation goes through maybeGenerateTitle -> early return log: discardLogger(), retention: 50 * time.Millisecond, safety: 0.9, } return hub } // defaultParams returns a StreamParams with sensible defaults for testing. func defaultParams(msgID int64) StreamParams { return StreamParams{ UserID: uuid.New(), ConversationID: uuid.New(), EndpointID: uuid.New(), ModelID: uuid.New(), AssistantMessageID: msgID, ModelMeta: LLMModel{ ModelID: "test-model", PromptPricePerMillionUSD: 3.0, CompletionPricePerMillionUSD: 15.0, ThinkingPricePerMillionUSD: 3.0, }, } } // drainSub reads all events from a subscription until the stream closes or // ctx expires. Returns all accumulated events. func drainSub(ctx context.Context, sub Subscription) []SSEEvent { var all []SSEEvent var lastID int64 for { evs, closed, err := sub.Next(ctx, lastID) if err != nil { return all } all = append(all, evs...) if len(evs) > 0 { lastID = evs[len(evs)-1].ID + 1 } if closed { return all } } } // ===== Tests ===== func TestStreamer_DoneFlow_LandsContentAndUsage(t *testing.T) { t.Parallel() repo := newStreamerFakeRepo() fake := llm.NewFake() fake.Events = []llm.StreamEvent{ {Type: llm.EventTextDelta, Text: "Hello"}, {Type: llm.EventTextDelta, Text: " World"}, {Type: llm.EventTextDelta, Text: "!"}, {Type: llm.EventUsage, Usage: &llm.Usage{PromptTokens: 10, CompletionTokens: 20}}, {Type: llm.EventDone, Reason: "end_turn"}, } dispatcher := &fakeNotifyDispatcher{} hub := buildStreamerHub(t, repo, fake, dispatcher) msgID := int64(42) p := defaultParams(msgID) require.NoError(t, hub.Start(context.Background(), p)) sub, ok := hub.Subscribe(msgID) require.True(t, ok) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() evs := drainSub(ctx, sub) require.Eventually(t, func() bool { repo.mu.Lock() defer repo.mu.Unlock() return repo.updateOKCalled }, 1*time.Second, 10*time.Millisecond, "UpdateMessageOK should have been called") repo.mu.Lock() params := repo.updateOKParams repo.mu.Unlock() require.Equal(t, msgID, params.ID) require.Equal(t, "Hello World!", params.Content) require.Eventually(t, func() bool { repo.mu.Lock() defer repo.mu.Unlock() return repo.insertUsageCalled }, 1*time.Second, 10*time.Millisecond, "InsertUsage should have been called") repo.mu.Lock() rec := repo.insertUsageRecord repo.mu.Unlock() require.Equal(t, 10, rec.PromptTokens) require.Equal(t, 20, rec.CompletionTokens) // Verify SSE events contain text_delta and done. var eventTypes []string for _, e := range evs { eventTypes = append(eventTypes, e.Event) } require.Contains(t, eventTypes, "text_delta") require.Contains(t, eventTypes, "done") } func TestStreamer_ErrorFlow_DispatchesNotify(t *testing.T) { t.Parallel() repo := newStreamerFakeRepo() fake := llm.NewFake() fake.Events = []llm.StreamEvent{ {Type: llm.EventError, Err: &testError{"upstream exploded"}}, } dispatcher := &fakeNotifyDispatcher{} hub := buildStreamerHub(t, repo, fake, dispatcher) msgID := int64(99) p := defaultParams(msgID) require.NoError(t, hub.Start(context.Background(), p)) sub, ok := hub.Subscribe(msgID) require.True(t, ok) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() evs := drainSub(ctx, sub) // UpdateMessageError must have been called. require.Eventually(t, func() bool { repo.mu.Lock() defer repo.mu.Unlock() return repo.updateErrorCalled }, 1*time.Second, 10*time.Millisecond) repo.mu.Lock() errID := repo.updateErrorID errMsg := repo.updateErrorMsg repo.mu.Unlock() require.Equal(t, msgID, errID) require.NotNil(t, errMsg) require.Contains(t, *errMsg, "upstream exploded") // Dispatcher must have received a notification. require.Eventually(t, func() bool { return len(dispatcher.received()) > 0 }, 1*time.Second, 10*time.Millisecond) msgs := dispatcher.received() require.Len(t, msgs, 1) require.Equal(t, "chat.message_failed", msgs[0].Topic) // Last SSE event must be "error" with code "llm.upstream_error". var lastEvent SSEEvent for _, e := range evs { lastEvent = e } require.Equal(t, "error", lastEvent.Event) dataMap, ok := lastEvent.Data.(map[string]string) require.True(t, ok) require.Equal(t, "llm.upstream_error", dataMap["code"]) } func TestStreamer_CancelMidStream_StatusCancelled(t *testing.T) { t.Parallel() repo := newStreamerFakeRepo() fake := llm.NewFake() blockCh := make(chan struct{}) fake.BlockCh = blockCh fake.Events = []llm.StreamEvent{ {Type: llm.EventTextDelta, Text: "partial"}, } dispatcher := &fakeNotifyDispatcher{} hub := buildStreamerHub(t, repo, fake, dispatcher) msgID := int64(7) p := defaultParams(msgID) require.NoError(t, hub.Start(context.Background(), p)) // Subscribe before cancelling so we can read the final error event. sub, ok := hub.Subscribe(msgID) require.True(t, ok) // Give the goroutine a moment to emit the partial event and reach the block. require.Eventually(t, func() bool { // peek: has at least the text_delta (ID=1, after message_meta ID=0) evs, _, _ := sub.Next(context.Background(), 0) for _, e := range evs { if e.Event == "text_delta" { return true } } return false }, 1*time.Second, 5*time.Millisecond) hub.Cancel(msgID) require.Eventually(t, func() bool { repo.mu.Lock() defer repo.mu.Unlock() return repo.updateCancelledCalled }, 1*time.Second, 10*time.Millisecond, "UpdateMessageCancelled should have been called") require.Equal(t, msgID, repo.updateCancelledID) // Drain remaining events and find the "error" cancelled event. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() evs := drainSub(ctx, sub) var hasCancel bool for _, e := range evs { if e.Event == "error" { dataMap, ok := e.Data.(map[string]string) if ok && dataMap["code"] == "cancelled" { hasCancel = true } } } require.True(t, hasCancel, "expected SSE error event with code=cancelled") } func TestStreamer_Subscribe_ReceivesEventsInOrder(t *testing.T) { t.Parallel() repo := newStreamerFakeRepo() fake := llm.NewFake() fake.Events = []llm.StreamEvent{ {Type: llm.EventTextDelta, Text: "a"}, {Type: llm.EventTextDelta, Text: "b"}, {Type: llm.EventTextDelta, Text: "c"}, {Type: llm.EventDone, Reason: "end_turn"}, } dispatcher := &fakeNotifyDispatcher{} hub := buildStreamerHub(t, repo, fake, dispatcher) msgID := int64(5) p := defaultParams(msgID) require.NoError(t, hub.Start(context.Background(), p)) sub, ok := hub.Subscribe(msgID) require.True(t, ok) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() evs := drainSub(ctx, sub) // IDs must be monotonically increasing. for i := 1; i < len(evs); i++ { require.Greater(t, evs[i].ID, evs[i-1].ID, "event IDs must be strictly increasing; got %v then %v", evs[i-1].ID, evs[i].ID) } // Events must contain the three text_delta values in sequence. var deltas []string for _, e := range evs { if e.Event == "text_delta" { d, ok := e.Data.(map[string]string) require.True(t, ok) deltas = append(deltas, d["text"]) } } require.Equal(t, []string{"a", "b", "c"}, deltas) } func TestStreamer_RetentionRemovesAfterTimer(t *testing.T) { t.Parallel() repo := newStreamerFakeRepo() fake := llm.NewFake() fake.Events = []llm.StreamEvent{ {Type: llm.EventDone, Reason: "end_turn"}, } dispatcher := &fakeNotifyDispatcher{} hub := &streamerHub{ byMsgID: make(map[int64]*streamer), repo: repo, registry: &fakeClientGetter{client: fake}, dispatch: dispatcher, auditor: noopAuditor{}, convSvc: nil, log: discardLogger(), retention: 20 * time.Millisecond, // very short for test speed safety: 0.9, } msgID := int64(3) p := defaultParams(msgID) require.NoError(t, hub.Start(context.Background(), p)) // Stream should complete; wait for it. sub, ok := hub.Subscribe(msgID) require.True(t, ok) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() drainSub(ctx, sub) // After retention elapses, the entry should be gone. require.Eventually(t, func() bool { hub.mu.Lock() defer hub.mu.Unlock() _, exists := hub.byMsgID[msgID] return !exists }, 500*time.Millisecond, 5*time.Millisecond, "byMsgID entry should be removed after retention") } func TestStreamer_TitleGenerationTriggered(t *testing.T) { t.Parallel() repo := newStreamerFakeRepo() convoID := uuid.New() // Set up a fake conversation with no title so GenerateTitle path runs. repo.conversations[convoID] = &Conversation{ID: convoID, Title: nil} fake := llm.NewFake() fake.Events = []llm.StreamEvent{ {Type: llm.EventTextDelta, Text: "hello"}, {Type: llm.EventDone, Reason: "end_turn"}, } dispatcher := &fakeNotifyDispatcher{} // We need a real conversationService-like thing to spy on. // Use a titleTriggeredFlag that we can set via a custom convSvc wrapper. var titleTriggered atomic.Bool convSvc := &spyConvSvc{onGenerateTitle: func() { titleTriggered.Store(true) }} hub := &streamerHub{ byMsgID: make(map[int64]*streamer), repo: repo, registry: &fakeClientGetter{client: fake}, dispatch: dispatcher, auditor: noopAuditor{}, convSvc: convSvc.asConversationService(), log: discardLogger(), retention: 50 * time.Millisecond, safety: 0.9, } msgID := int64(11) p := defaultParams(msgID) p.ConversationID = convoID require.NoError(t, hub.Start(context.Background(), p)) sub, ok := hub.Subscribe(msgID) require.True(t, ok) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() drainSub(ctx, sub) require.Eventually(t, func() bool { return titleTriggered.Load() }, 1*time.Second, 10*time.Millisecond, "GenerateTitle should have been triggered") } // TestStreamer_Next_EarlyCancel_NoLeak exercises the early-cancel race in // subscription.Next: when ctx is already cancelled (or cancels in the window // before the waiter parks on cond.Wait) and there are no new events, Next must // return ctx.Err() promptly instead of blocking forever, and must not leak the // Next goroutine or its ctx watcher. func TestStreamer_Next_EarlyCancel_NoLeak(t *testing.T) { t.Parallel() st := newStreamer() sub := &subscription{st: st} // Case 1: ctx already cancelled before Next is called. With no events and an // open stream, the predicate is unsatisfied except for ctx, so Next must // observe cancellation immediately rather than parking forever. ctx, cancel := context.WithCancel(context.Background()) cancel() done := make(chan struct{}) go func() { defer close(done) evs, closed, err := sub.Next(ctx, 0) require.ErrorIs(t, err, context.Canceled) require.Nil(t, evs) require.False(t, closed) }() select { case <-done: case <-time.After(2 * time.Second): t.Fatal("Next did not return on an already-cancelled ctx; goroutine leaked") } // Case 2: cancel concurrently while a waiter is (about to be) parked. Repeat // to widen the window between launching the watcher and parking on Wait. for i := 0; i < 200; i++ { ctx, cancel := context.WithCancel(context.Background()) ret := make(chan error, 1) go func() { _, _, err := sub.Next(ctx, 0) ret <- err }() cancel() select { case err := <-ret: require.ErrorIs(t, err, context.Canceled) case <-time.After(2 * time.Second): t.Fatalf("iteration %d: Next wedged on concurrent cancel", i) } } } // TestStreamer_Next_DataDeliveredDespiteCancel verifies that when events are // already buffered, Next returns them even if ctx is cancelled (data wins over // a late cancel, preserving the original behavior). func TestStreamer_Next_DataDeliveredDespiteCancel(t *testing.T) { t.Parallel() st := newStreamer() sub := &subscription{st: st} st.append(SSEEvent{Event: "text_delta", Data: map[string]string{"text": "hi"}}) ctx, cancel := context.WithCancel(context.Background()) cancel() evs, closed, err := sub.Next(ctx, 0) require.NoError(t, err) require.False(t, closed) require.Len(t, evs, 1) require.Equal(t, "text_delta", evs[0].Event) } // ===== helpers for title generation test ===== // spyConvSvc is a minimal conversationService-shaped struct that calls a hook // when GenerateTitle is invoked. It wraps the concrete type via embedding. type spyConvSvc struct { onGenerateTitle func() } // asConversationService returns a *conversationService whose GenerateTitle // method we intercept by using a specially crafted repo. func (s *spyConvSvc) asConversationService() *conversationService { // We can't monkey-patch the method, so we use a dedicated spy repo instead. // See spyConvSvcRepo below. return &conversationService{ repo: &spyConvSvcRepo{hook: s.onGenerateTitle}, log: discardLogger(), auditor: noopAuditor{}, } } // spyConvSvcRepo is a repository whose GetTitleGeneratorModel returns a fake // model, causing GenerateTitle to attempt a stream — but we override // GetFirstUserMessage to return an error so it bails early, and we call the // hook right before the bail. type spyConvSvcRepo struct { panicRepo hook func() } func (r *spyConvSvcRepo) GetConversation(_ context.Context, id uuid.UUID) (*Conversation, error) { return &Conversation{ID: id, Title: nil}, nil } func (r *spyConvSvcRepo) GetTitleGeneratorModel(_ context.Context) (*LLMModel, error) { r.hook() // signal that title generation was triggered return nil, &testError{"no model"} } func (r *spyConvSvcRepo) GetFirstUserMessage(_ context.Context, _ uuid.UUID) (*Message, error) { panic("should not reach here") } func (r *spyConvSvcRepo) UpdateConversationTitle(_ context.Context, _ uuid.UUID, _ *string) error { return nil } // testError is a simple error type for tests. type testError struct{ msg string } func (e *testError) Error() string { return e.msg } // Compile-time assertion: streamerFakeRepo satisfies Repository. var _ Repository = (*streamerFakeRepo)(nil) // Compile-time assertion: panicRepo satisfies Repository. var _ Repository = (*panicRepo)(nil) // Compile-time assertion: spyConvSvcRepo satisfies Repository. var _ Repository = (*spyConvSvcRepo)(nil)