From 7cbdc6a9e2bffdf324469dda0b6e0e14f6af0a46 Mon Sep 17 00:00:00 2001 From: Angel Beltran Date: Thu, 6 Aug 2026 20:39:30 -0600 Subject: [PATCH] tests: migrate assertions to testify's assert/require Replaces raw t.Error/t.Errorf/t.Fatal/t.Fatalf across every test file that has any (domains/accounts, domains/authentication, domains/raw_events, domains/amazon, domains/reports x2) with testify's assert (non-halting) / require (halting) equivalents. The three Example-based tests (server/ui/svg, server/ui/charts) have no *testing.T at all - nothing to convert there. require.Eventually replaces several hand-rolled polling loops in domains/amazon/mock_test.go. Its condition function runs on a separate goroutine (confirmed in testify's source), so calling require.* from inside one - which two of the new Eventually calls initially did, via the isProcessed helper - is unsafe per Go's testing rules (t.FailNow must only be called from the test's own goroutine). Fixed by splitting a *testing.T-free queryIsProcessed(ctx, pool, shopID, eventID) out of isProcessed for use inside those closures specifically. github.com/stretchr/testify promoted from an indirect to a direct dependency (go.mod only - it was already present transitively, so go.sum is unchanged). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY --- domains/accounts/accounts_test.go | 72 ++--- domains/amazon/mock_test.go | 251 +++++++----------- domains/authentication/dev_test.go | 53 ++-- domains/raw_events/events_test.go | 41 +-- domains/reports/events_test.go | 58 ++-- domains/reports/reports_test.go | 80 +++--- go.mod | 3 + .../work-summary-Claude-2026-08-06-2026.md | 31 +++ 8 files changed, 230 insertions(+), 359 deletions(-) create mode 100644 work-summaries/work-summary-Claude-2026-08-06-2026.md diff --git a/domains/accounts/accounts_test.go b/domains/accounts/accounts_test.go index 482c381..bcf5f92 100644 --- a/domains/accounts/accounts_test.go +++ b/domains/accounts/accounts_test.go @@ -2,9 +2,11 @@ package accounts_test import ( "context" - "errors" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "ruben/inventory2/consts" "ruben/inventory2/domains/accounts" "ruben/inventory2/internal/testdb" @@ -21,30 +23,18 @@ func TestCreateAccount(t *testing.T) { email := userID + "@example.com" acct, err := store.CreateAccount(ctx, userID, email) - if err != nil { - t.Fatalf("CreateAccount() error = %v", err) - } + require.NoError(t, err, "CreateAccount()") t.Cleanup(func() { pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID) }) - if acct.AccountID == 0 { - t.Error("CreateAccount() returned a zero AccountID") - } - if acct.UserID != userID { - t.Errorf("CreateAccount() UserID = %q, want %q", acct.UserID, userID) - } - if acct.Email != email { - t.Errorf("CreateAccount() Email = %q, want %q", acct.Email, email) - } + assert.NotZero(t, acct.AccountID, "CreateAccount() returned a zero AccountID") + assert.Equal(t, userID, acct.UserID, "CreateAccount() UserID") + assert.Equal(t, email, acct.Email, "CreateAccount() Email") got, err := store.GetAccount(ctx, acct.AccountID) - if err != nil { - t.Fatalf("GetAccount() error = %v", err) - } - if got != acct { - t.Errorf("GetAccount() = %+v, want %+v", got, acct) - } + require.NoError(t, err, "GetAccount()") + assert.Equal(t, acct, got, "GetAccount()") } func TestCreateAccount_DuplicateUserIsConflict(t *testing.T) { @@ -56,17 +46,13 @@ func TestCreateAccount_DuplicateUserIsConflict(t *testing.T) { testdb.SeedOAuthUser(t, pool, userID) acct, err := store.CreateAccount(ctx, userID, userID+"@example.com") - if err != nil { - t.Fatalf("first CreateAccount() error = %v", err) - } + require.NoError(t, err, "first CreateAccount()") t.Cleanup(func() { pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID) }) _, err = store.CreateAccount(ctx, userID, userID+"-other@example.com") - if !errors.Is(err, consts.ErrConflict) { - t.Fatalf("second CreateAccount() error = %v, want %v", err, consts.ErrConflict) - } + require.ErrorIs(t, err, consts.ErrConflict, "second CreateAccount()") } func TestGetAccount_NotFound(t *testing.T) { @@ -75,9 +61,7 @@ func TestGetAccount_NotFound(t *testing.T) { ctx := context.Background() _, err := store.GetAccount(ctx, -1) - if !errors.Is(err, consts.ErrNotFound) { - t.Fatalf("GetAccount() error = %v, want %v", err, consts.ErrNotFound) - } + require.ErrorIs(t, err, consts.ErrNotFound, "GetAccount()") } func TestGetUserAndAccountByAccessToken(t *testing.T) { @@ -90,34 +74,22 @@ func TestGetUserAndAccountByAccessToken(t *testing.T) { // before an account exists: user resolves, account does not. user, acct, err := store.GetUserAndAccountByAccessToken(ctx, accessToken) - if err != nil { - t.Fatalf("GetUserAndAccountByAccessToken() before account creation: error = %v", err) - } - if user.UserID != userID { - t.Errorf("GetUserAndAccountByAccessToken() UserID = %q, want %q", user.UserID, userID) - } - if acct != nil { - t.Errorf("GetUserAndAccountByAccessToken() Account = %+v, want nil before an account is created", acct) - } + require.NoError(t, err, "GetUserAndAccountByAccessToken() before account creation") + assert.Equal(t, userID, user.UserID, "GetUserAndAccountByAccessToken() UserID") + assert.Nil(t, acct, "GetUserAndAccountByAccessToken() Account should be nil before an account is created") created, err := store.CreateAccount(ctx, userID, userID+"@example.com") - if err != nil { - t.Fatalf("CreateAccount() error = %v", err) - } + require.NoError(t, err, "CreateAccount()") t.Cleanup(func() { pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", created.AccountID) }) // after an account exists: both resolve. user, acct, err = store.GetUserAndAccountByAccessToken(ctx, accessToken) - if err != nil { - t.Fatalf("GetUserAndAccountByAccessToken() after account creation: error = %v", err) - } - if user.UserID != userID { - t.Errorf("GetUserAndAccountByAccessToken() UserID = %q, want %q", user.UserID, userID) - } - if acct == nil || acct.AccountID != created.AccountID { - t.Errorf("GetUserAndAccountByAccessToken() Account = %+v, want AccountID %d", acct, created.AccountID) + require.NoError(t, err, "GetUserAndAccountByAccessToken() after account creation") + assert.Equal(t, userID, user.UserID, "GetUserAndAccountByAccessToken() UserID") + if assert.NotNil(t, acct, "GetUserAndAccountByAccessToken() Account should be set after an account is created") { + assert.Equal(t, created.AccountID, acct.AccountID, "GetUserAndAccountByAccessToken() Account.AccountID") } } @@ -127,7 +99,5 @@ func TestGetUserAndAccountByAccessToken_UnknownToken(t *testing.T) { ctx := context.Background() _, _, err := store.GetUserAndAccountByAccessToken(ctx, "no-such-token-"+testdb.NewUserID(t)) - if !errors.Is(err, consts.ErrNotFound) { - t.Fatalf("GetUserAndAccountByAccessToken() error = %v, want %v", err, consts.ErrNotFound) - } + require.ErrorIs(t, err, consts.ErrNotFound, "GetUserAndAccountByAccessToken()") } diff --git a/domains/amazon/mock_test.go b/domains/amazon/mock_test.go index 4536412..5df3c00 100644 --- a/domains/amazon/mock_test.go +++ b/domains/amazon/mock_test.go @@ -8,6 +8,8 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "ruben/inventory2/domains/raw_events" "ruben/inventory2/internal/testdb" @@ -70,9 +72,15 @@ func (s *notifySpy) ackAt(t *testing.T, i int) { s.mu.Lock() ack := s.acks[i] s.mu.Unlock() - if err := ack(context.Background()); err != nil { - t.Fatalf("ack() error = %v", err) - } + require.NoError(t, ack(context.Background()), "ack()") +} + +// completedCountSnapshot is safe to call from another goroutine (e.g. from +// inside require.Eventually's condition), unlike helpers that call t.Fatal. +func (s *notifySpy) completedCountSnapshot() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.completedCount } // waitForCount blocks until at least n Notify calls have fully completed @@ -80,20 +88,9 @@ func (s *notifySpy) ackAt(t *testing.T, i int) { // more than once with increasing n. func (s *notifySpy) waitForCount(t *testing.T, n int, timeout time.Duration) { t.Helper() - deadline := time.Now().Add(timeout) - for { - s.mu.Lock() - got := s.completedCount - s.mu.Unlock() - - if got >= n { - return - } - if time.Now().After(deadline) { - t.Fatalf("timed out after %v waiting for %d total completed notifications (got %d so far)", timeout, n, got) - } - time.Sleep(5 * time.Millisecond) - } + require.Eventually(t, func() bool { + return s.completedCountSnapshot() >= n + }, timeout, 5*time.Millisecond, "timed out waiting for %d total completed notifications (got %d)", n, s.completedCountSnapshot()) } // insertRawAmazonEvent inserts directly into mock.raw_shop_events, the same @@ -109,9 +106,7 @@ func insertRawAmazonEvent(t *testing.T, pool *pgxpool.Pool, shopID, eventID stri INSERT INTO mock.raw_shop_events (platform, shop_id, event_timestamp, event_id, raw_payload) VALUES ('amazon', $1, NOW(), $2, '{}'::jsonb) `, shopID, eventID) - if err != nil { - t.Fatalf("failed to insert raw amazon event: %v", err) - } + require.NoError(t, err, "insert raw amazon event") } // insertRawAmazonEvents bulk-inserts n events for shopID in one statement, @@ -123,22 +118,27 @@ func insertRawAmazonEvents(t *testing.T, pool *pgxpool.Pool, shopID string, n in SELECT 'amazon', $1, NOW() + (s || ' milliseconds')::interval, 'evt-' || s, '{}'::jsonb FROM generate_series(1, $2) AS s `, shopID, n) - if err != nil { - t.Fatalf("failed to insert %d raw amazon events: %v", n, err) - } + require.NoError(t, err, "insert %d raw amazon events", n) } -func isProcessed(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) bool { - t.Helper() +// queryIsProcessed has no *testing.T dependency, so it's safe to call from +// require.Eventually's condition function (which testify runs on a +// separate goroutine - t.Fatal/require must only be called from the main +// test goroutine). isProcessed wraps it for direct, main-goroutine use. +func queryIsProcessed(ctx context.Context, pool *pgxpool.Pool, shopID, eventID string) (bool, error) { var processed bool - err := pool.QueryRow(context.Background(), ` + err := pool.QueryRow(ctx, ` SELECT processed_at IS NOT NULL FROM mock.shop_amazon_events WHERE shop_id = $1 AND event_id = $2 `, shopID, eventID).Scan(&processed) - if err != nil { - t.Fatalf("failed to check processed state: %v", err) - } + return processed, err +} + +func isProcessed(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) bool { + t.Helper() + processed, err := queryIsProcessed(context.Background(), pool, shopID, eventID) + require.NoError(t, err, "check processed state") return processed } @@ -152,9 +152,7 @@ func isNotified(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) bool { FROM mock.shop_amazon_events WHERE shop_id = $1 AND event_id = $2 `, shopID, eventID).Scan(¬ified) - if err != nil { - t.Fatalf("failed to check notified state: %v", err) - } + require.NoError(t, err, "check notified state") return notified } @@ -164,9 +162,7 @@ func countUnprocessed(t *testing.T, pool *pgxpool.Pool, shopID string) int { err := pool.QueryRow(context.Background(), ` SELECT count(*) FROM mock.shop_amazon_events WHERE shop_id = $1 AND processed_at IS NULL `, shopID).Scan(&n) - if err != nil { - t.Fatalf("failed to count unprocessed events: %v", err) - } + require.NoError(t, err, "count unprocessed events") return n } @@ -197,12 +193,33 @@ func terminateListenConnection(t *testing.T, pool *pgxpool.Pool) { ORDER BY backend_start DESC LIMIT 1 `, eventChannelName).Scan(&pid) - if err != nil { - t.Fatalf("failed to find the LISTEN connection's backend pid: %v", err) - } + require.NoError(t, err, "find the LISTEN connection's backend pid") - if _, err := pool.Exec(ctx, `SELECT pg_terminate_backend($1)`, pid); err != nil { - t.Fatalf("failed to terminate backend %d: %v", pid, err) + _, err = pool.Exec(ctx, `SELECT pg_terminate_backend($1)`, pid) + require.NoError(t, err, "terminate backend %d", pid) +} + +// waitReady blocks until m signals it's actively listening, failing the +// test if that doesn't happen within timeout. +func waitReady(t *testing.T, m *Mocks, timeout time.Duration) { + t.Helper() + select { + case <-m.Ready(): + case <-time.After(timeout): + require.Fail(t, "ProcessEvents() did not become ready (LISTEN registered)", "within %v", timeout) + } +} + +// waitStopped blocks until ProcessEvents returns on errCh, asserting it +// returns a nil error, failing the test if that doesn't happen within +// timeout. +func waitStopped(t *testing.T, errCh <-chan error, timeout time.Duration) { + t.Helper() + select { + case err := <-errCh: + require.NoError(t, err, "ProcessEvents() after context cancellation") + case <-time.After(timeout): + require.Fail(t, "ProcessEvents() did not return", "within %v of context cancellation", timeout) } } @@ -218,15 +235,12 @@ func TestProcessUnprocessedEvents_ProcessesAllEventsAcrossBatches(t *testing.T) const n = 150 // exceeds the 100-row LIMIT per batch inside processUnprocessedEvents insertRawAmazonEvents(t, pool, shopID, n) - if err := m.processUnprocessedEvents(ctx); err != nil { - t.Fatalf("processUnprocessedEvents() error = %v", err) - } + require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents()") spy.waitForCount(t, n, 5*time.Second) - if got := countUnprocessed(t, pool, shopID); got != 0 { - t.Errorf("countUnprocessed() = %d, want 0 (all %d events should be processed across multiple 100-row batches)", got, n) - } + assert.Zero(t, countUnprocessed(t, pool, shopID), + "countUnprocessed() should be 0 (all %d events should be processed across multiple 100-row batches)", n) } func TestProcessUnprocessedEvents_NoListenerConfigured(t *testing.T) { @@ -239,18 +253,14 @@ func TestProcessUnprocessedEvents_NoListenerConfigured(t *testing.T) { insertRawAmazonEvent(t, pool, shopID, "evt-1") - if err := m.processUnprocessedEvents(ctx); err != nil { - t.Fatalf("processUnprocessedEvents() error = %v", err) - } + require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents()") // with no listener, nothing ever acks - the event is expected to stay // "notified" forever, not silently marked processed. - if isProcessed(t, pool, shopID, "evt-1") { - t.Error("event was marked processed despite no listener being configured to ack it") - } - if !isNotified(t, pool, shopID, "evt-1") { - t.Error("event should be in the notified state after being handed off with no listener to ack it") - } + assert.False(t, isProcessed(t, pool, shopID, "evt-1"), + "event was marked processed despite no listener being configured to ack it") + assert.True(t, isNotified(t, pool, shopID, "evt-1"), + "event should be in the notified state after being handed off with no listener to ack it") } // TestProcessUnprocessedEvents_RetriesUnackedNotification is the core of @@ -271,47 +281,29 @@ func TestProcessUnprocessedEvents_RetriesUnackedNotification(t *testing.T) { insertRawAmazonEvent(t, pool, shopID, "evt-1") - if err := m.processUnprocessedEvents(ctx); err != nil { - t.Fatalf("processUnprocessedEvents() (1st pass) error = %v", err) - } + require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (1st pass)") spy.waitForCount(t, 1, 2*time.Second) - if isProcessed(t, pool, shopID, "evt-1") { - t.Fatal("event was marked processed despite the listener never acking") - } - if !isNotified(t, pool, shopID, "evt-1") { - t.Fatal("event should be in the notified state after the first dispatch") - } + require.False(t, isProcessed(t, pool, shopID, "evt-1"), "event was marked processed despite the listener never acking") + require.True(t, isNotified(t, pool, shopID, "evt-1"), "event should be in the notified state after the first dispatch") // still within notifyRetryAfter: shouldn't be re-notified yet. - if err := m.processUnprocessedEvents(ctx); err != nil { - t.Fatalf("processUnprocessedEvents() (immediate 2nd pass) error = %v", err) - } - if got := len(spy.eventsSnapshot()); got != 1 { - t.Fatalf("listener was notified %d times before notifyRetryAfter elapsed, want 1", got) - } + require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (immediate 2nd pass)") + require.Len(t, spy.eventsSnapshot(), 1, "listener should not have been notified again before notifyRetryAfter elapsed") time.Sleep(60 * time.Millisecond) // past notifyRetryAfter - if err := m.processUnprocessedEvents(ctx); err != nil { - t.Fatalf("processUnprocessedEvents() (3rd pass, after retry window) error = %v", err) - } + require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (3rd pass, after retry window)") spy.waitForCount(t, 2, 2*time.Second) // the listener "finishes" the first notification late, via the ack it // was originally handed - not a fresh one from the retry. spy.ackAt(t, 0) - if !isProcessed(t, pool, shopID, "evt-1") { - t.Fatal("event should be processed once any recorded ack for it is called") - } + require.True(t, isProcessed(t, pool, shopID, "evt-1"), "event should be processed once any recorded ack for it is called") - if err := m.processUnprocessedEvents(ctx); err != nil { - t.Fatalf("processUnprocessedEvents() (4th pass, after ack) error = %v", err) - } - if got := len(spy.eventsSnapshot()); got != 2 { - t.Fatalf("listener was notified again after being acked: got %d calls, want 2", got) - } + require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (4th pass, after ack)") + assert.Len(t, spy.eventsSnapshot(), 2, "listener should not be notified again after being acked") } // TestProcessEvents_PollFallbackPicksUpRetryDueEvents proves the poll @@ -341,11 +333,7 @@ func TestProcessEvents_PollFallbackPicksUpRetryDueEvents(t *testing.T) { errCh <- m.ProcessEvents(ctx) }() - select { - case <-m.Ready(): - case <-time.After(5 * time.Second): - t.Fatal("ProcessEvents() did not become ready (LISTEN registered) within 5s") - } + waitReady(t, m, 5*time.Second) insertRawAmazonEvent(t, pool, shopID, "evt-1") @@ -358,14 +346,7 @@ func TestProcessEvents_PollFallbackPicksUpRetryDueEvents(t *testing.T) { spy.waitForCount(t, 2, 3*time.Second) cancel() - select { - case err := <-errCh: - if err != nil { - t.Fatalf("ProcessEvents() returned error = %v after context cancellation, want nil", err) - } - case <-time.After(5 * time.Second): - t.Fatal("ProcessEvents() did not return within 5s of context cancellation") - } + waitStopped(t, errCh, 5*time.Second) } // TestProcessEvents_ReactsToNotification drives the actual long-running @@ -389,36 +370,24 @@ func TestProcessEvents_ReactsToNotification(t *testing.T) { errCh <- m.ProcessEvents(ctx) }() - select { - case <-m.Ready(): - case <-time.After(5 * time.Second): - t.Fatal("ProcessEvents() did not become ready (LISTEN registered) within 5s") - } + waitReady(t, m, 5*time.Second) insertRawAmazonEvent(t, pool, shopID, "evt-1") - deadline := time.Now().Add(5 * time.Second) - for !isProcessed(t, pool, shopID, "evt-1") { - if time.Now().After(deadline) { - t.Fatal("event was not processed within 5s of insertion - the reactive LISTEN/NOTIFY wake-up did not fire (the 1-minute poll fallback would eventually catch it, but this test intentionally doesn't wait that long)") - } - time.Sleep(20 * time.Millisecond) - } + require.Eventually(t, func() bool { + processed, _ := queryIsProcessed(context.Background(), pool, shopID, "evt-1") + return processed + }, 5*time.Second, 20*time.Millisecond, + "event was not processed within 5s of insertion - the reactive LISTEN/NOTIFY wake-up did not fire "+ + "(the 1-minute poll fallback would eventually catch it, but this test intentionally doesn't wait that long)") spy.waitForCount(t, 1, 2*time.Second) - if got := spy.eventsSnapshot()[0]; got.StoreID != shopID || got.EventID != "evt-1" { - t.Errorf("listener notified with %+v, want StoreID=%q EventID=%q", got, shopID, "evt-1") - } + got := spy.eventsSnapshot()[0] + assert.Equal(t, shopID, got.StoreID, "listener notified with unexpected StoreID") + assert.Equal(t, "evt-1", got.EventID, "listener notified with unexpected EventID") cancel() - select { - case err := <-errCh: - if err != nil { - t.Fatalf("ProcessEvents() returned error = %v after context cancellation, want nil", err) - } - case <-time.After(5 * time.Second): - t.Fatal("ProcessEvents() did not return within 5s of context cancellation") - } + waitStopped(t, errCh, 5*time.Second) } // TestProcessEvents_ShutsDownOnContextCancel checks the lifecycle in @@ -436,21 +405,10 @@ func TestProcessEvents_ShutsDownOnContextCancel(t *testing.T) { errCh <- m.ProcessEvents(ctx) }() - select { - case <-m.Ready(): - case <-time.After(5 * time.Second): - t.Fatal("ProcessEvents() did not become ready (LISTEN registered) within 5s") - } + waitReady(t, m, 5*time.Second) cancel() - select { - case err := <-errCh: - if err != nil { - t.Fatalf("ProcessEvents() returned error = %v after context cancellation, want nil", err) - } - case <-time.After(5 * time.Second): - t.Fatal("ProcessEvents() did not return within 5s of context cancellation") - } + waitStopped(t, errCh, 5*time.Second) } // TestProcessEvents_ReconnectsAfterListenConnectionDrops is insight #4's @@ -474,11 +432,7 @@ func TestProcessEvents_ReconnectsAfterListenConnectionDrops(t *testing.T) { errCh <- m.ProcessEvents(ctx) }() - select { - case <-m.Ready(): - case <-time.After(5 * time.Second): - t.Fatal("ProcessEvents() did not become ready (LISTEN registered) within 5s") - } + waitReady(t, m, 5*time.Second) terminateListenConnection(t, pool) @@ -486,27 +440,20 @@ func TestProcessEvents_ReconnectsAfterListenConnectionDrops(t *testing.T) { // backoff is 1s) - it must not have returned because of this. select { case err := <-errCh: - t.Fatalf("ProcessEvents() returned (err = %v) after its LISTEN connection was killed, want it to reconnect and keep running", err) + require.Fail(t, "ProcessEvents() returned after its LISTEN connection was killed, want it to reconnect and keep running", + "err = %v", err) case <-time.After(2 * time.Second): } insertRawAmazonEvent(t, pool, shopID, "evt-1") - deadline := time.Now().Add(5 * time.Second) - for !isProcessed(t, pool, shopID, "evt-1") { - if time.Now().After(deadline) { - t.Fatal("event was not processed within 5s of insertion after the LISTEN connection was forcibly dropped - reconnection did not restore the reactive path") - } - time.Sleep(20 * time.Millisecond) - } + require.Eventually(t, func() bool { + processed, _ := queryIsProcessed(context.Background(), pool, shopID, "evt-1") + return processed + }, 5*time.Second, 20*time.Millisecond, + "event was not processed within 5s of insertion after the LISTEN connection was forcibly dropped - "+ + "reconnection did not restore the reactive path") cancel() - select { - case err := <-errCh: - if err != nil { - t.Fatalf("ProcessEvents() returned error = %v after context cancellation, want nil", err) - } - case <-time.After(5 * time.Second): - t.Fatal("ProcessEvents() did not return within 5s of context cancellation") - } + waitStopped(t, errCh, 5*time.Second) } diff --git a/domains/authentication/dev_test.go b/domains/authentication/dev_test.go index db9ec65..343b664 100644 --- a/domains/authentication/dev_test.go +++ b/domains/authentication/dev_test.go @@ -2,10 +2,12 @@ package authentication import ( "context" - "errors" "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "ruben/inventory2/consts" "ruben/inventory2/internal/testdb" ) @@ -22,36 +24,21 @@ func TestDevLogin(t *testing.T) { }) accessToken, expiration, err := auth.DevLogin(ctx, userID, "Test User") - if err != nil { - t.Fatalf("DevLogin() error = %v", err) - } - if accessToken == "" { - t.Fatal("DevLogin() returned an empty access token") - } - if !expiration.After(time.Now().Add(30 * 24 * time.Hour)) { - t.Errorf("DevLogin() expiration = %v, want something far enough out to avoid the near-expiry refresh path", expiration) - } + require.NoError(t, err, "DevLogin()") + require.NotEmpty(t, accessToken, "DevLogin() returned an empty access token") + assert.True(t, expiration.After(time.Now().Add(30*24*time.Hour)), + "DevLogin() expiration = %v, want something far enough out to avoid the near-expiry refresh path", expiration) claims, err := auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken) - if err != nil { - t.Fatalf("GetAccessTokenClaimsAndExpiration() error = %v", err) - } - if claims.Name != "Test User" { - t.Errorf("claims.Name = %q, want %q", claims.Name, "Test User") - } + require.NoError(t, err, "GetAccessTokenClaimsAndExpiration()") + assert.Equal(t, "Test User", claims.Name, "claims.Name") // Postgres timestamptz has microsecond precision, so the round-tripped // value loses the sub-microsecond portion of Go's nanosecond clock. - if diff := claims.Expiration.Sub(expiration); diff > time.Millisecond || diff < -time.Millisecond { - t.Errorf("claims.Expiration = %v, want ~%v (diff %v)", claims.Expiration, expiration, diff) - } + assert.WithinDuration(t, expiration, claims.Expiration, time.Millisecond, "claims.Expiration") _, tokenType, err := auth.getRefreshTokenForAccessToken(ctx, accessToken) - if err != nil { - t.Fatalf("getRefreshTokenForAccessToken() error = %v", err) - } - if tokenType != "dev" { - t.Errorf("tokenType = %q, want %q", tokenType, "dev") - } + require.NoError(t, err, "getRefreshTokenForAccessToken()") + assert.Equal(t, "dev", tokenType, "tokenType") } // DevLogin should be safe to call more than once for the same user_id - @@ -69,18 +56,12 @@ func TestDevLogin_SameUserIDTwice(t *testing.T) { }) token1, _, err := auth.DevLogin(ctx, userID, "Test User") - if err != nil { - t.Fatalf("first DevLogin() error = %v", err) - } + require.NoError(t, err, "first DevLogin()") token2, _, err := auth.DevLogin(ctx, userID, "Test User") - if err != nil { - t.Fatalf("second DevLogin() error = %v", err) - } + require.NoError(t, err, "second DevLogin()") - if token1 == token2 { - t.Fatalf("DevLogin() returned the same access token twice: %q", token1) - } + assert.NotEqual(t, token1, token2, "DevLogin() returned the same access token twice") } func TestGetAccessTokenClaimsAndExpiration_UnknownToken(t *testing.T) { @@ -89,7 +70,5 @@ func TestGetAccessTokenClaimsAndExpiration_UnknownToken(t *testing.T) { ctx := context.Background() _, err := auth.GetAccessTokenClaimsAndExpiration(ctx, "no-such-token-"+testdb.NewUserID(t)) - if !errors.Is(err, consts.ErrNotFound) { - t.Fatalf("GetAccessTokenClaimsAndExpiration() error = %v, want %v", err, consts.ErrNotFound) - } + require.ErrorIs(t, err, consts.ErrNotFound, "GetAccessTokenClaimsAndExpiration()") } diff --git a/domains/raw_events/events_test.go b/domains/raw_events/events_test.go index 605947c..9e8bc7b 100644 --- a/domains/raw_events/events_test.go +++ b/domains/raw_events/events_test.go @@ -7,6 +7,8 @@ import ( "time" "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "ruben/inventory2/domains/raw_events" "ruben/inventory2/internal/testdb" @@ -39,33 +41,20 @@ func TestSaveAndLoadEventsForStore(t *testing.T) { Payload: json.RawMessage(`{"n":2}`), } - if err := store.Save(ctx, &older); err != nil { - t.Fatalf("Save() older event error = %v", err) - } - if err := store.Save(ctx, &newer); err != nil { - t.Fatalf("Save() newer event error = %v", err) - } + require.NoError(t, store.Save(ctx, &older), "Save() older event") + require.NoError(t, store.Save(ctx, &newer), "Save() newer event") got, err := store.LoadEventsForStore(ctx, platform, storeID) - if err != nil { - t.Fatalf("LoadEventsForStore() error = %v", err) - } - - if len(got) != 2 { - t.Fatalf("LoadEventsForStore() returned %d events, want 2: %+v", len(got), got) - } + require.NoError(t, err, "LoadEventsForStore()") + require.Len(t, got, 2, "LoadEventsForStore()") // ordered event_timestamp DESC - newest first. - if got[0].EventID != "evt-2" || got[1].EventID != "evt-1" { - t.Errorf("LoadEventsForStore() order = [%s, %s], want [evt-2, evt-1]", got[0].EventID, got[1].EventID) - } + assert.Equal(t, "evt-2", got[0].EventID, "LoadEventsForStore()[0]") + assert.Equal(t, "evt-1", got[1].EventID, "LoadEventsForStore()[1]") + var payload struct{ N int } - if err := json.Unmarshal(got[0].Payload, &payload); err != nil { - t.Fatalf("failed to unmarshal LoadEventsForStore()[0].Payload = %s: %v", got[0].Payload, err) - } - if payload.N != 2 { - t.Errorf("LoadEventsForStore()[0].Payload n = %d, want 2", payload.N) - } + require.NoError(t, json.Unmarshal(got[0].Payload, &payload), "unmarshal LoadEventsForStore()[0].Payload") + assert.Equal(t, 2, payload.N, "LoadEventsForStore()[0].Payload n") } func TestLoadEventsForStore_NoEvents(t *testing.T) { @@ -74,10 +63,6 @@ func TestLoadEventsForStore_NoEvents(t *testing.T) { ctx := context.Background() got, err := store.LoadEventsForStore(ctx, "test-platform", "no-such-store-"+uuid.NewString()) - if err != nil { - t.Fatalf("LoadEventsForStore() error = %v", err) - } - if len(got) != 0 { - t.Fatalf("LoadEventsForStore() = %+v, want empty", got) - } + require.NoError(t, err, "LoadEventsForStore()") + assert.Empty(t, got, "LoadEventsForStore()") } diff --git a/domains/reports/events_test.go b/domains/reports/events_test.go index 7d7fe79..6339c8a 100644 --- a/domains/reports/events_test.go +++ b/domains/reports/events_test.go @@ -3,12 +3,13 @@ package reports_test import ( "context" "encoding/json" - "errors" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "ruben/inventory2/consts" "ruben/inventory2/domains/accounts" @@ -28,17 +29,13 @@ func setupAmazonMockShop(t *testing.T, pool *pgxpool.Pool, acctStore *accounts.S testdb.SeedOAuthUser(t, pool, userID) acct, err := acctStore.CreateAccount(ctx, userID, userID+"@example.com") - if err != nil { - t.Fatalf("CreateAccount() error = %v", err) - } + require.NoError(t, err, "CreateAccount()") t.Cleanup(func() { pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID) }) id, err := acctStore.CreateMockShop(ctx, acct.AccountID, accounts.Amazon, "Test Shop") - if err != nil { - t.Fatalf("CreateMockShop() error = %v", err) - } + require.NoError(t, err, "CreateMockShop()") shopID = id.String() t.Cleanup(func() { @@ -61,9 +58,7 @@ func insertRawShopEvent(t *testing.T, pool *pgxpool.Pool, shopID, eventID string INSERT INTO mock.raw_shop_events (platform, shop_id, event_timestamp, event_id, raw_payload) VALUES ('amazon', $1, $2, $3, $4::jsonb) `, shopID, ts, eventID, payload) - if err != nil { - t.Fatalf("failed to insert raw shop event: %v", err) - } + require.NoError(t, err, "insert raw shop event") } func TestGetRawShopEvents(t *testing.T) { @@ -80,30 +75,19 @@ func TestGetRawShopEvents(t *testing.T) { insertRawShopEvent(t, pool, shopID, "evt-2", newer, `{"n":2}`) got, err := reportsStore.GetRawShopEvents(ctx, acctID, accounts.Amazon, shopID) - if err != nil { - t.Fatalf("GetRawShopEvents() error = %v", err) - } - - if len(got) != 2 { - t.Fatalf("GetRawShopEvents() returned %d events, want 2: %+v", len(got), got) - } + require.NoError(t, err, "GetRawShopEvents()") + require.Len(t, got, 2, "GetRawShopEvents()") // ordered event_timestamp DESC, event_id ASC - newest first. - if got[0].EventID != "evt-2" || got[1].EventID != "evt-1" { - t.Errorf("GetRawShopEvents() order = [%s, %s], want [evt-2, evt-1]", got[0].EventID, got[1].EventID) - } + assert.Equal(t, "evt-2", got[0].EventID, "GetRawShopEvents()[0]") + assert.Equal(t, "evt-1", got[1].EventID, "GetRawShopEvents()[1]") - if got[0].Platform != accounts.Amazon || got[0].ShopID != shopID { - t.Errorf("got[0] Platform/ShopID = %s/%s, want %s/%s", got[0].Platform, got[0].ShopID, accounts.Amazon, shopID) - } + assert.Equal(t, accounts.Amazon, got[0].Platform, "got[0].Platform") + assert.Equal(t, shopID, got[0].ShopID, "got[0].ShopID") var payload struct{ N int } - if err := json.Unmarshal(got[0].RawPayload, &payload); err != nil { - t.Fatalf("failed to unmarshal got[0].RawPayload = %s: %v", got[0].RawPayload, err) - } - if payload.N != 2 { - t.Errorf("got[0].RawPayload n = %d, want 2", payload.N) - } + require.NoError(t, json.Unmarshal(got[0].RawPayload, &payload), "unmarshal got[0].RawPayload") + assert.Equal(t, 2, payload.N, "got[0].RawPayload n") } func TestGetRawShopEvents_NoEvents(t *testing.T) { @@ -115,12 +99,8 @@ func TestGetRawShopEvents_NoEvents(t *testing.T) { acctID, shopID := setupAmazonMockShop(t, pool, acctStore) got, err := reportsStore.GetRawShopEvents(ctx, acctID, accounts.Amazon, shopID) - if err != nil { - t.Fatalf("GetRawShopEvents() error = %v", err) - } - if len(got) != 0 { - t.Fatalf("GetRawShopEvents() = %+v, want empty", got) - } + require.NoError(t, err, "GetRawShopEvents()") + assert.Empty(t, got, "GetRawShopEvents()") } func TestGetRawShopEvents_UnknownShop(t *testing.T) { @@ -133,15 +113,11 @@ func TestGetRawShopEvents_UnknownShop(t *testing.T) { testdb.SeedOAuthUser(t, pool, userID) acct, err := acctStore.CreateAccount(ctx, userID, userID+"@example.com") - if err != nil { - t.Fatalf("CreateAccount() error = %v", err) - } + require.NoError(t, err, "CreateAccount()") t.Cleanup(func() { pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID) }) _, err = reportsStore.GetRawShopEvents(ctx, acct.AccountID, accounts.Amazon, "no-such-shop-"+uuid.NewString()) - if !errors.Is(err, consts.ErrNotFound) { - t.Fatalf("GetRawShopEvents() error = %v, want %v", err, consts.ErrNotFound) - } + require.ErrorIs(t, err, consts.ErrNotFound, "GetRawShopEvents()") } diff --git a/domains/reports/reports_test.go b/domains/reports/reports_test.go index 021452d..2dd50dc 100644 --- a/domains/reports/reports_test.go +++ b/domains/reports/reports_test.go @@ -2,9 +2,11 @@ package reports_test import ( "context" - "errors" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "ruben/inventory2/consts" "ruben/inventory2/domains/accounts" "ruben/inventory2/domains/reports" @@ -34,9 +36,7 @@ func createAmazonListing(t *testing.T, acctStore *accounts.Store, acctID int64, Description: "a listing created for a test", Count: baseCount, }) - if err != nil { - t.Fatalf("CreateMockListing() error = %v", err) - } + require.NoError(t, err, "CreateMockListing()") t.Cleanup(func() { pool.Exec(context.Background(), ` @@ -64,38 +64,28 @@ func TestGetListingCountsOverTime(t *testing.T) { acctID, shopID := setupAmazonMockShop(t, pool, acctStore) listingID := createAmazonListing(t, acctStore, acctID, shopID, 100) - if _, err := acctStore.SaveNewMockSale(ctx, acctID, accounts.Amazon, shopID, listingID, 10); err != nil { - t.Fatalf("SaveNewMockSale() error = %v", err) - } - if _, err := acctStore.SaveNewMockRefund(ctx, acctID, accounts.Amazon, shopID, listingID, 5); err != nil { - t.Fatalf("SaveNewMockRefund() error = %v", err) - } - if _, err := acctStore.SaveNewMockInventoryReset(ctx, acctID, accounts.Amazon, shopID, listingID, 50); err != nil { - t.Fatalf("SaveNewMockInventoryReset() error = %v", err) - } + _, err := acctStore.SaveNewMockSale(ctx, acctID, accounts.Amazon, shopID, listingID, 10) + require.NoError(t, err, "SaveNewMockSale()") + _, err = acctStore.SaveNewMockRefund(ctx, acctID, accounts.Amazon, shopID, listingID, 5) + require.NoError(t, err, "SaveNewMockRefund()") + _, err = acctStore.SaveNewMockInventoryReset(ctx, acctID, accounts.Amazon, shopID, listingID, 50) + require.NoError(t, err, "SaveNewMockInventoryReset()") got, err := reportsStore.GetListingCountsOverTime(ctx, acctID, accounts.Amazon, shopID, listingID) - if err != nil { - t.Fatalf("GetListingCountsOverTime() error = %v", err) - } + require.NoError(t, err, "GetListingCountsOverTime()") wantCounts := []int64{100, 110, 105, 50} - if len(got) != len(wantCounts) { - t.Fatalf("GetListingCountsOverTime() returned %d rows, want %d: %+v", len(got), len(wantCounts), got) - } + require.Len(t, got, len(wantCounts), "GetListingCountsOverTime()") - if got[0].EventTimestamp != nil { - t.Errorf("got[0].EventTimestamp = %v, want nil (the base count row)", got[0].EventTimestamp) - } + gotCounts := make([]int64, len(got)) for i, row := range got { - if row.Count != wantCounts[i] { - t.Errorf("got[%d].Count = %d, want %d (full sequence: %+v)", i, row.Count, wantCounts[i], got) - } + gotCounts[i] = row.Count } + assert.Equal(t, wantCounts, gotCounts, "GetListingCountsOverTime() counts, full sequence: %+v", got) + + assert.Nil(t, got[0].EventTimestamp, "got[0].EventTimestamp should be nil (the base count row)") for i := 1; i < len(got); i++ { - if got[i].EventTimestamp == nil { - t.Errorf("got[%d].EventTimestamp = nil, want set (only the base row should be nil)", i) - } + assert.NotNil(t, got[i].EventTimestamp, "got[%d].EventTimestamp should be set (only the base row should be nil)", i) } } @@ -108,9 +98,7 @@ func TestGetListingCountsOverTime_UnknownListing(t *testing.T) { acctID, shopID := setupAmazonMockShop(t, pool, acctStore) _, err := reportsStore.GetListingCountsOverTime(ctx, acctID, accounts.Amazon, shopID, "no-such-listing") - if !errors.Is(err, consts.ErrNotFound) { - t.Fatalf("GetListingCountsOverTime() error = %v, want %v", err, consts.ErrNotFound) - } + require.ErrorIs(t, err, consts.ErrNotFound, "GetListingCountsOverTime()") } func TestGetListingCountsReport(t *testing.T) { @@ -122,28 +110,20 @@ func TestGetListingCountsReport(t *testing.T) { acctID, shopID := setupAmazonMockShop(t, pool, acctStore) listingID := createAmazonListing(t, acctStore, acctID, shopID, 100) - if _, err := acctStore.SaveNewMockSale(ctx, acctID, accounts.Amazon, shopID, listingID, 10); err != nil { - t.Fatalf("SaveNewMockSale() error = %v", err) - } - if _, err := acctStore.SaveNewMockInventoryReset(ctx, acctID, accounts.Amazon, shopID, listingID, 20); err != nil { - t.Fatalf("SaveNewMockInventoryReset() error = %v", err) - } + _, err := acctStore.SaveNewMockSale(ctx, acctID, accounts.Amazon, shopID, listingID, 10) + require.NoError(t, err, "SaveNewMockSale()") + _, err = acctStore.SaveNewMockInventoryReset(ctx, acctID, accounts.Amazon, shopID, listingID, 20) + require.NoError(t, err, "SaveNewMockInventoryReset()") report, err := reportsStore.GetListingCountsReport(ctx, acctID, accounts.Amazon, shopID, listingID) - if err != nil { - t.Fatalf("GetListingCountsReport() error = %v", err) - } + require.NoError(t, err, "GetListingCountsReport()") - if report.AccountID != acctID || report.Platform != accounts.Amazon || report.ShopID != shopID || report.ListingID != listingID { - t.Errorf("report identity = %+v, want AccountID=%d Platform=%s ShopID=%s ListingID=%s", - report, acctID, accounts.Amazon, shopID, listingID) - } + assert.Equal(t, acctID, report.AccountID, "report.AccountID") + assert.Equal(t, accounts.Amazon, report.Platform, "report.Platform") + assert.Equal(t, shopID, report.ShopID, "report.ShopID") + assert.Equal(t, listingID, report.ListingID, "report.ListingID") // counts over the sequence: 100 (base) -> 110 (sale +10) -> 20 (reset) - if got := report.MaxCount().Count; got != 110 { - t.Errorf("MaxCount().Count = %d, want 110", got) - } - if got := report.MinCount().Count; got != 20 { - t.Errorf("MinCount().Count = %d, want 20", got) - } + assert.Equal(t, int64(110), report.MaxCount().Count, "MaxCount().Count") + assert.Equal(t, int64(20), report.MinCount().Count, "MinCount().Count") } diff --git a/go.mod b/go.mod index c054624..9a7b57a 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/lmittmann/tint v1.1.2 github.com/oapi-codegen/runtime v1.1.2 + github.com/stretchr/testify v1.11.1 golang.org/x/oauth2 v0.34.0 ) @@ -28,6 +29,7 @@ require ( github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/getkin/kin-openapi v0.133.0 // indirect @@ -60,6 +62,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/speakeasy-api/jsonpath v0.6.0 // indirect diff --git a/work-summaries/work-summary-Claude-2026-08-06-2026.md b/work-summaries/work-summary-Claude-2026-08-06-2026.md new file mode 100644 index 0000000..36876b1 --- /dev/null +++ b/work-summaries/work-summary-Claude-2026-08-06-2026.md @@ -0,0 +1,31 @@ +# Work Summary — 2026-08-06 20:26 + +## Task +Migrate all test files from raw `t.Error`/`t.Errorf`/`t.Fatal`/`t.Fatalf` to `github.com/stretchr/testify`'s `assert`/`require` packages, throughout the whole test suite. + +## Scope found +9 test files total. Three (`server/ui/svg/svg_test.go`, `server/ui/charts/bar_test.go`, `server/ui/charts/line_test.go`) are `Example` functions with no `*testing.T` parameter at all - Go's stdlib compares their output against a `// Output:` comment, a fundamentally different mechanism testify can't attach to. Nothing to convert there; confirmed via grep that none of the three contain any `t.Error`/`t.Fatal`/`*testing.T` usage. + +The remaining 6 were converted: `domains/accounts/accounts_test.go`, `domains/authentication/dev_test.go`, `domains/raw_events/events_test.go`, `domains/amazon/mock_test.go`, `domains/reports/events_test.go`, `domains/reports/reports_test.go`. + +## Conventions used +- `require.*` where the original was `t.Fatal`/`t.Fatalf` (halts the test) - error checks, and any assertion a later line depends on (e.g. indexing into a slice whose length was just checked). +- `assert.*` where the original was `t.Error`/`t.Errorf` (non-halting) - independent value checks that don't gate subsequent code. +- `require.ErrorIs`/`require.NoError` in place of manual `errors.Is`/`if err != nil` checks. +- `require.Eventually` in place of hand-rolled polling loops (`for !condition() { if timeout { t.Fatal }; sleep }`) in `domains/amazon/mock_test.go` - a direct, more concise match for that exact pattern, and it already existed in testify rather than needing a custom helper. +- `assert.NotNil`/`require.NotNil` guarding a subsequent field access, matching testify's own idiom for avoiding a nil-pointer panic in a non-fatal assertion chain (`if assert.NotNil(t, x) { assert.Equal(t, ..., x.Field) }`). +- `github.com/stretchr/testify` added as a direct dependency (`go get` + `go mod tidy`; it was already an indirect transitive dependency of something else, so it only became "direct" once actual imports existed for `go mod tidy` to key off of). + +## A correctness issue caught and fixed during the conversion (not a testify bug - a bug in my own test code) +`require.Eventually` runs its condition function via `go checkCond()` - a separate goroutine (confirmed by reading testify's source at `assert/assertions.go:1988`). Go's testing package requires `t.FailNow()` (which `require.*` calls internally) to only ever be invoked from the test's own goroutine; calling it from a spawned goroutine doesn't panic cleanly, it just silently fails to report the real error and can leave things in a confusing state. Two of my initial `require.Eventually(...)` calls wrapped `isProcessed(t, ...)`, which internally used `require.NoError` - exactly this hazard, latent until the underlying query ever actually errored. Fixed by splitting a `t`-free `queryIsProcessed(ctx, pool, shopID, eventID) (bool, error)` out of `isProcessed`, and having the `Eventually` closures call that directly (treating a query error as "not yet satisfied" rather than halting), while `isProcessed` itself (used from the main test goroutine elsewhere) still wraps it with `require.NoError` for a clear, immediate failure there. + +## Verification +- `go build ./...` / `go vet ./...` clean. +- `gofmt -l` clean on every file touched (the only `gofmt`-flagged files repo-wide are pre-existing generated `*_with_context.go` files this task didn't touch). +- `grep -rn "t\.Error\|t\.Fatal" --include="*_test.go" .` - only two hits, both in explanatory comments, not actual calls. +- Every package with tests passes individually and in the safe (non-colliding) combination - see "Follow-up" below for why `domains/amazon` was verified separately from the rest rather than via one `go test ./...` run. + +## Follow-up surfaced (separate from this task, not fixed here) +While confirming everything with a full-suite run, `go test ./...` hung and eventually timed out inside `domains/amazon`, with a goroutine stuck in `pgxpool.Pool.Close()`'s `sync.WaitGroup.Wait`. Root-caused: `(*Mocks).processUnprocessedEvents`'s query has no `shop_id` filter - it processes every unprocessed row in `mock.shop_amazon_events` system-wide. `domains/reports`' tests also insert `platform='amazon'` raw events (unrelated fixture data, for its own listing-count tests), which the DB trigger copies into that same table. When `go test ./...` runs both packages' test binaries concurrently (the default), `domains/amazon`'s tests can end up processing - and asserting on - events that belong to a different test in a different package entirely. Confirmed directly: running `domains/amazon` + `domains/reports` together reproduced a spy receiving 4 events instead of the expected 1, three of them from `domains/reports`' fixture shop. This likely also explains the hang: more cross-package NOTIFY traffic raises the odds of hitting a narrow race in `listenForNotifications` where its notification-forwarding goroutine can block forever on an unbuffered channel send if `ProcessEvents` has just exited (context cancelled), leaking the connection and hanging `pool.Close()`. + +This pre-dates and is unrelated to the testify conversion - it only became reachable once `domains/reports`' tests started writing `platform='amazon'` events in a recent session. Per user direction, verified the conversion by running `domains/amazon` on its own and every other tested package together (both clean, repeated runs, no leftovers), and left the underlying `mock.go` bug as an explicit follow-up task rather than fixing it as a side effect of this one.