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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
This commit is contained in:
2026-08-20 00:36:55 -06:00
co-authored by Claude Sonnet 5
parent fe8c451739
commit 707aa65ddc
8 changed files with 230 additions and 359 deletions
+21 -51
View File
@@ -2,9 +2,11 @@ package accounts_test
import ( import (
"context" "context"
"errors"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"ruben/inventory2/consts" "ruben/inventory2/consts"
"ruben/inventory2/domains/accounts" "ruben/inventory2/domains/accounts"
"ruben/inventory2/internal/testdb" "ruben/inventory2/internal/testdb"
@@ -21,30 +23,18 @@ func TestCreateAccount(t *testing.T) {
email := userID + "@example.com" email := userID + "@example.com"
acct, err := store.CreateAccount(ctx, userID, email) acct, err := store.CreateAccount(ctx, userID, email)
if err != nil { require.NoError(t, err, "CreateAccount()")
t.Fatalf("CreateAccount() error = %v", err)
}
t.Cleanup(func() { t.Cleanup(func() {
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID) pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID)
}) })
if acct.AccountID == 0 { assert.NotZero(t, acct.AccountID, "CreateAccount() returned a zero AccountID")
t.Error("CreateAccount() returned a zero AccountID") assert.Equal(t, userID, acct.UserID, "CreateAccount() UserID")
} assert.Equal(t, email, acct.Email, "CreateAccount() Email")
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)
}
got, err := store.GetAccount(ctx, acct.AccountID) got, err := store.GetAccount(ctx, acct.AccountID)
if err != nil { require.NoError(t, err, "GetAccount()")
t.Fatalf("GetAccount() error = %v", err) assert.Equal(t, acct, got, "GetAccount()")
}
if got != acct {
t.Errorf("GetAccount() = %+v, want %+v", got, acct)
}
} }
func TestCreateAccount_DuplicateUserIsConflict(t *testing.T) { func TestCreateAccount_DuplicateUserIsConflict(t *testing.T) {
@@ -56,17 +46,13 @@ func TestCreateAccount_DuplicateUserIsConflict(t *testing.T) {
testdb.SeedOAuthUser(t, pool, userID) testdb.SeedOAuthUser(t, pool, userID)
acct, err := store.CreateAccount(ctx, userID, userID+"@example.com") acct, err := store.CreateAccount(ctx, userID, userID+"@example.com")
if err != nil { require.NoError(t, err, "first CreateAccount()")
t.Fatalf("first CreateAccount() error = %v", err)
}
t.Cleanup(func() { t.Cleanup(func() {
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID) pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID)
}) })
_, err = store.CreateAccount(ctx, userID, userID+"-other@example.com") _, err = store.CreateAccount(ctx, userID, userID+"-other@example.com")
if !errors.Is(err, consts.ErrConflict) { require.ErrorIs(t, err, consts.ErrConflict, "second CreateAccount()")
t.Fatalf("second CreateAccount() error = %v, want %v", err, consts.ErrConflict)
}
} }
func TestGetAccount_NotFound(t *testing.T) { func TestGetAccount_NotFound(t *testing.T) {
@@ -75,9 +61,7 @@ func TestGetAccount_NotFound(t *testing.T) {
ctx := context.Background() ctx := context.Background()
_, err := store.GetAccount(ctx, -1) _, err := store.GetAccount(ctx, -1)
if !errors.Is(err, consts.ErrNotFound) { require.ErrorIs(t, err, consts.ErrNotFound, "GetAccount()")
t.Fatalf("GetAccount() error = %v, want %v", err, consts.ErrNotFound)
}
} }
func TestGetUserAndAccountByAccessToken(t *testing.T) { func TestGetUserAndAccountByAccessToken(t *testing.T) {
@@ -90,34 +74,22 @@ func TestGetUserAndAccountByAccessToken(t *testing.T) {
// before an account exists: user resolves, account does not. // before an account exists: user resolves, account does not.
user, acct, err := store.GetUserAndAccountByAccessToken(ctx, accessToken) user, acct, err := store.GetUserAndAccountByAccessToken(ctx, accessToken)
if err != nil { require.NoError(t, err, "GetUserAndAccountByAccessToken() before account creation")
t.Fatalf("GetUserAndAccountByAccessToken() before account creation: error = %v", err) assert.Equal(t, userID, user.UserID, "GetUserAndAccountByAccessToken() UserID")
} assert.Nil(t, acct, "GetUserAndAccountByAccessToken() Account should be nil before an account is created")
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)
}
created, err := store.CreateAccount(ctx, userID, userID+"@example.com") created, err := store.CreateAccount(ctx, userID, userID+"@example.com")
if err != nil { require.NoError(t, err, "CreateAccount()")
t.Fatalf("CreateAccount() error = %v", err)
}
t.Cleanup(func() { t.Cleanup(func() {
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", created.AccountID) pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", created.AccountID)
}) })
// after an account exists: both resolve. // after an account exists: both resolve.
user, acct, err = store.GetUserAndAccountByAccessToken(ctx, accessToken) user, acct, err = store.GetUserAndAccountByAccessToken(ctx, accessToken)
if err != nil { require.NoError(t, err, "GetUserAndAccountByAccessToken() after account creation")
t.Fatalf("GetUserAndAccountByAccessToken() after account creation: error = %v", err) assert.Equal(t, userID, user.UserID, "GetUserAndAccountByAccessToken() UserID")
} if assert.NotNil(t, acct, "GetUserAndAccountByAccessToken() Account should be set after an account is created") {
if user.UserID != userID { assert.Equal(t, created.AccountID, acct.AccountID, "GetUserAndAccountByAccessToken() Account.AccountID")
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)
} }
} }
@@ -127,7 +99,5 @@ func TestGetUserAndAccountByAccessToken_UnknownToken(t *testing.T) {
ctx := context.Background() ctx := context.Background()
_, _, err := store.GetUserAndAccountByAccessToken(ctx, "no-such-token-"+testdb.NewUserID(t)) _, _, err := store.GetUserAndAccountByAccessToken(ctx, "no-such-token-"+testdb.NewUserID(t))
if !errors.Is(err, consts.ErrNotFound) { require.ErrorIs(t, err, consts.ErrNotFound, "GetUserAndAccountByAccessToken()")
t.Fatalf("GetUserAndAccountByAccessToken() error = %v, want %v", err, consts.ErrNotFound)
}
} }
+96 -149
View File
@@ -8,6 +8,8 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"ruben/inventory2/domains/raw_events" "ruben/inventory2/domains/raw_events"
"ruben/inventory2/internal/testdb" "ruben/inventory2/internal/testdb"
@@ -70,9 +72,15 @@ func (s *notifySpy) ackAt(t *testing.T, i int) {
s.mu.Lock() s.mu.Lock()
ack := s.acks[i] ack := s.acks[i]
s.mu.Unlock() s.mu.Unlock()
if err := ack(context.Background()); err != nil { require.NoError(t, ack(context.Background()), "ack()")
t.Fatalf("ack() error = %v", err)
} }
// 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 // 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. // more than once with increasing n.
func (s *notifySpy) waitForCount(t *testing.T, n int, timeout time.Duration) { func (s *notifySpy) waitForCount(t *testing.T, n int, timeout time.Duration) {
t.Helper() t.Helper()
deadline := time.Now().Add(timeout) require.Eventually(t, func() bool {
for { return s.completedCountSnapshot() >= n
s.mu.Lock() }, timeout, 5*time.Millisecond, "timed out waiting for %d total completed notifications (got %d)", n, s.completedCountSnapshot())
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)
}
} }
// insertRawAmazonEvent inserts directly into mock.raw_shop_events, the same // 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) INSERT INTO mock.raw_shop_events (platform, shop_id, event_timestamp, event_id, raw_payload)
VALUES ('amazon', $1, NOW(), $2, '{}'::jsonb) VALUES ('amazon', $1, NOW(), $2, '{}'::jsonb)
`, shopID, eventID) `, shopID, eventID)
if err != nil { require.NoError(t, err, "insert raw amazon event")
t.Fatalf("failed to insert raw amazon event: %v", err)
}
} }
// insertRawAmazonEvents bulk-inserts n events for shopID in one statement, // 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 SELECT 'amazon', $1, NOW() + (s || ' milliseconds')::interval, 'evt-' || s, '{}'::jsonb
FROM generate_series(1, $2) AS s FROM generate_series(1, $2) AS s
`, shopID, n) `, shopID, n)
if err != nil { require.NoError(t, err, "insert %d raw amazon events", n)
t.Fatalf("failed to insert %d raw amazon events: %v", n, err)
}
} }
func isProcessed(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) bool { // queryIsProcessed has no *testing.T dependency, so it's safe to call from
t.Helper() // 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 var processed bool
err := pool.QueryRow(context.Background(), ` err := pool.QueryRow(ctx, `
SELECT processed_at IS NOT NULL SELECT processed_at IS NOT NULL
FROM mock.shop_amazon_events FROM mock.shop_amazon_events
WHERE shop_id = $1 AND event_id = $2 WHERE shop_id = $1 AND event_id = $2
`, shopID, eventID).Scan(&processed) `, shopID, eventID).Scan(&processed)
if err != nil { return processed, err
t.Fatalf("failed to check processed state: %v", 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 return processed
} }
@@ -152,9 +152,7 @@ func isNotified(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) bool {
FROM mock.shop_amazon_events FROM mock.shop_amazon_events
WHERE shop_id = $1 AND event_id = $2 WHERE shop_id = $1 AND event_id = $2
`, shopID, eventID).Scan(&notified) `, shopID, eventID).Scan(&notified)
if err != nil { require.NoError(t, err, "check notified state")
t.Fatalf("failed to check notified state: %v", err)
}
return notified return notified
} }
@@ -164,9 +162,7 @@ func countUnprocessed(t *testing.T, pool *pgxpool.Pool, shopID string) int {
err := pool.QueryRow(context.Background(), ` err := pool.QueryRow(context.Background(), `
SELECT count(*) FROM mock.shop_amazon_events WHERE shop_id = $1 AND processed_at IS NULL SELECT count(*) FROM mock.shop_amazon_events WHERE shop_id = $1 AND processed_at IS NULL
`, shopID).Scan(&n) `, shopID).Scan(&n)
if err != nil { require.NoError(t, err, "count unprocessed events")
t.Fatalf("failed to count unprocessed events: %v", err)
}
return n return n
} }
@@ -197,12 +193,33 @@ func terminateListenConnection(t *testing.T, pool *pgxpool.Pool) {
ORDER BY backend_start DESC ORDER BY backend_start DESC
LIMIT 1 LIMIT 1
`, eventChannelName).Scan(&pid) `, eventChannelName).Scan(&pid)
if err != nil { require.NoError(t, err, "find the LISTEN connection's backend pid")
t.Fatalf("failed to find the LISTEN connection's backend pid: %v", err)
_, err = pool.Exec(ctx, `SELECT pg_terminate_backend($1)`, pid)
require.NoError(t, err, "terminate backend %d", pid)
} }
if _, err := pool.Exec(ctx, `SELECT pg_terminate_backend($1)`, pid); err != nil { // waitReady blocks until m signals it's actively listening, failing the
t.Fatalf("failed to terminate backend %d: %v", pid, err) // 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 const n = 150 // exceeds the 100-row LIMIT per batch inside processUnprocessedEvents
insertRawAmazonEvents(t, pool, shopID, n) insertRawAmazonEvents(t, pool, shopID, n)
if err := m.processUnprocessedEvents(ctx); err != nil { require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents()")
t.Fatalf("processUnprocessedEvents() error = %v", err)
}
spy.waitForCount(t, n, 5*time.Second) spy.waitForCount(t, n, 5*time.Second)
if got := countUnprocessed(t, pool, shopID); got != 0 { assert.Zero(t, countUnprocessed(t, pool, shopID),
t.Errorf("countUnprocessed() = %d, want 0 (all %d events should be processed across multiple 100-row batches)", got, n) "countUnprocessed() should be 0 (all %d events should be processed across multiple 100-row batches)", n)
}
} }
func TestProcessUnprocessedEvents_NoListenerConfigured(t *testing.T) { func TestProcessUnprocessedEvents_NoListenerConfigured(t *testing.T) {
@@ -239,18 +253,14 @@ func TestProcessUnprocessedEvents_NoListenerConfigured(t *testing.T) {
insertRawAmazonEvent(t, pool, shopID, "evt-1") insertRawAmazonEvent(t, pool, shopID, "evt-1")
if err := m.processUnprocessedEvents(ctx); err != nil { require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents()")
t.Fatalf("processUnprocessedEvents() error = %v", err)
}
// with no listener, nothing ever acks - the event is expected to stay // with no listener, nothing ever acks - the event is expected to stay
// "notified" forever, not silently marked processed. // "notified" forever, not silently marked processed.
if isProcessed(t, pool, shopID, "evt-1") { assert.False(t, isProcessed(t, pool, shopID, "evt-1"),
t.Error("event was marked processed despite no listener being configured to ack it") "event was marked processed despite no listener being configured to ack it")
} assert.True(t, isNotified(t, pool, shopID, "evt-1"),
if !isNotified(t, pool, shopID, "evt-1") { "event should be in the notified state after being handed off with no listener to ack it")
t.Error("event should be in the notified state after being handed off with no listener to ack it")
}
} }
// TestProcessUnprocessedEvents_RetriesUnackedNotification is the core of // TestProcessUnprocessedEvents_RetriesUnackedNotification is the core of
@@ -271,47 +281,29 @@ func TestProcessUnprocessedEvents_RetriesUnackedNotification(t *testing.T) {
insertRawAmazonEvent(t, pool, shopID, "evt-1") insertRawAmazonEvent(t, pool, shopID, "evt-1")
if err := m.processUnprocessedEvents(ctx); err != nil { require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (1st pass)")
t.Fatalf("processUnprocessedEvents() (1st pass) error = %v", err)
}
spy.waitForCount(t, 1, 2*time.Second) spy.waitForCount(t, 1, 2*time.Second)
if isProcessed(t, pool, shopID, "evt-1") { require.False(t, isProcessed(t, pool, shopID, "evt-1"), "event was marked processed despite the listener never acking")
t.Fatal("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")
}
if !isNotified(t, pool, shopID, "evt-1") {
t.Fatal("event should be in the notified state after the first dispatch")
}
// still within notifyRetryAfter: shouldn't be re-notified yet. // still within notifyRetryAfter: shouldn't be re-notified yet.
if err := m.processUnprocessedEvents(ctx); err != nil { require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (immediate 2nd pass)")
t.Fatalf("processUnprocessedEvents() (immediate 2nd pass) error = %v", err) require.Len(t, spy.eventsSnapshot(), 1, "listener should not have been notified again before notifyRetryAfter elapsed")
}
if got := len(spy.eventsSnapshot()); got != 1 {
t.Fatalf("listener was notified %d times before notifyRetryAfter elapsed, want 1", got)
}
time.Sleep(60 * time.Millisecond) // past notifyRetryAfter time.Sleep(60 * time.Millisecond) // past notifyRetryAfter
if err := m.processUnprocessedEvents(ctx); err != nil { require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (3rd pass, after retry window)")
t.Fatalf("processUnprocessedEvents() (3rd pass, after retry window) error = %v", err)
}
spy.waitForCount(t, 2, 2*time.Second) spy.waitForCount(t, 2, 2*time.Second)
// the listener "finishes" the first notification late, via the ack it // the listener "finishes" the first notification late, via the ack it
// was originally handed - not a fresh one from the retry. // was originally handed - not a fresh one from the retry.
spy.ackAt(t, 0) spy.ackAt(t, 0)
if !isProcessed(t, pool, shopID, "evt-1") { require.True(t, isProcessed(t, pool, shopID, "evt-1"), "event should be processed once any recorded ack for it is called")
t.Fatal("event should be processed once any recorded ack for it is called")
}
if err := m.processUnprocessedEvents(ctx); err != nil { require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (4th pass, after ack)")
t.Fatalf("processUnprocessedEvents() (4th pass, after ack) error = %v", err) assert.Len(t, spy.eventsSnapshot(), 2, "listener should not be notified again after being acked")
}
if got := len(spy.eventsSnapshot()); got != 2 {
t.Fatalf("listener was notified again after being acked: got %d calls, want 2", got)
}
} }
// TestProcessEvents_PollFallbackPicksUpRetryDueEvents proves the poll // TestProcessEvents_PollFallbackPicksUpRetryDueEvents proves the poll
@@ -341,11 +333,7 @@ func TestProcessEvents_PollFallbackPicksUpRetryDueEvents(t *testing.T) {
errCh <- m.ProcessEvents(ctx) errCh <- m.ProcessEvents(ctx)
}() }()
select { waitReady(t, m, 5*time.Second)
case <-m.Ready():
case <-time.After(5 * time.Second):
t.Fatal("ProcessEvents() did not become ready (LISTEN registered) within 5s")
}
insertRawAmazonEvent(t, pool, shopID, "evt-1") insertRawAmazonEvent(t, pool, shopID, "evt-1")
@@ -358,14 +346,7 @@ func TestProcessEvents_PollFallbackPicksUpRetryDueEvents(t *testing.T) {
spy.waitForCount(t, 2, 3*time.Second) spy.waitForCount(t, 2, 3*time.Second)
cancel() cancel()
select { waitStopped(t, errCh, 5*time.Second)
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")
}
} }
// TestProcessEvents_ReactsToNotification drives the actual long-running // TestProcessEvents_ReactsToNotification drives the actual long-running
@@ -389,36 +370,24 @@ func TestProcessEvents_ReactsToNotification(t *testing.T) {
errCh <- m.ProcessEvents(ctx) errCh <- m.ProcessEvents(ctx)
}() }()
select { waitReady(t, m, 5*time.Second)
case <-m.Ready():
case <-time.After(5 * time.Second):
t.Fatal("ProcessEvents() did not become ready (LISTEN registered) within 5s")
}
insertRawAmazonEvent(t, pool, shopID, "evt-1") insertRawAmazonEvent(t, pool, shopID, "evt-1")
deadline := time.Now().Add(5 * time.Second) require.Eventually(t, func() bool {
for !isProcessed(t, pool, shopID, "evt-1") { processed, _ := queryIsProcessed(context.Background(), pool, shopID, "evt-1")
if time.Now().After(deadline) { return processed
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)") }, 5*time.Second, 20*time.Millisecond,
} "event was not processed within 5s of insertion - the reactive LISTEN/NOTIFY wake-up did not fire "+
time.Sleep(20 * time.Millisecond) "(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) spy.waitForCount(t, 1, 2*time.Second)
if got := spy.eventsSnapshot()[0]; got.StoreID != shopID || got.EventID != "evt-1" { got := spy.eventsSnapshot()[0]
t.Errorf("listener notified with %+v, want StoreID=%q EventID=%q", got, shopID, "evt-1") assert.Equal(t, shopID, got.StoreID, "listener notified with unexpected StoreID")
} assert.Equal(t, "evt-1", got.EventID, "listener notified with unexpected EventID")
cancel() cancel()
select { waitStopped(t, errCh, 5*time.Second)
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")
}
} }
// TestProcessEvents_ShutsDownOnContextCancel checks the lifecycle in // TestProcessEvents_ShutsDownOnContextCancel checks the lifecycle in
@@ -436,21 +405,10 @@ func TestProcessEvents_ShutsDownOnContextCancel(t *testing.T) {
errCh <- m.ProcessEvents(ctx) errCh <- m.ProcessEvents(ctx)
}() }()
select { waitReady(t, m, 5*time.Second)
case <-m.Ready():
case <-time.After(5 * time.Second):
t.Fatal("ProcessEvents() did not become ready (LISTEN registered) within 5s")
}
cancel() cancel()
select { waitStopped(t, errCh, 5*time.Second)
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")
}
} }
// TestProcessEvents_ReconnectsAfterListenConnectionDrops is insight #4's // TestProcessEvents_ReconnectsAfterListenConnectionDrops is insight #4's
@@ -474,11 +432,7 @@ func TestProcessEvents_ReconnectsAfterListenConnectionDrops(t *testing.T) {
errCh <- m.ProcessEvents(ctx) errCh <- m.ProcessEvents(ctx)
}() }()
select { waitReady(t, m, 5*time.Second)
case <-m.Ready():
case <-time.After(5 * time.Second):
t.Fatal("ProcessEvents() did not become ready (LISTEN registered) within 5s")
}
terminateListenConnection(t, pool) terminateListenConnection(t, pool)
@@ -486,27 +440,20 @@ func TestProcessEvents_ReconnectsAfterListenConnectionDrops(t *testing.T) {
// backoff is 1s) - it must not have returned because of this. // backoff is 1s) - it must not have returned because of this.
select { select {
case err := <-errCh: 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): case <-time.After(2 * time.Second):
} }
insertRawAmazonEvent(t, pool, shopID, "evt-1") insertRawAmazonEvent(t, pool, shopID, "evt-1")
deadline := time.Now().Add(5 * time.Second) require.Eventually(t, func() bool {
for !isProcessed(t, pool, shopID, "evt-1") { processed, _ := queryIsProcessed(context.Background(), pool, shopID, "evt-1")
if time.Now().After(deadline) { return processed
t.Fatal("event was not processed within 5s of insertion after the LISTEN connection was forcibly dropped - reconnection did not restore the reactive path") }, 5*time.Second, 20*time.Millisecond,
} "event was not processed within 5s of insertion after the LISTEN connection was forcibly dropped - "+
time.Sleep(20 * time.Millisecond) "reconnection did not restore the reactive path")
}
cancel() cancel()
select { waitStopped(t, errCh, 5*time.Second)
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")
}
} }
+16 -37
View File
@@ -2,10 +2,12 @@ package authentication
import ( import (
"context" "context"
"errors"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"ruben/inventory2/consts" "ruben/inventory2/consts"
"ruben/inventory2/internal/testdb" "ruben/inventory2/internal/testdb"
) )
@@ -22,36 +24,21 @@ func TestDevLogin(t *testing.T) {
}) })
accessToken, expiration, err := auth.DevLogin(ctx, userID, "Test User") accessToken, expiration, err := auth.DevLogin(ctx, userID, "Test User")
if err != nil { require.NoError(t, err, "DevLogin()")
t.Fatalf("DevLogin() error = %v", err) require.NotEmpty(t, accessToken, "DevLogin() returned an empty access token")
} assert.True(t, expiration.After(time.Now().Add(30*24*time.Hour)),
if accessToken == "" { "DevLogin() expiration = %v, want something far enough out to avoid the near-expiry refresh path", expiration)
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)
}
claims, err := auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken) claims, err := auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
if err != nil { require.NoError(t, err, "GetAccessTokenClaimsAndExpiration()")
t.Fatalf("GetAccessTokenClaimsAndExpiration() error = %v", err) assert.Equal(t, "Test User", claims.Name, "claims.Name")
}
if claims.Name != "Test User" {
t.Errorf("claims.Name = %q, want %q", claims.Name, "Test User")
}
// Postgres timestamptz has microsecond precision, so the round-tripped // Postgres timestamptz has microsecond precision, so the round-tripped
// value loses the sub-microsecond portion of Go's nanosecond clock. // value loses the sub-microsecond portion of Go's nanosecond clock.
if diff := claims.Expiration.Sub(expiration); diff > time.Millisecond || diff < -time.Millisecond { assert.WithinDuration(t, expiration, claims.Expiration, time.Millisecond, "claims.Expiration")
t.Errorf("claims.Expiration = %v, want ~%v (diff %v)", claims.Expiration, expiration, diff)
}
_, tokenType, err := auth.getRefreshTokenForAccessToken(ctx, accessToken) _, tokenType, err := auth.getRefreshTokenForAccessToken(ctx, accessToken)
if err != nil { require.NoError(t, err, "getRefreshTokenForAccessToken()")
t.Fatalf("getRefreshTokenForAccessToken() error = %v", err) assert.Equal(t, "dev", tokenType, "tokenType")
}
if tokenType != "dev" {
t.Errorf("tokenType = %q, want %q", tokenType, "dev")
}
} }
// DevLogin should be safe to call more than once for the same user_id - // 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") token1, _, err := auth.DevLogin(ctx, userID, "Test User")
if err != nil { require.NoError(t, err, "first DevLogin()")
t.Fatalf("first DevLogin() error = %v", err)
}
token2, _, err := auth.DevLogin(ctx, userID, "Test User") token2, _, err := auth.DevLogin(ctx, userID, "Test User")
if err != nil { require.NoError(t, err, "second DevLogin()")
t.Fatalf("second DevLogin() error = %v", err)
}
if token1 == token2 { assert.NotEqual(t, token1, token2, "DevLogin() returned the same access token twice")
t.Fatalf("DevLogin() returned the same access token twice: %q", token1)
}
} }
func TestGetAccessTokenClaimsAndExpiration_UnknownToken(t *testing.T) { func TestGetAccessTokenClaimsAndExpiration_UnknownToken(t *testing.T) {
@@ -89,7 +70,5 @@ func TestGetAccessTokenClaimsAndExpiration_UnknownToken(t *testing.T) {
ctx := context.Background() ctx := context.Background()
_, err := auth.GetAccessTokenClaimsAndExpiration(ctx, "no-such-token-"+testdb.NewUserID(t)) _, err := auth.GetAccessTokenClaimsAndExpiration(ctx, "no-such-token-"+testdb.NewUserID(t))
if !errors.Is(err, consts.ErrNotFound) { require.ErrorIs(t, err, consts.ErrNotFound, "GetAccessTokenClaimsAndExpiration()")
t.Fatalf("GetAccessTokenClaimsAndExpiration() error = %v, want %v", err, consts.ErrNotFound)
}
} }
+13 -28
View File
@@ -7,6 +7,8 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"ruben/inventory2/domains/raw_events" "ruben/inventory2/domains/raw_events"
"ruben/inventory2/internal/testdb" "ruben/inventory2/internal/testdb"
@@ -39,33 +41,20 @@ func TestSaveAndLoadEventsForStore(t *testing.T) {
Payload: json.RawMessage(`{"n":2}`), Payload: json.RawMessage(`{"n":2}`),
} }
if err := store.Save(ctx, &older); err != nil { require.NoError(t, store.Save(ctx, &older), "Save() older event")
t.Fatalf("Save() older event error = %v", err) require.NoError(t, store.Save(ctx, &newer), "Save() newer event")
}
if err := store.Save(ctx, &newer); err != nil {
t.Fatalf("Save() newer event error = %v", err)
}
got, err := store.LoadEventsForStore(ctx, platform, storeID) got, err := store.LoadEventsForStore(ctx, platform, storeID)
if err != nil { require.NoError(t, err, "LoadEventsForStore()")
t.Fatalf("LoadEventsForStore() error = %v", err) require.Len(t, got, 2, "LoadEventsForStore()")
}
if len(got) != 2 {
t.Fatalf("LoadEventsForStore() returned %d events, want 2: %+v", len(got), got)
}
// ordered event_timestamp DESC - newest first. // ordered event_timestamp DESC - newest first.
if got[0].EventID != "evt-2" || got[1].EventID != "evt-1" { assert.Equal(t, "evt-2", got[0].EventID, "LoadEventsForStore()[0]")
t.Errorf("LoadEventsForStore() order = [%s, %s], want [evt-2, evt-1]", got[0].EventID, got[1].EventID) assert.Equal(t, "evt-1", got[1].EventID, "LoadEventsForStore()[1]")
}
var payload struct{ N int } var payload struct{ N int }
if err := json.Unmarshal(got[0].Payload, &payload); err != nil { require.NoError(t, json.Unmarshal(got[0].Payload, &payload), "unmarshal LoadEventsForStore()[0].Payload")
t.Fatalf("failed to unmarshal LoadEventsForStore()[0].Payload = %s: %v", got[0].Payload, err) assert.Equal(t, 2, payload.N, "LoadEventsForStore()[0].Payload n")
}
if payload.N != 2 {
t.Errorf("LoadEventsForStore()[0].Payload n = %d, want 2", payload.N)
}
} }
func TestLoadEventsForStore_NoEvents(t *testing.T) { func TestLoadEventsForStore_NoEvents(t *testing.T) {
@@ -74,10 +63,6 @@ func TestLoadEventsForStore_NoEvents(t *testing.T) {
ctx := context.Background() ctx := context.Background()
got, err := store.LoadEventsForStore(ctx, "test-platform", "no-such-store-"+uuid.NewString()) got, err := store.LoadEventsForStore(ctx, "test-platform", "no-such-store-"+uuid.NewString())
if err != nil { require.NoError(t, err, "LoadEventsForStore()")
t.Fatalf("LoadEventsForStore() error = %v", err) assert.Empty(t, got, "LoadEventsForStore()")
}
if len(got) != 0 {
t.Fatalf("LoadEventsForStore() = %+v, want empty", got)
}
} }
+17 -41
View File
@@ -3,12 +3,13 @@ package reports_test
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"testing" "testing"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"ruben/inventory2/consts" "ruben/inventory2/consts"
"ruben/inventory2/domains/accounts" "ruben/inventory2/domains/accounts"
@@ -28,17 +29,13 @@ func setupAmazonMockShop(t *testing.T, pool *pgxpool.Pool, acctStore *accounts.S
testdb.SeedOAuthUser(t, pool, userID) testdb.SeedOAuthUser(t, pool, userID)
acct, err := acctStore.CreateAccount(ctx, userID, userID+"@example.com") acct, err := acctStore.CreateAccount(ctx, userID, userID+"@example.com")
if err != nil { require.NoError(t, err, "CreateAccount()")
t.Fatalf("CreateAccount() error = %v", err)
}
t.Cleanup(func() { t.Cleanup(func() {
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID) pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID)
}) })
id, err := acctStore.CreateMockShop(ctx, acct.AccountID, accounts.Amazon, "Test Shop") id, err := acctStore.CreateMockShop(ctx, acct.AccountID, accounts.Amazon, "Test Shop")
if err != nil { require.NoError(t, err, "CreateMockShop()")
t.Fatalf("CreateMockShop() error = %v", err)
}
shopID = id.String() shopID = id.String()
t.Cleanup(func() { 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) INSERT INTO mock.raw_shop_events (platform, shop_id, event_timestamp, event_id, raw_payload)
VALUES ('amazon', $1, $2, $3, $4::jsonb) VALUES ('amazon', $1, $2, $3, $4::jsonb)
`, shopID, ts, eventID, payload) `, shopID, ts, eventID, payload)
if err != nil { require.NoError(t, err, "insert raw shop event")
t.Fatalf("failed to insert raw shop event: %v", err)
}
} }
func TestGetRawShopEvents(t *testing.T) { func TestGetRawShopEvents(t *testing.T) {
@@ -80,30 +75,19 @@ func TestGetRawShopEvents(t *testing.T) {
insertRawShopEvent(t, pool, shopID, "evt-2", newer, `{"n":2}`) insertRawShopEvent(t, pool, shopID, "evt-2", newer, `{"n":2}`)
got, err := reportsStore.GetRawShopEvents(ctx, acctID, accounts.Amazon, shopID) got, err := reportsStore.GetRawShopEvents(ctx, acctID, accounts.Amazon, shopID)
if err != nil { require.NoError(t, err, "GetRawShopEvents()")
t.Fatalf("GetRawShopEvents() error = %v", err) require.Len(t, got, 2, "GetRawShopEvents()")
}
if len(got) != 2 {
t.Fatalf("GetRawShopEvents() returned %d events, want 2: %+v", len(got), got)
}
// ordered event_timestamp DESC, event_id ASC - newest first. // ordered event_timestamp DESC, event_id ASC - newest first.
if got[0].EventID != "evt-2" || got[1].EventID != "evt-1" { assert.Equal(t, "evt-2", got[0].EventID, "GetRawShopEvents()[0]")
t.Errorf("GetRawShopEvents() order = [%s, %s], want [evt-2, evt-1]", got[0].EventID, got[1].EventID) assert.Equal(t, "evt-1", got[1].EventID, "GetRawShopEvents()[1]")
}
if got[0].Platform != accounts.Amazon || got[0].ShopID != shopID { assert.Equal(t, accounts.Amazon, got[0].Platform, "got[0].Platform")
t.Errorf("got[0] Platform/ShopID = %s/%s, want %s/%s", got[0].Platform, got[0].ShopID, accounts.Amazon, shopID) assert.Equal(t, shopID, got[0].ShopID, "got[0].ShopID")
}
var payload struct{ N int } var payload struct{ N int }
if err := json.Unmarshal(got[0].RawPayload, &payload); err != nil { require.NoError(t, json.Unmarshal(got[0].RawPayload, &payload), "unmarshal got[0].RawPayload")
t.Fatalf("failed to unmarshal got[0].RawPayload = %s: %v", got[0].RawPayload, err) assert.Equal(t, 2, payload.N, "got[0].RawPayload n")
}
if payload.N != 2 {
t.Errorf("got[0].RawPayload n = %d, want 2", payload.N)
}
} }
func TestGetRawShopEvents_NoEvents(t *testing.T) { func TestGetRawShopEvents_NoEvents(t *testing.T) {
@@ -115,12 +99,8 @@ func TestGetRawShopEvents_NoEvents(t *testing.T) {
acctID, shopID := setupAmazonMockShop(t, pool, acctStore) acctID, shopID := setupAmazonMockShop(t, pool, acctStore)
got, err := reportsStore.GetRawShopEvents(ctx, acctID, accounts.Amazon, shopID) got, err := reportsStore.GetRawShopEvents(ctx, acctID, accounts.Amazon, shopID)
if err != nil { require.NoError(t, err, "GetRawShopEvents()")
t.Fatalf("GetRawShopEvents() error = %v", err) assert.Empty(t, got, "GetRawShopEvents()")
}
if len(got) != 0 {
t.Fatalf("GetRawShopEvents() = %+v, want empty", got)
}
} }
func TestGetRawShopEvents_UnknownShop(t *testing.T) { func TestGetRawShopEvents_UnknownShop(t *testing.T) {
@@ -133,15 +113,11 @@ func TestGetRawShopEvents_UnknownShop(t *testing.T) {
testdb.SeedOAuthUser(t, pool, userID) testdb.SeedOAuthUser(t, pool, userID)
acct, err := acctStore.CreateAccount(ctx, userID, userID+"@example.com") acct, err := acctStore.CreateAccount(ctx, userID, userID+"@example.com")
if err != nil { require.NoError(t, err, "CreateAccount()")
t.Fatalf("CreateAccount() error = %v", err)
}
t.Cleanup(func() { t.Cleanup(func() {
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID) 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()) _, err = reportsStore.GetRawShopEvents(ctx, acct.AccountID, accounts.Amazon, "no-such-shop-"+uuid.NewString())
if !errors.Is(err, consts.ErrNotFound) { require.ErrorIs(t, err, consts.ErrNotFound, "GetRawShopEvents()")
t.Fatalf("GetRawShopEvents() error = %v, want %v", err, consts.ErrNotFound)
}
} }
+30 -50
View File
@@ -2,9 +2,11 @@ package reports_test
import ( import (
"context" "context"
"errors"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"ruben/inventory2/consts" "ruben/inventory2/consts"
"ruben/inventory2/domains/accounts" "ruben/inventory2/domains/accounts"
"ruben/inventory2/domains/reports" "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", Description: "a listing created for a test",
Count: baseCount, Count: baseCount,
}) })
if err != nil { require.NoError(t, err, "CreateMockListing()")
t.Fatalf("CreateMockListing() error = %v", err)
}
t.Cleanup(func() { t.Cleanup(func() {
pool.Exec(context.Background(), ` pool.Exec(context.Background(), `
@@ -64,38 +64,28 @@ func TestGetListingCountsOverTime(t *testing.T) {
acctID, shopID := setupAmazonMockShop(t, pool, acctStore) acctID, shopID := setupAmazonMockShop(t, pool, acctStore)
listingID := createAmazonListing(t, acctStore, acctID, shopID, 100) listingID := createAmazonListing(t, acctStore, acctID, shopID, 100)
if _, err := acctStore.SaveNewMockSale(ctx, acctID, accounts.Amazon, shopID, listingID, 10); err != nil { _, err := acctStore.SaveNewMockSale(ctx, acctID, accounts.Amazon, shopID, listingID, 10)
t.Fatalf("SaveNewMockSale() error = %v", err) require.NoError(t, err, "SaveNewMockSale()")
} _, err = acctStore.SaveNewMockRefund(ctx, acctID, accounts.Amazon, shopID, listingID, 5)
if _, err := acctStore.SaveNewMockRefund(ctx, acctID, accounts.Amazon, shopID, listingID, 5); err != nil { require.NoError(t, err, "SaveNewMockRefund()")
t.Fatalf("SaveNewMockRefund() error = %v", err) _, err = acctStore.SaveNewMockInventoryReset(ctx, acctID, accounts.Amazon, shopID, listingID, 50)
} require.NoError(t, err, "SaveNewMockInventoryReset()")
if _, err := acctStore.SaveNewMockInventoryReset(ctx, acctID, accounts.Amazon, shopID, listingID, 50); err != nil {
t.Fatalf("SaveNewMockInventoryReset() error = %v", err)
}
got, err := reportsStore.GetListingCountsOverTime(ctx, acctID, accounts.Amazon, shopID, listingID) got, err := reportsStore.GetListingCountsOverTime(ctx, acctID, accounts.Amazon, shopID, listingID)
if err != nil { require.NoError(t, err, "GetListingCountsOverTime()")
t.Fatalf("GetListingCountsOverTime() error = %v", err)
}
wantCounts := []int64{100, 110, 105, 50} wantCounts := []int64{100, 110, 105, 50}
if len(got) != len(wantCounts) { require.Len(t, got, len(wantCounts), "GetListingCountsOverTime()")
t.Fatalf("GetListingCountsOverTime() returned %d rows, want %d: %+v", len(got), len(wantCounts), got)
}
if got[0].EventTimestamp != nil { gotCounts := make([]int64, len(got))
t.Errorf("got[0].EventTimestamp = %v, want nil (the base count row)", got[0].EventTimestamp)
}
for i, row := range got { for i, row := range got {
if row.Count != wantCounts[i] { gotCounts[i] = row.Count
t.Errorf("got[%d].Count = %d, want %d (full sequence: %+v)", i, row.Count, wantCounts[i], got)
}
} }
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++ { for i := 1; i < len(got); i++ {
if got[i].EventTimestamp == nil { assert.NotNil(t, got[i].EventTimestamp, "got[%d].EventTimestamp should be set (only the base row should be nil)", i)
t.Errorf("got[%d].EventTimestamp = nil, want 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) acctID, shopID := setupAmazonMockShop(t, pool, acctStore)
_, err := reportsStore.GetListingCountsOverTime(ctx, acctID, accounts.Amazon, shopID, "no-such-listing") _, err := reportsStore.GetListingCountsOverTime(ctx, acctID, accounts.Amazon, shopID, "no-such-listing")
if !errors.Is(err, consts.ErrNotFound) { require.ErrorIs(t, err, consts.ErrNotFound, "GetListingCountsOverTime()")
t.Fatalf("GetListingCountsOverTime() error = %v, want %v", err, consts.ErrNotFound)
}
} }
func TestGetListingCountsReport(t *testing.T) { func TestGetListingCountsReport(t *testing.T) {
@@ -122,28 +110,20 @@ func TestGetListingCountsReport(t *testing.T) {
acctID, shopID := setupAmazonMockShop(t, pool, acctStore) acctID, shopID := setupAmazonMockShop(t, pool, acctStore)
listingID := createAmazonListing(t, acctStore, acctID, shopID, 100) listingID := createAmazonListing(t, acctStore, acctID, shopID, 100)
if _, err := acctStore.SaveNewMockSale(ctx, acctID, accounts.Amazon, shopID, listingID, 10); err != nil { _, err := acctStore.SaveNewMockSale(ctx, acctID, accounts.Amazon, shopID, listingID, 10)
t.Fatalf("SaveNewMockSale() error = %v", err) require.NoError(t, err, "SaveNewMockSale()")
} _, err = acctStore.SaveNewMockInventoryReset(ctx, acctID, accounts.Amazon, shopID, listingID, 20)
if _, err := acctStore.SaveNewMockInventoryReset(ctx, acctID, accounts.Amazon, shopID, listingID, 20); err != nil { require.NoError(t, err, "SaveNewMockInventoryReset()")
t.Fatalf("SaveNewMockInventoryReset() error = %v", err)
}
report, err := reportsStore.GetListingCountsReport(ctx, acctID, accounts.Amazon, shopID, listingID) report, err := reportsStore.GetListingCountsReport(ctx, acctID, accounts.Amazon, shopID, listingID)
if err != nil { require.NoError(t, err, "GetListingCountsReport()")
t.Fatalf("GetListingCountsReport() error = %v", err)
}
if report.AccountID != acctID || report.Platform != accounts.Amazon || report.ShopID != shopID || report.ListingID != listingID { assert.Equal(t, acctID, report.AccountID, "report.AccountID")
t.Errorf("report identity = %+v, want AccountID=%d Platform=%s ShopID=%s ListingID=%s", assert.Equal(t, accounts.Amazon, report.Platform, "report.Platform")
report, acctID, accounts.Amazon, shopID, listingID) 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) // counts over the sequence: 100 (base) -> 110 (sale +10) -> 20 (reset)
if got := report.MaxCount().Count; got != 110 { assert.Equal(t, int64(110), report.MaxCount().Count, "MaxCount().Count")
t.Errorf("MaxCount().Count = %d, want 110", got) assert.Equal(t, int64(20), report.MinCount().Count, "MinCount().Count")
}
if got := report.MinCount().Count; got != 20 {
t.Errorf("MinCount().Count = %d, want 20", got)
}
} }
+3
View File
@@ -14,6 +14,7 @@ require (
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/lmittmann/tint v1.1.2 github.com/lmittmann/tint v1.1.2
github.com/oapi-codegen/runtime 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 golang.org/x/oauth2 v0.34.0
) )
@@ -28,6 +29,7 @@ require (
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // 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/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/getkin/kin-openapi v0.133.0 // 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/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pkg/errors v0.9.1 // 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/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
github.com/speakeasy-api/jsonpath v0.6.0 // indirect github.com/speakeasy-api/jsonpath v0.6.0 // indirect
@@ -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.