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:
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+99
-152
@@ -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(¬ified)
|
`, shopID, eventID).Scan(¬ified)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := pool.Exec(ctx, `SELECT pg_terminate_backend($1)`, pid); err != nil {
|
_, err = pool.Exec(ctx, `SELECT pg_terminate_backend($1)`, pid)
|
||||||
t.Fatalf("failed to terminate backend %d: %v", pid, err)
|
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
|
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user