Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e78d5c4246 | ||
|
|
465fb107b2 | ||
|
|
7932e5d90c | ||
|
|
e1268316bd | ||
|
|
7c65f928ed | ||
|
|
a9b51bcad9 | ||
|
|
56feb931bf | ||
|
|
e286f04df9 | ||
|
|
e4e805d139 | ||
|
|
345e78753b |
@@ -0,0 +1,183 @@
|
||||
# AGENTS.md
|
||||
|
||||
Go backend for a multi-platform ecommerce inventory-sync tool. Etsy is the
|
||||
one real, live platform integration; everything else (Amazon, BigCartel,
|
||||
Ebay, Ecwid, Shopify, SquareOnline, Squarespace, Tiktok, WalmartMarketplace,
|
||||
Wix, WooCommerce, Zoho) exists only as a **mock simulation layer** used for
|
||||
development, demos, and testing the sync/reporting logic without needing
|
||||
real store credentials. See `README.md` for the product-level roadmap.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Postgres running locally, with an `app_client` role/database matching
|
||||
`.env.example`'s `DATABASE_URL`.
|
||||
2. `cp .env.example .env` and fill in real values (Auth0, Etsy). For local
|
||||
dev-only work you don't need real Auth0/Etsy secrets if `DEV_AUTH_ENABLED`
|
||||
is `true` (see Auth below) - `changeme` placeholders are fine.
|
||||
3. Create the dedicated test database once: `createdb -O app_client
|
||||
inventory_2_test`. `make test` migrates it automatically after that.
|
||||
|
||||
## Commands
|
||||
|
||||
- `make dev` (default: bare `make`) - applies pending migrations, starts the
|
||||
Tailwind watcher in the background, runs the server in the foreground.
|
||||
Ctrl-C stops both.
|
||||
- `make run` - just `go run .`, no migrations or CSS.
|
||||
- `make test` - runs the full test suite against the dedicated
|
||||
`inventory_2_test` database (migrates it first). Safe to run anytime;
|
||||
never touches real data.
|
||||
- `make test-against-dev-db` - same suite, pointed at the real dev database
|
||||
instead. Useful for checking behavior against real data; tests clean up
|
||||
after themselves via `t.Cleanup`, but it's still touching your dev DB.
|
||||
- `make migrate-up` / `migrate-down` / `migrate-version` - wrap the
|
||||
`migrate` CLI using `DATABASE_URL` from `.env`, so the DSN is never
|
||||
hand-typed. `migrate-test-*` variants target `TEST_DATABASE_URL`.
|
||||
- `go build ./...`, `go vet ./...`, `gofmt -l .` - standard, expected clean
|
||||
before considering work done.
|
||||
- `go test ./... -race` - the test suite is safe to run this way (see
|
||||
Testing below for what made that true).
|
||||
|
||||
## Architecture
|
||||
|
||||
- `domains/` - business logic, one package per bounded concern
|
||||
(`accounts`, `authentication`, `raw_events`, `reports`, `amazon`,
|
||||
`platforms/etsy`). Each domain owns its own DB access; there's no shared
|
||||
ORM/repository layer. Looking to loosely follow CQRS: writes go through
|
||||
domain `Store` methods, reads are mostly separate query methods on the
|
||||
same `Store`.
|
||||
- `domains/accounts/mocks.go` - the mock-platform simulation layer:
|
||||
`CreateMockShop`, `CreateMockListing`, `SaveNewMockSale` /
|
||||
`SaveNewMockRefund` / `SaveNewMockInventoryReset`, etc. These are the
|
||||
*real* entry points production code (and the simulate-sale/refund/
|
||||
inventory UI) uses - prefer them over hand-rolled SQL when writing tests
|
||||
or new features that need mock shop/listing/event data.
|
||||
- `domains/amazon/mock.go` - the one platform with a live *background
|
||||
processor* on top of the mock layer: a Postgres `LISTEN`/`NOTIFY`-driven
|
||||
loop (`(*Mocks).ProcessEvents`) that reacts to newly inserted mock events
|
||||
and dispatches them to a `MockEventListener` (wired to Server-Sent Events
|
||||
in `main.go`, so the UI updates live). No other platform has this yet;
|
||||
if adding one, read this file's history/comments first - it went through
|
||||
several correctness passes (reconnect-on-failure, ack-based retry,
|
||||
configurable poll fallback) worth not re-discovering from scratch.
|
||||
- `server/` - HTTP layer (Gin). `server/api` is the JSON/HTMX API,
|
||||
`server/ui` renders HTML pages, `server/auth` is session/auth middleware,
|
||||
`server/sse` is the Server-Sent Events plumbing.
|
||||
- `config/config.go` - all runtime configuration comes from environment
|
||||
variables (loaded from `.env` via `godotenv`), never hardcoded. If you
|
||||
add a new required external dependency (a new secret, a new service
|
||||
URL), add it here, not as a literal in the code that uses it.
|
||||
- `internal/testdb` - shared test fixtures: `Pool(t)` (connects via
|
||||
`TEST_DATABASE_URL`, skips the test if unset), `Logger()`, `NewUserID(t)`,
|
||||
`SeedOAuthUser`/`SeedOAuthSession`.
|
||||
|
||||
## Database & migrations
|
||||
|
||||
- Migrations live in `database_migrations/`, run via `golang-migrate`
|
||||
(`migrate` CLI). Every migration needs a paired `.up.sql`/`.down.sql`.
|
||||
- **Migration files can drift from the live schema, and drift can mean
|
||||
"unrunnable," not just "stale docs."** This has happened at least twice:
|
||||
an interval-literal syntax error that failed on any fresh database, and a
|
||||
column that existed in the live dev DB but not in what the migration
|
||||
files would produce (fixed by adding a migration that reconciles the two,
|
||||
then `migrate force <version>` on the DB that already matched by hand).
|
||||
When something about the schema seems off, verify against the *live* DB
|
||||
(`psql \d`, `pg_get_viewdef(...)`) before trusting the migration files,
|
||||
and *especially* before assuming a fresh database would come up the same
|
||||
way the long-running dev one does.
|
||||
- The mock-platform schema (`mock.*`) follows a consistent per-platform
|
||||
pattern: `mock.shop_<platform>`, `mock.shop_<platform>_listings`, and for
|
||||
Amazon specifically `mock.shop_<platform>_events` (the background
|
||||
processor's queue). Some platforms also have a recursive
|
||||
`mock.shop_<platform>_listing_counts` view (built on
|
||||
`mock.shop_<platform>_listing_event_sequence`) that computes a running
|
||||
inventory count from `mock.raw_shop_events` - it's real per-platform
|
||||
logic living in SQL, not a passthrough, and worth reading with
|
||||
`pg_get_viewdef(..., true)` rather than assuming it matches another
|
||||
platform's shape.
|
||||
- **Platform-string casing is inconsistent and matters.** Most
|
||||
`accounts.Platform` constants are lowercase/snake_case (`"amazon"`,
|
||||
`"big_cartel"`, ...) matching what SQL triggers/views expect, but
|
||||
`Etsy`/`Tiktok`/`Wix` are capitalized Go-side (`"Etsy"`, not `"etsy"`).
|
||||
Some SQL objects for those platforms expect the capitalized form (e.g.
|
||||
the Etsy listing-counts view filters on `'Etsy'`), others expect
|
||||
lowercase (e.g. the Etsy `raw_shop_events` trigger checks `'etsy'`).
|
||||
Don't assume; check the actual SQL (`pg_get_viewdef`, trigger
|
||||
definitions) for the specific object you're relying on before writing a
|
||||
fixture or a query.
|
||||
- The `mock_shop_<platform>_event_inserted` `NOTIFY` channels are global,
|
||||
not scoped by shop/account. Any insert into `raw_shop_events` for that
|
||||
platform notifies *every* currently-listening connection for that
|
||||
platform, regardless of which test or code path caused it. See Testing
|
||||
below for how this bit two test packages at once.
|
||||
|
||||
## Testing
|
||||
|
||||
- Tests are real integration tests against a real Postgres database
|
||||
(`internal/testdb.Pool(t)`), not mocks/stubs of the DB layer - this
|
||||
codebase is mostly thin `Store` methods wrapping SQL, so testing the Go
|
||||
code in isolation would miss most of the actual risk.
|
||||
- Assertions use `github.com/stretchr/testify`'s `assert`/`require`
|
||||
throughout - `require` for anything the rest of the test depends on
|
||||
(halts immediately), `assert` for independent checks. Use
|
||||
`require.Eventually` for "poll until true or timeout" patterns instead of
|
||||
hand-rolling one.
|
||||
- **`require.Eventually`'s condition function runs on a separate
|
||||
goroutine.** Never call `require.*`/`t.Fatal*` (which invoke
|
||||
`t.FailNow()`) from inside one - Go's testing package requires `FailNow`
|
||||
to only be called from the test's own goroutine; calling it from a
|
||||
spawned one doesn't panic cleanly, it just silently reports the wrong
|
||||
thing. If a helper used inside an `Eventually` closure needs to check an
|
||||
error, give it a `*testing.T`-free variant that returns `(value, error)`
|
||||
and let the closure decide what "not yet satisfied" means.
|
||||
- Every test cleans up its own rows via `t.Cleanup`, in FK-safe order
|
||||
(children before parents). Use unique, randomly-generated IDs
|
||||
(`testdb.NewUserID(t)`, `uuid.NewString()`) so tests are safe to run
|
||||
concurrently with themselves and with real dev data under
|
||||
`test-against-dev-db`.
|
||||
- **Pick a mock platform deliberately when writing new fixtures that touch
|
||||
`mock.raw_shop_events`.** `domains/amazon`'s tests process *every*
|
||||
unprocessed event system-wide (that's correct behavior for its
|
||||
background worker, not a bug) and react to the global Amazon NOTIFY
|
||||
channel - any other package's tests that insert Amazon-platform mock
|
||||
events risk being picked up by `domains/amazon`'s tests when `go test
|
||||
./...` runs both packages' binaries concurrently (Go's default).
|
||||
`domains/reports`, for example, deliberately uses Etsy as its example
|
||||
platform for exactly this reason. If you're not testing
|
||||
`domains/amazon` itself, don't use Amazon as your fixture platform.
|
||||
- Never disable/re-enable a shared DB trigger to simulate a "notify never
|
||||
fires" scenario in a test, even temporarily - it's unsafe to do against
|
||||
a database anything else might be using concurrently (including a real
|
||||
`make dev` session someone has running). If you need to prove a
|
||||
timing/fallback path works, find a way to construct the scenario through
|
||||
normal writes instead (see `domains/amazon/mock_test.go`'s poll-fallback
|
||||
test for an example: it reuses the retry mechanism to guarantee a second
|
||||
dispatch can only come from the poll timer, without touching any
|
||||
trigger).
|
||||
- Killing a real Postgres backend connection (`pg_terminate_backend`,
|
||||
found via `pg_stat_activity`) is a legitimate, safe way to test
|
||||
reconnect/failure-recovery logic when properly scoped - see
|
||||
`domains/amazon/mock_test.go`'s `terminateListenConnection`. Be aware its
|
||||
PID lookup matches on query text globally, not "this test's connection
|
||||
specifically" - check for other live processes (e.g. a running `make
|
||||
dev`) before running a test like this against a shared database.
|
||||
|
||||
## Auth
|
||||
|
||||
- Real auth is Auth0-backed OIDC (`domains/authentication`). For local
|
||||
dev, set `DEV_AUTH_ENABLED=true` and hit `GET
|
||||
/api/auth/dev-login?user_id=whoever` to mint a real session (writes
|
||||
directly to `oauth_users`/`oauth_tokens`, same shape a real login
|
||||
produces) without any Auth0 round-trip. Different `user_id` values let
|
||||
you test multiple identities/accounts side by side. Never enable this
|
||||
outside local development.
|
||||
|
||||
## Commit conventions
|
||||
|
||||
- Commit messages: imperative mood subject line, no period, body explains
|
||||
*why* (what problem existed, what the fix changes) rather than
|
||||
re-describing the diff. One logical change per commit - e.g. a bugfix
|
||||
found while doing unrelated work gets its own commit, not folded into
|
||||
the original task's.
|
||||
- Work summaries: see `work-summaries/` for dated records of past
|
||||
sessions' changes and reasoning - useful context before touching an area
|
||||
someone else (human or agent) recently worked on.
|
||||
@@ -1,4 +1,62 @@
|
||||
# V2 - Attempt 2
|
||||
##
|
||||
|
||||
WIP!
|
||||
|
||||
This is an application intended to ease the process of managing multiple online stores.
|
||||
The benefits provided by this application will be to automatically manage shared inventory between stores,
|
||||
reducing the amount of time needed to synchronize inventory between stores.
|
||||
|
||||
|
||||
# Deploying
|
||||
|
||||
The application runs locally, from this directory.
|
||||
It is deployed simply by running either from the root of the project,
|
||||
```
|
||||
make run
|
||||
```
|
||||
or
|
||||
```
|
||||
go run .
|
||||
```
|
||||
|
||||
|
||||
# Development
|
||||
|
||||
## Testing
|
||||
|
||||
There's a Makefile with a number of operations for running and testing the application
|
||||
|
||||
|
||||
## Technology
|
||||
|
||||
### Languages
|
||||
|
||||
#### Server side
|
||||
Golang, Go Templates,
|
||||
|
||||
#### Front end
|
||||
HTML, CSS, Javascript
|
||||
|
||||
### Databases
|
||||
Postgres, in docker
|
||||
|
||||
#### Management
|
||||
Using [migrate](https://github.com/golang-migrate/migrate) to manage build out the database schema, and to run
|
||||
migrations.
|
||||
|
||||
|
||||
### Tooling
|
||||
|
||||
#### Server side
|
||||
- github.com/angelbeltran/templater: for wiring up template directories for serving over the web and improving
|
||||
the task of composing template together.
|
||||
- go templates: generating html declaratively from the server.
|
||||
- github.com/jackc/pgx/v5: for postgres db interfacing
|
||||
|
||||
#### Front end
|
||||
- htmx: for strong hypermedia support
|
||||
- hyperscript: for minimal, inline scripting, with strong integration with htmx
|
||||
- tailwind: for styling the front end, using tried and testing styling paradigms, conventions, and templates.
|
||||
|
||||
|
||||
# Roadmap
|
||||
|
||||
+69
-17
@@ -3,6 +3,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -25,13 +26,32 @@ type Config struct {
|
||||
// login session for any user_id without going through Auth0. Must
|
||||
// never be true outside local development.
|
||||
DevAuthEnabled bool
|
||||
|
||||
Port int
|
||||
}
|
||||
|
||||
var envFlagPtr = flag.String("env", "", "")
|
||||
|
||||
func init() {
|
||||
flag.Parse()
|
||||
}
|
||||
|
||||
// Load reads a .env file, if present, into the process environment, then
|
||||
// reads the required configuration values from the environment.
|
||||
func Load() (Config, error) {
|
||||
if err := godotenv.Load(); err != nil && !os.IsNotExist(err) {
|
||||
return Config{}, fmt.Errorf("failed to load .env file: %w", err)
|
||||
dev, err := checkEnvFlag()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if dev {
|
||||
if err := godotenv.Load(".env.dev"); err != nil && !os.IsNotExist(err) {
|
||||
return Config{}, fmt.Errorf("failed to load .env.dev file: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := godotenv.Load(); err != nil && !os.IsNotExist(err) {
|
||||
return Config{}, fmt.Errorf("failed to load .env file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -49,21 +69,8 @@ func Load() (Config, error) {
|
||||
|
||||
cfg.DatabaseURL = required("DATABASE_URL")
|
||||
|
||||
cfg.Auth0Domain = required("AUTH0_DOMAIN")
|
||||
cfg.Auth0ClientID = required("AUTH0_CLIENT_ID")
|
||||
cfg.Auth0ClientSecret = required("AUTH0_CLIENT_SECRET")
|
||||
cfg.Auth0CallbackURL = required("AUTH0_CALLBACK_URL")
|
||||
|
||||
cfg.EtsyAPIKeystring = required("ETSY_API_KEYSTRING")
|
||||
cfg.EtsyAPISharedSecret = required("ETSY_API_SHARED_SECRET")
|
||||
|
||||
if len(missing) > 0 {
|
||||
return Config{}, fmt.Errorf(
|
||||
"missing required environment variables: %v (see .env.example)",
|
||||
missing,
|
||||
)
|
||||
}
|
||||
|
||||
// DEV_AUTH_ENABLED must be known before the Auth0 vars are read below,
|
||||
// since it decides whether those are required at all.
|
||||
if raw := os.Getenv("DEV_AUTH_ENABLED"); raw != "" {
|
||||
devAuthEnabled, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
@@ -72,5 +79,50 @@ func Load() (Config, error) {
|
||||
cfg.DevAuthEnabled = devAuthEnabled
|
||||
}
|
||||
|
||||
// With DEV_AUTH_ENABLED, real Auth0 login/logout never runs (dev-login
|
||||
// mints sessions locally instead), so these are unused and optional.
|
||||
if cfg.DevAuthEnabled {
|
||||
cfg.Auth0Domain = os.Getenv("AUTH0_DOMAIN")
|
||||
cfg.Auth0ClientID = os.Getenv("AUTH0_CLIENT_ID")
|
||||
cfg.Auth0ClientSecret = os.Getenv("AUTH0_CLIENT_SECRET")
|
||||
cfg.Auth0CallbackURL = os.Getenv("AUTH0_CALLBACK_URL")
|
||||
} else {
|
||||
cfg.Auth0Domain = required("AUTH0_DOMAIN")
|
||||
cfg.Auth0ClientID = required("AUTH0_CLIENT_ID")
|
||||
cfg.Auth0ClientSecret = required("AUTH0_CLIENT_SECRET")
|
||||
cfg.Auth0CallbackURL = required("AUTH0_CALLBACK_URL")
|
||||
}
|
||||
|
||||
cfg.EtsyAPIKeystring = required("ETSY_API_KEYSTRING")
|
||||
cfg.EtsyAPISharedSecret = required("ETSY_API_SHARED_SECRET")
|
||||
|
||||
portStr := required("PORT")
|
||||
|
||||
if len(missing) > 0 {
|
||||
return Config{}, fmt.Errorf(
|
||||
"missing required environment variables: %v (see .env.example)",
|
||||
missing,
|
||||
)
|
||||
}
|
||||
|
||||
if port, err := strconv.Atoi(portStr); err != nil {
|
||||
return Config{}, fmt.Errorf("invalid port number: %w", err)
|
||||
} else {
|
||||
cfg.Port = port
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func checkEnvFlag() (dev bool, err error) {
|
||||
switch env := *envFlagPtr; env {
|
||||
case "dev":
|
||||
return true, nil
|
||||
case "prd":
|
||||
return false, nil
|
||||
case "":
|
||||
return false, fmt.Errorf("no env flag specified: must be one of dev, prd")
|
||||
default:
|
||||
return false, fmt.Errorf("invalid env flag specified: must be one of dev, prd: %q", env)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package accounts_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"ruben/inventory2/consts"
|
||||
"ruben/inventory2/domains/accounts"
|
||||
"ruben/inventory2/internal/testdb"
|
||||
@@ -21,30 +23,18 @@ func TestCreateAccount(t *testing.T) {
|
||||
email := userID + "@example.com"
|
||||
|
||||
acct, err := store.CreateAccount(ctx, userID, email)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount() error = %v", err)
|
||||
}
|
||||
require.NoError(t, err, "CreateAccount()")
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID)
|
||||
})
|
||||
|
||||
if acct.AccountID == 0 {
|
||||
t.Error("CreateAccount() returned a zero AccountID")
|
||||
}
|
||||
if acct.UserID != userID {
|
||||
t.Errorf("CreateAccount() UserID = %q, want %q", acct.UserID, userID)
|
||||
}
|
||||
if acct.Email != email {
|
||||
t.Errorf("CreateAccount() Email = %q, want %q", acct.Email, email)
|
||||
}
|
||||
assert.NotZero(t, acct.AccountID, "CreateAccount() returned a zero AccountID")
|
||||
assert.Equal(t, userID, acct.UserID, "CreateAccount() UserID")
|
||||
assert.Equal(t, email, acct.Email, "CreateAccount() Email")
|
||||
|
||||
got, err := store.GetAccount(ctx, acct.AccountID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccount() error = %v", err)
|
||||
}
|
||||
if got != acct {
|
||||
t.Errorf("GetAccount() = %+v, want %+v", got, acct)
|
||||
}
|
||||
require.NoError(t, err, "GetAccount()")
|
||||
assert.Equal(t, acct, got, "GetAccount()")
|
||||
}
|
||||
|
||||
func TestCreateAccount_DuplicateUserIsConflict(t *testing.T) {
|
||||
@@ -56,17 +46,13 @@ func TestCreateAccount_DuplicateUserIsConflict(t *testing.T) {
|
||||
testdb.SeedOAuthUser(t, pool, userID)
|
||||
|
||||
acct, err := store.CreateAccount(ctx, userID, userID+"@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("first CreateAccount() error = %v", err)
|
||||
}
|
||||
require.NoError(t, err, "first CreateAccount()")
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID)
|
||||
})
|
||||
|
||||
_, err = store.CreateAccount(ctx, userID, userID+"-other@example.com")
|
||||
if !errors.Is(err, consts.ErrConflict) {
|
||||
t.Fatalf("second CreateAccount() error = %v, want %v", err, consts.ErrConflict)
|
||||
}
|
||||
require.ErrorIs(t, err, consts.ErrConflict, "second CreateAccount()")
|
||||
}
|
||||
|
||||
func TestGetAccount_NotFound(t *testing.T) {
|
||||
@@ -75,9 +61,7 @@ func TestGetAccount_NotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := store.GetAccount(ctx, -1)
|
||||
if !errors.Is(err, consts.ErrNotFound) {
|
||||
t.Fatalf("GetAccount() error = %v, want %v", err, consts.ErrNotFound)
|
||||
}
|
||||
require.ErrorIs(t, err, consts.ErrNotFound, "GetAccount()")
|
||||
}
|
||||
|
||||
func TestGetUserAndAccountByAccessToken(t *testing.T) {
|
||||
@@ -90,34 +74,22 @@ func TestGetUserAndAccountByAccessToken(t *testing.T) {
|
||||
|
||||
// before an account exists: user resolves, account does not.
|
||||
user, acct, err := store.GetUserAndAccountByAccessToken(ctx, accessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserAndAccountByAccessToken() before account creation: error = %v", err)
|
||||
}
|
||||
if user.UserID != userID {
|
||||
t.Errorf("GetUserAndAccountByAccessToken() UserID = %q, want %q", user.UserID, userID)
|
||||
}
|
||||
if acct != nil {
|
||||
t.Errorf("GetUserAndAccountByAccessToken() Account = %+v, want nil before an account is created", acct)
|
||||
}
|
||||
require.NoError(t, err, "GetUserAndAccountByAccessToken() before account creation")
|
||||
assert.Equal(t, userID, user.UserID, "GetUserAndAccountByAccessToken() UserID")
|
||||
assert.Nil(t, acct, "GetUserAndAccountByAccessToken() Account should be nil before an account is created")
|
||||
|
||||
created, err := store.CreateAccount(ctx, userID, userID+"@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount() error = %v", err)
|
||||
}
|
||||
require.NoError(t, err, "CreateAccount()")
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", created.AccountID)
|
||||
})
|
||||
|
||||
// after an account exists: both resolve.
|
||||
user, acct, err = store.GetUserAndAccountByAccessToken(ctx, accessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserAndAccountByAccessToken() after account creation: error = %v", err)
|
||||
}
|
||||
if user.UserID != userID {
|
||||
t.Errorf("GetUserAndAccountByAccessToken() UserID = %q, want %q", user.UserID, userID)
|
||||
}
|
||||
if acct == nil || acct.AccountID != created.AccountID {
|
||||
t.Errorf("GetUserAndAccountByAccessToken() Account = %+v, want AccountID %d", acct, created.AccountID)
|
||||
require.NoError(t, err, "GetUserAndAccountByAccessToken() after account creation")
|
||||
assert.Equal(t, userID, user.UserID, "GetUserAndAccountByAccessToken() UserID")
|
||||
if assert.NotNil(t, acct, "GetUserAndAccountByAccessToken() Account should be set after an account is created") {
|
||||
assert.Equal(t, created.AccountID, acct.AccountID, "GetUserAndAccountByAccessToken() Account.AccountID")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +99,5 @@ func TestGetUserAndAccountByAccessToken_UnknownToken(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
_, _, err := store.GetUserAndAccountByAccessToken(ctx, "no-such-token-"+testdb.NewUserID(t))
|
||||
if !errors.Is(err, consts.ErrNotFound) {
|
||||
t.Fatalf("GetUserAndAccountByAccessToken() error = %v, want %v", err, consts.ErrNotFound)
|
||||
}
|
||||
require.ErrorIs(t, err, consts.ErrNotFound, "GetUserAndAccountByAccessToken()")
|
||||
}
|
||||
|
||||
@@ -217,7 +217,11 @@ func (m *Mocks) listenForNotifications(ctx context.Context) (<-chan struct{}, <-
|
||||
return fmt.Errorf("error occurred while waiting for the next notification: %w", err)
|
||||
}
|
||||
|
||||
ch <- struct{}{}
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
+99
-152
@@ -8,6 +8,8 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"ruben/inventory2/domains/raw_events"
|
||||
"ruben/inventory2/internal/testdb"
|
||||
@@ -70,9 +72,15 @@ func (s *notifySpy) ackAt(t *testing.T, i int) {
|
||||
s.mu.Lock()
|
||||
ack := s.acks[i]
|
||||
s.mu.Unlock()
|
||||
if err := ack(context.Background()); err != nil {
|
||||
t.Fatalf("ack() error = %v", err)
|
||||
}
|
||||
require.NoError(t, ack(context.Background()), "ack()")
|
||||
}
|
||||
|
||||
// completedCountSnapshot is safe to call from another goroutine (e.g. from
|
||||
// inside require.Eventually's condition), unlike helpers that call t.Fatal.
|
||||
func (s *notifySpy) completedCountSnapshot() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.completedCount
|
||||
}
|
||||
|
||||
// waitForCount blocks until at least n Notify calls have fully completed
|
||||
@@ -80,20 +88,9 @@ func (s *notifySpy) ackAt(t *testing.T, i int) {
|
||||
// more than once with increasing n.
|
||||
func (s *notifySpy) waitForCount(t *testing.T, n int, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
s.mu.Lock()
|
||||
got := s.completedCount
|
||||
s.mu.Unlock()
|
||||
|
||||
if got >= n {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("timed out after %v waiting for %d total completed notifications (got %d so far)", timeout, n, got)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
return s.completedCountSnapshot() >= n
|
||||
}, timeout, 5*time.Millisecond, "timed out waiting for %d total completed notifications (got %d)", n, s.completedCountSnapshot())
|
||||
}
|
||||
|
||||
// insertRawAmazonEvent inserts directly into mock.raw_shop_events, the same
|
||||
@@ -109,9 +106,7 @@ func insertRawAmazonEvent(t *testing.T, pool *pgxpool.Pool, shopID, eventID stri
|
||||
INSERT INTO mock.raw_shop_events (platform, shop_id, event_timestamp, event_id, raw_payload)
|
||||
VALUES ('amazon', $1, NOW(), $2, '{}'::jsonb)
|
||||
`, shopID, eventID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert raw amazon event: %v", err)
|
||||
}
|
||||
require.NoError(t, err, "insert raw amazon event")
|
||||
}
|
||||
|
||||
// insertRawAmazonEvents bulk-inserts n events for shopID in one statement,
|
||||
@@ -123,22 +118,27 @@ func insertRawAmazonEvents(t *testing.T, pool *pgxpool.Pool, shopID string, n in
|
||||
SELECT 'amazon', $1, NOW() + (s || ' milliseconds')::interval, 'evt-' || s, '{}'::jsonb
|
||||
FROM generate_series(1, $2) AS s
|
||||
`, shopID, n)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert %d raw amazon events: %v", n, err)
|
||||
}
|
||||
require.NoError(t, err, "insert %d raw amazon events", n)
|
||||
}
|
||||
|
||||
func isProcessed(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) bool {
|
||||
t.Helper()
|
||||
// queryIsProcessed has no *testing.T dependency, so it's safe to call from
|
||||
// require.Eventually's condition function (which testify runs on a
|
||||
// separate goroutine - t.Fatal/require must only be called from the main
|
||||
// test goroutine). isProcessed wraps it for direct, main-goroutine use.
|
||||
func queryIsProcessed(ctx context.Context, pool *pgxpool.Pool, shopID, eventID string) (bool, error) {
|
||||
var processed bool
|
||||
err := pool.QueryRow(context.Background(), `
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT processed_at IS NOT NULL
|
||||
FROM mock.shop_amazon_events
|
||||
WHERE shop_id = $1 AND event_id = $2
|
||||
`, shopID, eventID).Scan(&processed)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check processed state: %v", err)
|
||||
}
|
||||
return processed, err
|
||||
}
|
||||
|
||||
func isProcessed(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) bool {
|
||||
t.Helper()
|
||||
processed, err := queryIsProcessed(context.Background(), pool, shopID, eventID)
|
||||
require.NoError(t, err, "check processed state")
|
||||
return processed
|
||||
}
|
||||
|
||||
@@ -152,9 +152,7 @@ func isNotified(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) bool {
|
||||
FROM mock.shop_amazon_events
|
||||
WHERE shop_id = $1 AND event_id = $2
|
||||
`, shopID, eventID).Scan(¬ified)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check notified state: %v", err)
|
||||
}
|
||||
require.NoError(t, err, "check notified state")
|
||||
return notified
|
||||
}
|
||||
|
||||
@@ -164,9 +162,7 @@ func countUnprocessed(t *testing.T, pool *pgxpool.Pool, shopID string) int {
|
||||
err := pool.QueryRow(context.Background(), `
|
||||
SELECT count(*) FROM mock.shop_amazon_events WHERE shop_id = $1 AND processed_at IS NULL
|
||||
`, shopID).Scan(&n)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count unprocessed events: %v", err)
|
||||
}
|
||||
require.NoError(t, err, "count unprocessed events")
|
||||
return n
|
||||
}
|
||||
|
||||
@@ -197,12 +193,33 @@ func terminateListenConnection(t *testing.T, pool *pgxpool.Pool) {
|
||||
ORDER BY backend_start DESC
|
||||
LIMIT 1
|
||||
`, eventChannelName).Scan(&pid)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to find the LISTEN connection's backend pid: %v", err)
|
||||
}
|
||||
require.NoError(t, err, "find the LISTEN connection's backend pid")
|
||||
|
||||
if _, err := pool.Exec(ctx, `SELECT pg_terminate_backend($1)`, pid); err != nil {
|
||||
t.Fatalf("failed to terminate backend %d: %v", pid, err)
|
||||
_, err = pool.Exec(ctx, `SELECT pg_terminate_backend($1)`, pid)
|
||||
require.NoError(t, err, "terminate backend %d", pid)
|
||||
}
|
||||
|
||||
// waitReady blocks until m signals it's actively listening, failing the
|
||||
// test if that doesn't happen within timeout.
|
||||
func waitReady(t *testing.T, m *Mocks, timeout time.Duration) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-m.Ready():
|
||||
case <-time.After(timeout):
|
||||
require.Fail(t, "ProcessEvents() did not become ready (LISTEN registered)", "within %v", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// waitStopped blocks until ProcessEvents returns on errCh, asserting it
|
||||
// returns a nil error, failing the test if that doesn't happen within
|
||||
// timeout.
|
||||
func waitStopped(t *testing.T, errCh <-chan error, timeout time.Duration) {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
require.NoError(t, err, "ProcessEvents() after context cancellation")
|
||||
case <-time.After(timeout):
|
||||
require.Fail(t, "ProcessEvents() did not return", "within %v of context cancellation", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,15 +235,12 @@ func TestProcessUnprocessedEvents_ProcessesAllEventsAcrossBatches(t *testing.T)
|
||||
const n = 150 // exceeds the 100-row LIMIT per batch inside processUnprocessedEvents
|
||||
insertRawAmazonEvents(t, pool, shopID, n)
|
||||
|
||||
if err := m.processUnprocessedEvents(ctx); err != nil {
|
||||
t.Fatalf("processUnprocessedEvents() error = %v", err)
|
||||
}
|
||||
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents()")
|
||||
|
||||
spy.waitForCount(t, n, 5*time.Second)
|
||||
|
||||
if got := countUnprocessed(t, pool, shopID); got != 0 {
|
||||
t.Errorf("countUnprocessed() = %d, want 0 (all %d events should be processed across multiple 100-row batches)", got, n)
|
||||
}
|
||||
assert.Zero(t, countUnprocessed(t, pool, shopID),
|
||||
"countUnprocessed() should be 0 (all %d events should be processed across multiple 100-row batches)", n)
|
||||
}
|
||||
|
||||
func TestProcessUnprocessedEvents_NoListenerConfigured(t *testing.T) {
|
||||
@@ -239,18 +253,14 @@ func TestProcessUnprocessedEvents_NoListenerConfigured(t *testing.T) {
|
||||
|
||||
insertRawAmazonEvent(t, pool, shopID, "evt-1")
|
||||
|
||||
if err := m.processUnprocessedEvents(ctx); err != nil {
|
||||
t.Fatalf("processUnprocessedEvents() error = %v", err)
|
||||
}
|
||||
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents()")
|
||||
|
||||
// with no listener, nothing ever acks - the event is expected to stay
|
||||
// "notified" forever, not silently marked processed.
|
||||
if isProcessed(t, pool, shopID, "evt-1") {
|
||||
t.Error("event was marked processed despite no listener being configured to ack it")
|
||||
}
|
||||
if !isNotified(t, pool, shopID, "evt-1") {
|
||||
t.Error("event should be in the notified state after being handed off with no listener to ack it")
|
||||
}
|
||||
assert.False(t, isProcessed(t, pool, shopID, "evt-1"),
|
||||
"event was marked processed despite no listener being configured to ack it")
|
||||
assert.True(t, isNotified(t, pool, shopID, "evt-1"),
|
||||
"event should be in the notified state after being handed off with no listener to ack it")
|
||||
}
|
||||
|
||||
// TestProcessUnprocessedEvents_RetriesUnackedNotification is the core of
|
||||
@@ -271,47 +281,29 @@ func TestProcessUnprocessedEvents_RetriesUnackedNotification(t *testing.T) {
|
||||
|
||||
insertRawAmazonEvent(t, pool, shopID, "evt-1")
|
||||
|
||||
if err := m.processUnprocessedEvents(ctx); err != nil {
|
||||
t.Fatalf("processUnprocessedEvents() (1st pass) error = %v", err)
|
||||
}
|
||||
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (1st pass)")
|
||||
spy.waitForCount(t, 1, 2*time.Second)
|
||||
|
||||
if isProcessed(t, pool, shopID, "evt-1") {
|
||||
t.Fatal("event was marked processed despite the listener never acking")
|
||||
}
|
||||
if !isNotified(t, pool, shopID, "evt-1") {
|
||||
t.Fatal("event should be in the notified state after the first dispatch")
|
||||
}
|
||||
require.False(t, isProcessed(t, pool, shopID, "evt-1"), "event was marked processed despite the listener never acking")
|
||||
require.True(t, isNotified(t, pool, shopID, "evt-1"), "event should be in the notified state after the first dispatch")
|
||||
|
||||
// still within notifyRetryAfter: shouldn't be re-notified yet.
|
||||
if err := m.processUnprocessedEvents(ctx); err != nil {
|
||||
t.Fatalf("processUnprocessedEvents() (immediate 2nd pass) error = %v", err)
|
||||
}
|
||||
if got := len(spy.eventsSnapshot()); got != 1 {
|
||||
t.Fatalf("listener was notified %d times before notifyRetryAfter elapsed, want 1", got)
|
||||
}
|
||||
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (immediate 2nd pass)")
|
||||
require.Len(t, spy.eventsSnapshot(), 1, "listener should not have been notified again before notifyRetryAfter elapsed")
|
||||
|
||||
time.Sleep(60 * time.Millisecond) // past notifyRetryAfter
|
||||
|
||||
if err := m.processUnprocessedEvents(ctx); err != nil {
|
||||
t.Fatalf("processUnprocessedEvents() (3rd pass, after retry window) error = %v", err)
|
||||
}
|
||||
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (3rd pass, after retry window)")
|
||||
spy.waitForCount(t, 2, 2*time.Second)
|
||||
|
||||
// the listener "finishes" the first notification late, via the ack it
|
||||
// was originally handed - not a fresh one from the retry.
|
||||
spy.ackAt(t, 0)
|
||||
|
||||
if !isProcessed(t, pool, shopID, "evt-1") {
|
||||
t.Fatal("event should be processed once any recorded ack for it is called")
|
||||
}
|
||||
require.True(t, isProcessed(t, pool, shopID, "evt-1"), "event should be processed once any recorded ack for it is called")
|
||||
|
||||
if err := m.processUnprocessedEvents(ctx); err != nil {
|
||||
t.Fatalf("processUnprocessedEvents() (4th pass, after ack) error = %v", err)
|
||||
}
|
||||
if got := len(spy.eventsSnapshot()); got != 2 {
|
||||
t.Fatalf("listener was notified again after being acked: got %d calls, want 2", got)
|
||||
}
|
||||
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (4th pass, after ack)")
|
||||
assert.Len(t, spy.eventsSnapshot(), 2, "listener should not be notified again after being acked")
|
||||
}
|
||||
|
||||
// TestProcessEvents_PollFallbackPicksUpRetryDueEvents proves the poll
|
||||
@@ -341,11 +333,7 @@ func TestProcessEvents_PollFallbackPicksUpRetryDueEvents(t *testing.T) {
|
||||
errCh <- m.ProcessEvents(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-m.Ready():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ProcessEvents() did not become ready (LISTEN registered) within 5s")
|
||||
}
|
||||
waitReady(t, m, 5*time.Second)
|
||||
|
||||
insertRawAmazonEvent(t, pool, shopID, "evt-1")
|
||||
|
||||
@@ -358,14 +346,7 @@ func TestProcessEvents_PollFallbackPicksUpRetryDueEvents(t *testing.T) {
|
||||
spy.waitForCount(t, 2, 3*time.Second)
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessEvents() returned error = %v after context cancellation, want nil", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ProcessEvents() did not return within 5s of context cancellation")
|
||||
}
|
||||
waitStopped(t, errCh, 5*time.Second)
|
||||
}
|
||||
|
||||
// TestProcessEvents_ReactsToNotification drives the actual long-running
|
||||
@@ -389,36 +370,24 @@ func TestProcessEvents_ReactsToNotification(t *testing.T) {
|
||||
errCh <- m.ProcessEvents(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-m.Ready():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ProcessEvents() did not become ready (LISTEN registered) within 5s")
|
||||
}
|
||||
waitReady(t, m, 5*time.Second)
|
||||
|
||||
insertRawAmazonEvent(t, pool, shopID, "evt-1")
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !isProcessed(t, pool, shopID, "evt-1") {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("event was not processed within 5s of insertion - the reactive LISTEN/NOTIFY wake-up did not fire (the 1-minute poll fallback would eventually catch it, but this test intentionally doesn't wait that long)")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
processed, _ := queryIsProcessed(context.Background(), pool, shopID, "evt-1")
|
||||
return processed
|
||||
}, 5*time.Second, 20*time.Millisecond,
|
||||
"event was not processed within 5s of insertion - the reactive LISTEN/NOTIFY wake-up did not fire "+
|
||||
"(the 1-minute poll fallback would eventually catch it, but this test intentionally doesn't wait that long)")
|
||||
|
||||
spy.waitForCount(t, 1, 2*time.Second)
|
||||
if got := spy.eventsSnapshot()[0]; got.StoreID != shopID || got.EventID != "evt-1" {
|
||||
t.Errorf("listener notified with %+v, want StoreID=%q EventID=%q", got, shopID, "evt-1")
|
||||
}
|
||||
got := spy.eventsSnapshot()[0]
|
||||
assert.Equal(t, shopID, got.StoreID, "listener notified with unexpected StoreID")
|
||||
assert.Equal(t, "evt-1", got.EventID, "listener notified with unexpected EventID")
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessEvents() returned error = %v after context cancellation, want nil", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ProcessEvents() did not return within 5s of context cancellation")
|
||||
}
|
||||
waitStopped(t, errCh, 5*time.Second)
|
||||
}
|
||||
|
||||
// TestProcessEvents_ShutsDownOnContextCancel checks the lifecycle in
|
||||
@@ -436,21 +405,10 @@ func TestProcessEvents_ShutsDownOnContextCancel(t *testing.T) {
|
||||
errCh <- m.ProcessEvents(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-m.Ready():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ProcessEvents() did not become ready (LISTEN registered) within 5s")
|
||||
}
|
||||
waitReady(t, m, 5*time.Second)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessEvents() returned error = %v after context cancellation, want nil", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ProcessEvents() did not return within 5s of context cancellation")
|
||||
}
|
||||
waitStopped(t, errCh, 5*time.Second)
|
||||
}
|
||||
|
||||
// TestProcessEvents_ReconnectsAfterListenConnectionDrops is insight #4's
|
||||
@@ -474,11 +432,7 @@ func TestProcessEvents_ReconnectsAfterListenConnectionDrops(t *testing.T) {
|
||||
errCh <- m.ProcessEvents(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-m.Ready():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ProcessEvents() did not become ready (LISTEN registered) within 5s")
|
||||
}
|
||||
waitReady(t, m, 5*time.Second)
|
||||
|
||||
terminateListenConnection(t, pool)
|
||||
|
||||
@@ -486,27 +440,20 @@ func TestProcessEvents_ReconnectsAfterListenConnectionDrops(t *testing.T) {
|
||||
// backoff is 1s) - it must not have returned because of this.
|
||||
select {
|
||||
case err := <-errCh:
|
||||
t.Fatalf("ProcessEvents() returned (err = %v) after its LISTEN connection was killed, want it to reconnect and keep running", err)
|
||||
require.Fail(t, "ProcessEvents() returned after its LISTEN connection was killed, want it to reconnect and keep running",
|
||||
"err = %v", err)
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
|
||||
insertRawAmazonEvent(t, pool, shopID, "evt-1")
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !isProcessed(t, pool, shopID, "evt-1") {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("event was not processed within 5s of insertion after the LISTEN connection was forcibly dropped - reconnection did not restore the reactive path")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
processed, _ := queryIsProcessed(context.Background(), pool, shopID, "evt-1")
|
||||
return processed
|
||||
}, 5*time.Second, 20*time.Millisecond,
|
||||
"event was not processed within 5s of insertion after the LISTEN connection was forcibly dropped - "+
|
||||
"reconnection did not restore the reactive path")
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessEvents() returned error = %v after context cancellation, want nil", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ProcessEvents() did not return within 5s of context cancellation")
|
||||
}
|
||||
waitStopped(t, errCh, 5*time.Second)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,10 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
// New instantiates the *Authenticator.
|
||||
// New instantiates an *Authenticator backed by a real Auth0 tenant: it makes
|
||||
// an OIDC discovery call against domain, so login/callback/logout are fully
|
||||
// functional. Use NewDev instead when DEV_AUTH_ENABLED is set and no real
|
||||
// Auth0 app is configured.
|
||||
func New(
|
||||
ctx context.Context,
|
||||
db *pgxpool.Pool,
|
||||
@@ -75,6 +78,18 @@ func New(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewDev instantiates an *Authenticator with no real Auth0 tenant behind it:
|
||||
// no OIDC discovery call is made, and Provider/Config are left zero-valued.
|
||||
// Only DevLogin is safe to call on the result - Exchange, VerifyIDToken, and
|
||||
// GetLogoutURL all assume a real Auth0 setup and will misbehave. Only use
|
||||
// this from a route gated on an explicit dev-mode flag.
|
||||
func NewDev(db *pgxpool.Pool, logger *logging.Logger) *Authenticator {
|
||||
return &Authenticator{
|
||||
log: logger,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Authenticator) RunBackgroundCleanup(ctx context.Context) error {
|
||||
for {
|
||||
if _, err := a.db.Exec(ctx, "DELETE FROM oauth_tokens WHERE expiry < NOW()"); err != nil {
|
||||
|
||||
@@ -2,10 +2,12 @@ package authentication
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"ruben/inventory2/consts"
|
||||
"ruben/inventory2/internal/testdb"
|
||||
)
|
||||
@@ -22,36 +24,21 @@ func TestDevLogin(t *testing.T) {
|
||||
})
|
||||
|
||||
accessToken, expiration, err := auth.DevLogin(ctx, userID, "Test User")
|
||||
if err != nil {
|
||||
t.Fatalf("DevLogin() error = %v", err)
|
||||
}
|
||||
if accessToken == "" {
|
||||
t.Fatal("DevLogin() returned an empty access token")
|
||||
}
|
||||
if !expiration.After(time.Now().Add(30 * 24 * time.Hour)) {
|
||||
t.Errorf("DevLogin() expiration = %v, want something far enough out to avoid the near-expiry refresh path", expiration)
|
||||
}
|
||||
require.NoError(t, err, "DevLogin()")
|
||||
require.NotEmpty(t, accessToken, "DevLogin() returned an empty access token")
|
||||
assert.True(t, expiration.After(time.Now().Add(30*24*time.Hour)),
|
||||
"DevLogin() expiration = %v, want something far enough out to avoid the near-expiry refresh path", expiration)
|
||||
|
||||
claims, err := auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccessTokenClaimsAndExpiration() error = %v", err)
|
||||
}
|
||||
if claims.Name != "Test User" {
|
||||
t.Errorf("claims.Name = %q, want %q", claims.Name, "Test User")
|
||||
}
|
||||
require.NoError(t, err, "GetAccessTokenClaimsAndExpiration()")
|
||||
assert.Equal(t, "Test User", claims.Name, "claims.Name")
|
||||
// Postgres timestamptz has microsecond precision, so the round-tripped
|
||||
// value loses the sub-microsecond portion of Go's nanosecond clock.
|
||||
if diff := claims.Expiration.Sub(expiration); diff > time.Millisecond || diff < -time.Millisecond {
|
||||
t.Errorf("claims.Expiration = %v, want ~%v (diff %v)", claims.Expiration, expiration, diff)
|
||||
}
|
||||
assert.WithinDuration(t, expiration, claims.Expiration, time.Millisecond, "claims.Expiration")
|
||||
|
||||
_, tokenType, err := auth.getRefreshTokenForAccessToken(ctx, accessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("getRefreshTokenForAccessToken() error = %v", err)
|
||||
}
|
||||
if tokenType != "dev" {
|
||||
t.Errorf("tokenType = %q, want %q", tokenType, "dev")
|
||||
}
|
||||
require.NoError(t, err, "getRefreshTokenForAccessToken()")
|
||||
assert.Equal(t, "dev", tokenType, "tokenType")
|
||||
}
|
||||
|
||||
// DevLogin should be safe to call more than once for the same user_id -
|
||||
@@ -69,18 +56,12 @@ func TestDevLogin_SameUserIDTwice(t *testing.T) {
|
||||
})
|
||||
|
||||
token1, _, err := auth.DevLogin(ctx, userID, "Test User")
|
||||
if err != nil {
|
||||
t.Fatalf("first DevLogin() error = %v", err)
|
||||
}
|
||||
require.NoError(t, err, "first DevLogin()")
|
||||
|
||||
token2, _, err := auth.DevLogin(ctx, userID, "Test User")
|
||||
if err != nil {
|
||||
t.Fatalf("second DevLogin() error = %v", err)
|
||||
}
|
||||
require.NoError(t, err, "second DevLogin()")
|
||||
|
||||
if token1 == token2 {
|
||||
t.Fatalf("DevLogin() returned the same access token twice: %q", token1)
|
||||
}
|
||||
assert.NotEqual(t, token1, token2, "DevLogin() returned the same access token twice")
|
||||
}
|
||||
|
||||
func TestGetAccessTokenClaimsAndExpiration_UnknownToken(t *testing.T) {
|
||||
@@ -89,7 +70,5 @@ func TestGetAccessTokenClaimsAndExpiration_UnknownToken(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := auth.GetAccessTokenClaimsAndExpiration(ctx, "no-such-token-"+testdb.NewUserID(t))
|
||||
if !errors.Is(err, consts.ErrNotFound) {
|
||||
t.Fatalf("GetAccessTokenClaimsAndExpiration() error = %v, want %v", err, consts.ErrNotFound)
|
||||
}
|
||||
require.ErrorIs(t, err, consts.ErrNotFound, "GetAccessTokenClaimsAndExpiration()")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"ruben/inventory2/domains/raw_events"
|
||||
"ruben/inventory2/internal/testdb"
|
||||
@@ -39,33 +41,20 @@ func TestSaveAndLoadEventsForStore(t *testing.T) {
|
||||
Payload: json.RawMessage(`{"n":2}`),
|
||||
}
|
||||
|
||||
if err := store.Save(ctx, &older); err != nil {
|
||||
t.Fatalf("Save() older event error = %v", err)
|
||||
}
|
||||
if err := store.Save(ctx, &newer); err != nil {
|
||||
t.Fatalf("Save() newer event error = %v", err)
|
||||
}
|
||||
require.NoError(t, store.Save(ctx, &older), "Save() older event")
|
||||
require.NoError(t, store.Save(ctx, &newer), "Save() newer event")
|
||||
|
||||
got, err := store.LoadEventsForStore(ctx, platform, storeID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEventsForStore() error = %v", err)
|
||||
}
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("LoadEventsForStore() returned %d events, want 2: %+v", len(got), got)
|
||||
}
|
||||
require.NoError(t, err, "LoadEventsForStore()")
|
||||
require.Len(t, got, 2, "LoadEventsForStore()")
|
||||
|
||||
// ordered event_timestamp DESC - newest first.
|
||||
if got[0].EventID != "evt-2" || got[1].EventID != "evt-1" {
|
||||
t.Errorf("LoadEventsForStore() order = [%s, %s], want [evt-2, evt-1]", got[0].EventID, got[1].EventID)
|
||||
}
|
||||
assert.Equal(t, "evt-2", got[0].EventID, "LoadEventsForStore()[0]")
|
||||
assert.Equal(t, "evt-1", got[1].EventID, "LoadEventsForStore()[1]")
|
||||
|
||||
var payload struct{ N int }
|
||||
if err := json.Unmarshal(got[0].Payload, &payload); err != nil {
|
||||
t.Fatalf("failed to unmarshal LoadEventsForStore()[0].Payload = %s: %v", got[0].Payload, err)
|
||||
}
|
||||
if payload.N != 2 {
|
||||
t.Errorf("LoadEventsForStore()[0].Payload n = %d, want 2", payload.N)
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(got[0].Payload, &payload), "unmarshal LoadEventsForStore()[0].Payload")
|
||||
assert.Equal(t, 2, payload.N, "LoadEventsForStore()[0].Payload n")
|
||||
}
|
||||
|
||||
func TestLoadEventsForStore_NoEvents(t *testing.T) {
|
||||
@@ -74,10 +63,6 @@ func TestLoadEventsForStore_NoEvents(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
got, err := store.LoadEventsForStore(ctx, "test-platform", "no-such-store-"+uuid.NewString())
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEventsForStore() error = %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("LoadEventsForStore() = %+v, want empty", got)
|
||||
}
|
||||
require.NoError(t, err, "LoadEventsForStore()")
|
||||
assert.Empty(t, got, "LoadEventsForStore()")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package reports_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"ruben/inventory2/consts"
|
||||
"ruben/inventory2/domains/accounts"
|
||||
"ruben/inventory2/domains/reports"
|
||||
"ruben/inventory2/internal/testdb"
|
||||
)
|
||||
|
||||
// setupEtsyMockShop creates a fresh account and an Etsy mock shop for it,
|
||||
// and registers cleanup for every row it creates, in FK-safe order
|
||||
// (shop_etsy[_events] -> mock.accounts -> accounts; oauth_users cleanup is
|
||||
// handled by testdb.SeedOAuthUser itself).
|
||||
//
|
||||
// Etsy (not Amazon) is deliberately used here: domains/amazon's tests
|
||||
// process every unprocessed row in mock.shop_amazon_events system-wide
|
||||
// (that's correct production behavior for a background worker, not a
|
||||
// bug), so any test that writes Amazon-platform mock events risks being
|
||||
// picked up by domains/amazon's tests when the two packages' test
|
||||
// binaries run concurrently (go test ./... does this by default). Using
|
||||
// a different platform here keeps this package's fixtures completely off
|
||||
// domains/amazon's tables and NOTIFY channel.
|
||||
func setupEtsyMockShop(t *testing.T, pool *pgxpool.Pool, acctStore *accounts.Store) (acctID int64, shopID string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
userID := testdb.NewUserID(t)
|
||||
testdb.SeedOAuthUser(t, pool, userID)
|
||||
|
||||
acct, err := acctStore.CreateAccount(ctx, userID, userID+"@example.com")
|
||||
require.NoError(t, err, "CreateAccount()")
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID)
|
||||
})
|
||||
|
||||
id, err := acctStore.CreateMockShop(ctx, acct.AccountID, accounts.Etsy, "Test Shop")
|
||||
require.NoError(t, err, "CreateMockShop()")
|
||||
shopID = id.String()
|
||||
|
||||
t.Cleanup(func() {
|
||||
ctx := context.Background()
|
||||
pool.Exec(ctx, "DELETE FROM mock.shop_etsy_events WHERE shop_id = $1", shopID)
|
||||
pool.Exec(ctx, "DELETE FROM mock.raw_shop_events WHERE shop_id = $1", shopID)
|
||||
pool.Exec(ctx, "DELETE FROM mock.shop_etsy WHERE account_id = $1", acct.AccountID)
|
||||
pool.Exec(ctx, "DELETE FROM mock.accounts WHERE account_id = $1", acct.AccountID)
|
||||
})
|
||||
|
||||
return acct.AccountID, shopID
|
||||
}
|
||||
|
||||
func insertRawShopEvent(t *testing.T, pool *pgxpool.Pool, shopID, eventID string, ts time.Time, payload string) {
|
||||
t.Helper()
|
||||
_, err := pool.Exec(context.Background(), `
|
||||
INSERT INTO mock.raw_shop_events (platform, shop_id, event_timestamp, event_id, raw_payload)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||
`, string(accounts.Etsy), shopID, ts, eventID, payload)
|
||||
require.NoError(t, err, "insert raw shop event")
|
||||
}
|
||||
|
||||
func TestGetRawShopEvents(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
acctStore := accounts.NewStore(testdb.Logger(), pool)
|
||||
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
|
||||
ctx := context.Background()
|
||||
|
||||
acctID, shopID := setupEtsyMockShop(t, pool, acctStore)
|
||||
|
||||
older := time.Now().Add(-time.Hour).UTC()
|
||||
newer := time.Now().UTC()
|
||||
insertRawShopEvent(t, pool, shopID, "evt-1", older, `{"n":1}`)
|
||||
insertRawShopEvent(t, pool, shopID, "evt-2", newer, `{"n":2}`)
|
||||
|
||||
got, err := reportsStore.GetRawShopEvents(ctx, acctID, accounts.Etsy, shopID)
|
||||
require.NoError(t, err, "GetRawShopEvents()")
|
||||
require.Len(t, got, 2, "GetRawShopEvents()")
|
||||
|
||||
// ordered event_timestamp DESC, event_id ASC - newest first.
|
||||
assert.Equal(t, "evt-2", got[0].EventID, "GetRawShopEvents()[0]")
|
||||
assert.Equal(t, "evt-1", got[1].EventID, "GetRawShopEvents()[1]")
|
||||
|
||||
assert.Equal(t, accounts.Etsy, got[0].Platform, "got[0].Platform")
|
||||
assert.Equal(t, shopID, got[0].ShopID, "got[0].ShopID")
|
||||
|
||||
var payload struct{ N int }
|
||||
require.NoError(t, json.Unmarshal(got[0].RawPayload, &payload), "unmarshal got[0].RawPayload")
|
||||
assert.Equal(t, 2, payload.N, "got[0].RawPayload n")
|
||||
}
|
||||
|
||||
func TestGetRawShopEvents_NoEvents(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
acctStore := accounts.NewStore(testdb.Logger(), pool)
|
||||
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
|
||||
ctx := context.Background()
|
||||
|
||||
acctID, shopID := setupEtsyMockShop(t, pool, acctStore)
|
||||
|
||||
got, err := reportsStore.GetRawShopEvents(ctx, acctID, accounts.Etsy, shopID)
|
||||
require.NoError(t, err, "GetRawShopEvents()")
|
||||
assert.Empty(t, got, "GetRawShopEvents()")
|
||||
}
|
||||
|
||||
func TestGetRawShopEvents_UnknownShop(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
acctStore := accounts.NewStore(testdb.Logger(), pool)
|
||||
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := testdb.NewUserID(t)
|
||||
testdb.SeedOAuthUser(t, pool, userID)
|
||||
|
||||
acct, err := acctStore.CreateAccount(ctx, userID, userID+"@example.com")
|
||||
require.NoError(t, err, "CreateAccount()")
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID)
|
||||
})
|
||||
|
||||
_, err = reportsStore.GetRawShopEvents(ctx, acct.AccountID, accounts.Etsy, "no-such-shop-"+uuid.NewString())
|
||||
require.ErrorIs(t, err, consts.ErrNotFound, "GetRawShopEvents()")
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package reports_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"ruben/inventory2/consts"
|
||||
"ruben/inventory2/domains/accounts"
|
||||
"ruben/inventory2/domains/reports"
|
||||
"ruben/inventory2/internal/testdb"
|
||||
)
|
||||
|
||||
// createEtsyListing creates a listing with the given base count in shopID
|
||||
// and registers its cleanup. Must be called after setupEtsyMockShop, so
|
||||
// cleanup order (LIFO) deletes the listing before the shop it belongs to.
|
||||
func createEtsyListing(t *testing.T, acctStore *accounts.Store, acctID int64, shopID string, baseCount int64) (listingID string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
pool := testdb.Pool(t)
|
||||
|
||||
listing, err := acctStore.CreateMockListing(ctx, accounts.MockListing{
|
||||
AccountShopListingIDs: accounts.AccountShopListingIDs{
|
||||
AccountShopIDs: accounts.AccountShopIDs{
|
||||
AccountIDs: accounts.AccountIDs{AccountID: acctID},
|
||||
Platform: accounts.Etsy,
|
||||
ShopID: shopID,
|
||||
},
|
||||
},
|
||||
SKU: "test-sku",
|
||||
Name: "Test Listing",
|
||||
Description: "a listing created for a test",
|
||||
Count: baseCount,
|
||||
})
|
||||
require.NoError(t, err, "CreateMockListing()")
|
||||
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(context.Background(), `
|
||||
DELETE FROM mock.shop_etsy_listings WHERE shop_id = $1 AND listing_id = $2
|
||||
`, shopID, listing.ListingID)
|
||||
})
|
||||
|
||||
return listing.ListingID
|
||||
}
|
||||
|
||||
// TestGetListingCountsOverTime exercises the actual running-count logic,
|
||||
// which lives in a recursive SQL view (mock.shop_etsy_listing_counts,
|
||||
// built on mock.shop_etsy_listing_event_sequence) rather than in Go: it
|
||||
// starts from the listing's base count and walks mock.raw_shop_events in
|
||||
// order, applying each as a delta (sale/refund) or an absolute reset
|
||||
// (inventory-reset). Uses the real SaveNewMock* methods to write those
|
||||
// events, the same entry points the simulate-sale/refund/inventory UI
|
||||
// uses, rather than hand-rolling the JSON payload shape.
|
||||
func TestGetListingCountsOverTime(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
acctStore := accounts.NewStore(testdb.Logger(), pool)
|
||||
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
|
||||
ctx := context.Background()
|
||||
|
||||
acctID, shopID := setupEtsyMockShop(t, pool, acctStore)
|
||||
listingID := createEtsyListing(t, acctStore, acctID, shopID, 100)
|
||||
|
||||
_, err := acctStore.SaveNewMockSale(ctx, acctID, accounts.Etsy, shopID, listingID, 10)
|
||||
require.NoError(t, err, "SaveNewMockSale()")
|
||||
_, err = acctStore.SaveNewMockRefund(ctx, acctID, accounts.Etsy, shopID, listingID, 5)
|
||||
require.NoError(t, err, "SaveNewMockRefund()")
|
||||
_, err = acctStore.SaveNewMockInventoryReset(ctx, acctID, accounts.Etsy, shopID, listingID, 50)
|
||||
require.NoError(t, err, "SaveNewMockInventoryReset()")
|
||||
|
||||
got, err := reportsStore.GetListingCountsOverTime(ctx, acctID, accounts.Etsy, shopID, listingID)
|
||||
require.NoError(t, err, "GetListingCountsOverTime()")
|
||||
|
||||
wantCounts := []int64{100, 110, 105, 50}
|
||||
require.Len(t, got, len(wantCounts), "GetListingCountsOverTime()")
|
||||
|
||||
gotCounts := make([]int64, len(got))
|
||||
for i, row := range got {
|
||||
gotCounts[i] = row.Count
|
||||
}
|
||||
assert.Equal(t, wantCounts, gotCounts, "GetListingCountsOverTime() counts, full sequence: %+v", got)
|
||||
|
||||
assert.Nil(t, got[0].EventTimestamp, "got[0].EventTimestamp should be nil (the base count row)")
|
||||
for i := 1; i < len(got); i++ {
|
||||
assert.NotNil(t, got[i].EventTimestamp, "got[%d].EventTimestamp should be set (only the base row should be nil)", i)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetListingCountsOverTime_UnknownListing(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
acctStore := accounts.NewStore(testdb.Logger(), pool)
|
||||
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
|
||||
ctx := context.Background()
|
||||
|
||||
acctID, shopID := setupEtsyMockShop(t, pool, acctStore)
|
||||
|
||||
_, err := reportsStore.GetListingCountsOverTime(ctx, acctID, accounts.Etsy, shopID, "no-such-listing")
|
||||
require.ErrorIs(t, err, consts.ErrNotFound, "GetListingCountsOverTime()")
|
||||
}
|
||||
|
||||
func TestGetListingCountsReport(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
acctStore := accounts.NewStore(testdb.Logger(), pool)
|
||||
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
|
||||
ctx := context.Background()
|
||||
|
||||
acctID, shopID := setupEtsyMockShop(t, pool, acctStore)
|
||||
listingID := createEtsyListing(t, acctStore, acctID, shopID, 100)
|
||||
|
||||
_, err := acctStore.SaveNewMockSale(ctx, acctID, accounts.Etsy, shopID, listingID, 10)
|
||||
require.NoError(t, err, "SaveNewMockSale()")
|
||||
_, err = acctStore.SaveNewMockInventoryReset(ctx, acctID, accounts.Etsy, shopID, listingID, 20)
|
||||
require.NoError(t, err, "SaveNewMockInventoryReset()")
|
||||
|
||||
report, err := reportsStore.GetListingCountsReport(ctx, acctID, accounts.Etsy, shopID, listingID)
|
||||
require.NoError(t, err, "GetListingCountsReport()")
|
||||
|
||||
assert.Equal(t, acctID, report.AccountID, "report.AccountID")
|
||||
assert.Equal(t, accounts.Etsy, report.Platform, "report.Platform")
|
||||
assert.Equal(t, shopID, report.ShopID, "report.ShopID")
|
||||
assert.Equal(t, listingID, report.ListingID, "report.ListingID")
|
||||
|
||||
// counts over the sequence: 100 (base) -> 110 (sale +10) -> 20 (reset)
|
||||
assert.Equal(t, int64(110), report.MaxCount().Count, "MaxCount().Count")
|
||||
assert.Equal(t, int64(20), report.MinCount().Count, "MinCount().Count")
|
||||
}
|
||||
@@ -14,6 +14,7 @@ require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lmittmann/tint v1.1.2
|
||||
github.com/oapi-codegen/runtime v1.1.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
)
|
||||
|
||||
@@ -28,6 +29,7 @@ require (
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/getkin/kin-openapi v0.133.0 // indirect
|
||||
@@ -60,6 +62,7 @@ require (
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
|
||||
|
||||
@@ -78,17 +78,22 @@ func runApp(ctx context.Context, logger *logging.Logger) error {
|
||||
return fmt.Errorf("failed to initialize database connection pool: %w", err)
|
||||
}
|
||||
|
||||
auth, err := authentication.New(
|
||||
ctx,
|
||||
connPool,
|
||||
logger.WithGroup("authenticator"),
|
||||
cfg.Auth0Domain,
|
||||
cfg.Auth0ClientID,
|
||||
cfg.Auth0ClientSecret,
|
||||
cfg.Auth0CallbackURL,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to construct authenticator: %w", err)
|
||||
var auth *authentication.Authenticator
|
||||
if cfg.DevAuthEnabled {
|
||||
auth = authentication.NewDev(connPool, logger.WithGroup("authenticator"))
|
||||
} else {
|
||||
auth, err = authentication.New(
|
||||
ctx,
|
||||
connPool,
|
||||
logger.WithGroup("authenticator"),
|
||||
cfg.Auth0Domain,
|
||||
cfg.Auth0ClientID,
|
||||
cfg.Auth0ClientSecret,
|
||||
cfg.Auth0CallbackURL,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to construct authenticator: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
accts := accounts.NewStore(logger.WithGroup("accounts"), connPool)
|
||||
@@ -240,7 +245,7 @@ func runServer(
|
||||
)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":8082", // local
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port), // local
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
@@ -253,7 +258,7 @@ func runServer(
|
||||
defer cancel()
|
||||
defer close(alreadyShutdownCh)
|
||||
|
||||
logger.Info("server running on 8082...")
|
||||
logger.Infof("server running on %d...", cfg.Port)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
runningErrCh <- fmt.Errorf("server experienced error: %w", err)
|
||||
|
||||
@@ -35,6 +35,7 @@ func Routes(
|
||||
if devAuthEnabled {
|
||||
logger.Warn("DEV_AUTH_ENABLED is set: /api/auth/dev-login is live and lets any caller authenticate as any user_id with no credentials. Never enable this outside local development.")
|
||||
r.GET("/dev-login", response.Handler(ls.devLoginPage))
|
||||
r.GET("/dev-logout", response.Handler(ls.devLogoutPage))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,3 +136,21 @@ func (s *loginSubrouter) logoutPage(c *gin.Context) (response.Response, error) {
|
||||
return response.TemporaryRedirect(s.auth.GetLogoutURL(host).String()).
|
||||
Cookie(cookies.Expired("access_token")), nil
|
||||
}
|
||||
|
||||
func (s *loginSubrouter) devLogoutPage(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
|
||||
if ck, err := r.Cookie("access_token"); err == nil && ck != nil {
|
||||
if err := s.auth.DeleteOAuthTokens(r.Context(), ck.Value); err != nil {
|
||||
s.log.Error("failed to delete auth token", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return response.TemporaryRedirect("/ui").
|
||||
Cookie(cookies.Expired("access_token")), nil
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ func NewRouter(
|
||||
reps,
|
||||
etsy,
|
||||
authM.Authenticate(),
|
||||
devAuthEnabled,
|
||||
)
|
||||
|
||||
// non-html content: scripts, styles, images, etc
|
||||
|
||||
+19
-11
@@ -24,13 +24,14 @@ import (
|
||||
|
||||
type (
|
||||
webpageRouter struct {
|
||||
log *logging.Logger
|
||||
uiPath string
|
||||
templater *templater.Templater
|
||||
rawEvents *raw_events.Store
|
||||
accts *accounts.Store
|
||||
reports *reports.Store
|
||||
etsy *etsy_platform.Platform
|
||||
log *logging.Logger
|
||||
uiPath string
|
||||
templater *templater.Templater
|
||||
rawEvents *raw_events.Store
|
||||
accts *accounts.Store
|
||||
reports *reports.Store
|
||||
etsy *etsy_platform.Platform
|
||||
devAuthEnabled bool
|
||||
}
|
||||
|
||||
// ErrTemplateNotFound is returned if the reason the template failed to compile
|
||||
@@ -49,6 +50,7 @@ func Routes(
|
||||
reps *reports.Store,
|
||||
etsy *etsy_platform.Platform,
|
||||
authenticate gin.HandlerFunc,
|
||||
devAuthEnabled bool,
|
||||
) {
|
||||
|
||||
s := &webpageRouter{
|
||||
@@ -177,10 +179,11 @@ func Routes(
|
||||
}
|
||||
},
|
||||
}),
|
||||
rawEvents: rawEvents,
|
||||
accts: accts,
|
||||
reports: reps,
|
||||
etsy: etsy,
|
||||
rawEvents: rawEvents,
|
||||
accts: accts,
|
||||
reports: reps,
|
||||
etsy: etsy,
|
||||
devAuthEnabled: devAuthEnabled,
|
||||
}
|
||||
|
||||
r.GET("", response.Handler(s.redirectToAccountsIfLoggedInWithAnAccount), response.Handler(s.serveTemplate))
|
||||
@@ -219,6 +222,11 @@ func (s *webpageRouter) serveTemplate(c *gin.Context) (response.Response, error)
|
||||
args := []any{
|
||||
"Request",
|
||||
r,
|
||||
|
||||
// dev mode
|
||||
"DevAuthEnabled",
|
||||
s.devAuthEnabled,
|
||||
|
||||
// add services and data here
|
||||
"RawEvents",
|
||||
s.rawEvents.WithContext(ctx),
|
||||
|
||||
+106
-10
@@ -18,6 +18,7 @@
|
||||
--text-xl--line-height: calc(1.75 / 1.25);
|
||||
--text-3xl: 1.875rem;
|
||||
--text-6xl: 3.75rem;
|
||||
--text-6xl--line-height: 1;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
--radius-lg: var(--radius);
|
||||
@@ -179,9 +180,18 @@
|
||||
}
|
||||
}
|
||||
@layer utilities {
|
||||
.invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
.visible {
|
||||
visibility: visible;
|
||||
}
|
||||
.absolute {
|
||||
position: absolute;
|
||||
}
|
||||
.fixed {
|
||||
position: fixed;
|
||||
}
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
@@ -319,6 +329,9 @@
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.inline {
|
||||
display: inline;
|
||||
}
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -585,6 +598,10 @@
|
||||
.font-display {
|
||||
font-family: var(--display-family);
|
||||
}
|
||||
.text-6xl {
|
||||
font-size: var(--text-6xl);
|
||||
line-height: var(--tw-leading, var(--text-6xl--line-height));
|
||||
}
|
||||
.text-lg {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--tw-leading, var(--text-lg--line-height));
|
||||
@@ -617,12 +634,22 @@
|
||||
.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.lowercase {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.accent-secondary {
|
||||
accent-color: var(--secondary);
|
||||
}
|
||||
.shadow {
|
||||
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.outline-1 {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
@@ -643,11 +670,6 @@
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
.not-group-focus-within\:hidden {
|
||||
&:not(*:is(:where(.group):focus-within *)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.not-group-hover\:hidden {
|
||||
&:not(*:is(:where(.group):hover *)) {
|
||||
display: none;
|
||||
@@ -656,11 +678,6 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.not-group-focus\:hidden {
|
||||
&:not(*:is(:where(.group):focus *)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.not-open\:mb-\[1em\] {
|
||||
&:not(*:is([open], :popover-open, :open)) {
|
||||
margin-bottom: 1em;
|
||||
@@ -1249,6 +1266,71 @@
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-shadow-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-shadow-alpha {
|
||||
syntax: "<percentage>";
|
||||
inherits: false;
|
||||
initial-value: 100%;
|
||||
}
|
||||
@property --tw-inset-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-inset-shadow-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-inset-shadow-alpha {
|
||||
syntax: "<percentage>";
|
||||
inherits: false;
|
||||
initial-value: 100%;
|
||||
}
|
||||
@property --tw-ring-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-ring-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-inset-ring-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-inset-ring-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-ring-inset {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-ring-offset-width {
|
||||
syntax: "<length>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
@property --tw-ring-offset-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: #fff;
|
||||
}
|
||||
@property --tw-ring-offset-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-outline-style {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
@@ -1346,6 +1428,20 @@
|
||||
--tw-skew-y: initial;
|
||||
--tw-border-style: solid;
|
||||
--tw-font-weight: initial;
|
||||
--tw-shadow: 0 0 #0000;
|
||||
--tw-shadow-color: initial;
|
||||
--tw-shadow-alpha: 100%;
|
||||
--tw-inset-shadow: 0 0 #0000;
|
||||
--tw-inset-shadow-color: initial;
|
||||
--tw-inset-shadow-alpha: 100%;
|
||||
--tw-ring-color: initial;
|
||||
--tw-ring-shadow: 0 0 #0000;
|
||||
--tw-inset-ring-color: initial;
|
||||
--tw-inset-ring-shadow: 0 0 #0000;
|
||||
--tw-ring-inset: initial;
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-outline-style: solid;
|
||||
--tw-blur: initial;
|
||||
--tw-brightness: initial;
|
||||
|
||||
+25
-11
@@ -5,6 +5,7 @@
|
||||
{{- $acctID = .Identity.Account.AccountID }}
|
||||
{{- end }}
|
||||
{{- $mockMode := .MockMode }}
|
||||
{{- $devAuthEnabled := .DevAuthEnabled }}
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
@@ -117,8 +118,13 @@
|
||||
>
|
||||
{{- if not $loggedIn }}
|
||||
|
||||
{{- $loginPath := "/api/auth/login" }}
|
||||
{{- if $devAuthEnabled }}
|
||||
{{- $loginPath = "/api/auth/dev-login" }}
|
||||
{{- end }}
|
||||
|
||||
{{ template "navbar-link" (props
|
||||
"Href" "/api/auth/login"
|
||||
"Href" $loginPath
|
||||
"NoHXBoost" true
|
||||
"Selected" (eq $path "/auth/login")
|
||||
"Content" "Log In"
|
||||
@@ -231,7 +237,8 @@
|
||||
<a href="mailto:contact-us@inventory-plus-plus.com" class="p-[1em] font-display text-center">Contact Us</a>
|
||||
<a href="mailto:support@inventory-plus-plus.com" class="p-[1em] font-display text-center">Support</a>
|
||||
</address>
|
||||
{{- if .Identity.Claims.Picture }}
|
||||
|
||||
{{- if (or .Identity.Claims.Picture .Identity.Claims.Name) }}
|
||||
<button
|
||||
popovertarget="identity-popover"
|
||||
class="
|
||||
@@ -244,14 +251,21 @@
|
||||
items-center
|
||||
"
|
||||
>
|
||||
<img
|
||||
src="{{ .Identity.Claims.Picture }}"
|
||||
class="rounded-[50%]"
|
||||
style="
|
||||
max-height: 3rem;
|
||||
margin: 1rem 0;
|
||||
"
|
||||
/>
|
||||
{{ if .Identity.Claims.Picture }}
|
||||
<img
|
||||
src="{{ .Identity.Claims.Picture }}"
|
||||
class="rounded-[50%]"
|
||||
style="
|
||||
max-height: 3rem;
|
||||
margin: 1rem 0;
|
||||
"
|
||||
/>
|
||||
{{ else if .Identity.Claims.Name }}
|
||||
{{ .Identity.Claims.Name }}
|
||||
{{ else }}
|
||||
User
|
||||
{{ end }}
|
||||
|
||||
</button>
|
||||
<div
|
||||
popover="auto"
|
||||
@@ -308,7 +322,7 @@
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/api/auth/logout"
|
||||
href="/api/auth/{{if $devAuthEnabled}}dev-logout{{else}}logout{{end}}"
|
||||
hx-boost="false"
|
||||
class="
|
||||
w-fit
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{{- $loggedIn := and (and .Identity .Identity.AccessToken) true -}}
|
||||
{{- $devAuthEnabled := .DevAuthEnabled }}
|
||||
|
||||
|
||||
<section class="flex justify-center max-w-full mt-[3em] mb-[3em]">
|
||||
@@ -26,7 +27,7 @@
|
||||
Create an Account
|
||||
</a>
|
||||
{{- else }}
|
||||
<a href="/api/auth/login" hx-boost="false" class="block p-[1em] font-bold">
|
||||
<a href="/api/auth/{{if $devAuthEnabled}}dev-login{{else}}login{{end}}" hx-boost="false" class="block p-[1em] font-bold">
|
||||
New Login
|
||||
</a>
|
||||
{{- end }}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Work Summary — 2026-08-05 22:18
|
||||
|
||||
## Task
|
||||
First half of `domains/reports` test coverage (agreed to split into two separate tasks): `GetRawShopEvents`. The other method, `GetListingCountsOverTime`/`GetListingCountsReport`, needs a deeper fixture (a listing plus count-changing history feeding a DB view) and was deliberately left for a separate pass.
|
||||
|
||||
## Changes
|
||||
New `domains/reports/events_test.go`:
|
||||
- `setupAmazonMockShop` helper: creates a fresh account (via `testdb.SeedOAuthUser` + `accounts.Store.CreateAccount`, matching the pattern from `domains/accounts/accounts_test.go`) and an Amazon mock shop via `accounts.Store.CreateMockShop`, registering cleanup for every row it creates in FK-safe order (`mock.shop_amazon_events` / `mock.raw_shop_events` → `mock.shop_amazon` → `mock.accounts` → `accounts`; `oauth_users` cleanup comes from `SeedOAuthUser` itself). Cleans up `shop_amazon_events` too even though these tests don't touch the Amazon event processor, since any `raw_shop_events` insert for `platform='amazon'` fires the same DB trigger that populates it.
|
||||
- `TestGetRawShopEvents`: inserts two raw events at different timestamps, confirms both come back, in the right order (`event_timestamp DESC, event_id ASC`), with the right `Platform`/`ShopID`, and that `RawPayload` round-trips correctly through the `jsonb` column.
|
||||
- `TestGetRawShopEvents_NoEvents`: a valid shop with zero events returns an empty slice, not an error.
|
||||
- `TestGetRawShopEvents_UnknownShop`: an unrecognized shop ID returns `consts.ErrNotFound` (via the `accts.GetMockShop` check `GetRawShopEvents` does before querying events).
|
||||
|
||||
## Verification
|
||||
- `go build ./...` / `go vet ./...` clean.
|
||||
- `go test ./domains/reports/... -v -race`: all 3 pass.
|
||||
- 10x repeated runs (`-count=1 -race`) with no flakes, ~1.1-1.2s each.
|
||||
- `make test`: full suite green.
|
||||
- Confirmed zero leftover rows after the run across `accounts`, `mock.accounts`, and `mock.raw_shop_events` on the test DB.
|
||||
- Skipped `make test-against-dev-db`: same live `go run .` process from the previous session was still running, and this suite inserts real `mock.raw_shop_events` rows for `platform='amazon'`, which fires the same trigger/NOTIFY that live process's Amazon background handler listens on. Almost certainly harmless (unique per-test IDs, no connection manipulation involved this time, unlike the insight #4 test), but no strong need to interact with a running session's live processing loop just to re-confirm what the test-DB run already showed cleanly.
|
||||
|
||||
## Follow-ups / not done here
|
||||
- `GetListingCountsOverTime`/`GetListingCountsReport` coverage remains a separate, open task - needs an account + mock shop + `CreateMockListing` + something that actually generates count history (a simulated sale/refund/inventory change, or a direct insert) so the `listingCountsView` these methods read from has real data to aggregate. That view is real per-platform logic, not just a passthrough, so this would be the first test exercising it directly rather than just the Go code around it.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Work Summary — 2026-08-06 20:44
|
||||
|
||||
## Task
|
||||
Follow-up from the testify migration: fix the remaining test-isolation failure between `domains/amazon` and `domains/reports` (the goroutine-leak half of the original `go test ./...` hang was already fixed and committed separately as `7c65f92`).
|
||||
|
||||
## Root cause
|
||||
`domains/reports`' fixtures used `accounts.Amazon` as their example platform - same as `domains/amazon`'s own tests. Both packages' test binaries run concurrently under `go test ./...` (Go's default), both write to the shared `mock.raw_shop_events` table with `platform='amazon'`, and the DB trigger routes those into `mock.shop_amazon_events`, the exact table `domains/amazon`'s background-processing tests poll and assert on. Confirmed directly: running the two packages together, `domains/amazon`'s `TestProcessUnprocessedEvents_RetriesUnackedNotification` picked up 4 events instead of 1, three of them from `domains/reports`' fixture shop.
|
||||
|
||||
## Fix
|
||||
Switched `domains/reports`' fixtures from Amazon to Etsy (renamed `setupAmazonMockShop`→`setupEtsyMockShop`, `createAmazonListing`→`createEtsyListing`, and all table names/platform constants throughout `domains/reports/events_test.go` and `domains/reports/reports_test.go`). Test-only change, no production code touched.
|
||||
|
||||
Verified the exact casing before switching, since it mattered: `accounts.Etsy`'s Go value is `"Etsy"` (capital E) unlike most other platform constants (`"amazon"`, `"big_cartel"`, etc., all lowercase) - and separately, Etsy's `mock.raw_shop_events` *trigger* (which routes into `mock.shop_etsy_events`, the table `domains/amazon`-style processors would use) checks for lowercase `'etsy'`. Confirmed via `pg_get_viewdef` that the *listing-counts view* (`mock.shop_etsy_listing_event_sequence`, what these tests actually depend on) filters on `'Etsy'` (capital), matching the Go constant correctly - so the fixtures work correctly, and as a side effect never fire the lowercase-gated trigger at all, keeping `mock.shop_etsy_events` completely untouched by these tests regardless.
|
||||
|
||||
## Verification
|
||||
- `go build ./...` / `go vet ./...` clean.
|
||||
- `domains/reports` alone: all 6 tests still pass.
|
||||
- `domains/amazon` + `domains/reports` together, 8x repeated (`-race -count=1`): all clean, no failures.
|
||||
- `go test ./... -race` (the exact command that used to hang before the goroutine-leak fix, and would still have failed on the isolation issue afterward): now green as one command, no special-casing needed.
|
||||
- `make test`: green, zero leftover rows in `accounts`, `mock.accounts`, `mock.raw_shop_events` afterward.
|
||||
|
||||
## Follow-ups / not done here
|
||||
None specific to this fix. Combined with the goroutine-leak fix (`7c65f92`), `go test ./...` / `make test` are both reliably green as single commands again - the workaround of running `domains/amazon` separately from everything else is no longer needed.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Work Summary — 2026-08-10 15:43
|
||||
|
||||
## Task
|
||||
"Play around in Penpot and see if you can generalize any of the common elements." Not a git-tracked code change — this is design-system work in the connected Penpot file. Recorded here for continuity since it's real project work.
|
||||
|
||||
## Context
|
||||
The connected Penpot file turned out to be the *unmodified* default Tailwind CSS starter template (raw, unrenamed color swatches, a breakpoint reference, a shadow reference) - no existing mockups of this app's actual UI. Clarified with the user that "generalize the common elements" meant reverse-engineering the app's *real*, already-implemented UI (`templates/components/*.html.tmpl`, `styles/typography.css`) into actual Penpot library assets, not curating the generic starter kit.
|
||||
|
||||
`styles/typography.css` turned out to hold a full custom shadcn/ui-style design system already: three OKLCH color scales (`base`, `primary`, `secondary`, 50-1000 each), ~38 semantic tokens (`background`, `card`, `accent`, `border`, `chart-1..5`, `sidebar-*`, `table-*`, etc.) with distinct light/dark values via `@media (prefers-color-scheme: dark)`, and two custom fonts (Geist 600 for headings, Alexandria 300 for body).
|
||||
|
||||
No browser extension was available to read live computed styles, so exact OKLCH→hex conversion had to be done by hand-implementing the standard algorithm (Björn Ottosson's OKLab, CSS Color 4 matrices) inside Penpot's JS sandbox, rather than relying on assumption or memory.
|
||||
|
||||
## What was built in Penpot
|
||||
- **OKLCH→hex conversion**: implemented and validated against known-correct reference points (pure black/white exactly, and `oklch(0.577 0.245 27.325)` → `#E7000B`, which independently matches shadcn/ui's well-known default destructive red) before trusting it for the full palette.
|
||||
- **Design Tokens** (Penpot's native token system, chosen over flat colors specifically so light/dark could be modeled properly):
|
||||
- `Primitives` set (40 tokens, always active): `base.50`-`base.1000`, `primary.50`-`primary.1000`, `secondary.50`-`secondary.1000`, `destructive.light`/`.dark`, `white`, `black`.
|
||||
- `Semantic/Light` and `Semantic/Dark` sets (38 tokens each): every semantic token name the app's CSS defines, referencing the right primitive per theme.
|
||||
- A `Mode` theme group (Light/Dark), Light active by default - mirrors the app's `:root` vs. `@media (prefers-color-scheme: dark)` structure. Verified references resolve correctly end-to-end.
|
||||
- **Typography**: `Display/H1`-`H4`,`H6` (Geist 600, sized from Tailwind's `text-6xl`/`3xl`/`xl`/`lg`/`sm`) and `Text/Body` (Alexandria 300, 16px). `H5` deliberately skipped - the source CSS references `--text-md`, which isn't defined anywhere (a real gap in the app's CSS, not something to guess a value for).
|
||||
- **Button component** (from `templates/components/button.html.tmpl`): a proper Penpot Variant group (`State` property: `Default`/`Hover`/`Active-Disabled`) built entirely from the token system rather than hardcoded colors - background/border/text colors all come from `color.card`/`color.border`/`color.foreground`/`color.accent`/`color.accent-secondary` tokens. Visually verified via export at each stage.
|
||||
|
||||
## A real Penpot platform quirk hit and worked around
|
||||
`penpot.createVariantFromComponents` (and the `penpotUtils.createVariantContainer` wrapper built on it) failed with a server-side validation error (`Value not valid: [object ShapeProxy]... Code: :shapes`) when passed boards where two of the three had been created via `.clone()` of a board that was *already* a component's main instance. Isolated the cause with targeted diagnostics (confirmed flex layout alone was fine; confirmed applied color tokens alone were fine; the only remaining variable - clone-of-an-existing-main-instance - was the actual cause). Fix: rebuild each variant state as an independent board from scratch rather than cloning an already-componentized one, then create each as its own component before combining. Worth remembering if building more variant groups in this file.
|
||||
|
||||
## Verification
|
||||
- OKLCH conversion validated against known reference values before trusting it for ~40 colors.
|
||||
- Token resolution spot-checked (`color.background` → `#FAFAFE`, `color.primary` → `#B8E954`, `color.destructive` → `#E7000B`), all correct.
|
||||
- Button auto-sizing confirmed (73×36, correctly hugging text + 8px padding on `p-[0.5em]` at 16px base).
|
||||
- All three button states exported and visually inspected individually and as a working variant group; no `variantError` on any of the three.
|
||||
- A Penpot plugin disconnect happened mid-session (browser-side, not caused by this work) - the already-computed, validated hex map was saved to the scratchpad before it could be lost, and reloaded directly (no recomputation needed) once reconnected.
|
||||
|
||||
## Follow-ups / not done here
|
||||
- `Accordion` (`templates/components/accordion.html.tmpl`) and `TutorialTooltip` (`templates/components/tutorial-tooltip.html.tmpl`) components not yet built - user chose to first wire the Button into a proper Variant group (done above) rather than continue to these; picking this back up is the natural next step.
|
||||
- `H5` typography intentionally left unresolved (`--text-md` undefined in source CSS) - worth a decision on the actual code side (define `--text-md`, or change `h5` to reference an existing size) independent of the Penpot work.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Work Summary — 2026-08-10 20:22
|
||||
|
||||
## Task
|
||||
Continue the Penpot design-system work: build the `Accordion` component from `templates/components/accordion.html.tmpl`, following the Button's pattern from the previous session.
|
||||
|
||||
## A false alarm, resolved
|
||||
Mid-build, token resolution appeared to produce scrambled/wrong values - including for the *already-verified-correct* Button component, which seemed to indicate live corruption. Paused and reported rather than continuing to build on top of it. Turned out to be a non-issue: the user had switched Penpot's active theme from Light to Dark while reviewing in the UI. Verified by checking the "wrong" values against the actual Dark-theme semantic mapping - every one matched exactly (`color.accent` → `base.800` → `#242237`, `color.foreground` → `base.200` → `#E6E7F3`, etc.). The token system was working correctly the whole time; the check was just comparing Dark output against Light expectations. Switched back to Light (`lightTheme.toggleActive()`) and confirmed the Button's Default fill was `#ffffff` again before continuing. No actual reversal was needed - nothing was broken.
|
||||
|
||||
## What was built
|
||||
`Accordion` component (from `accordion.html.tmpl`), as a Penpot Variant group (`State`: `Closed`/`Open`), built fresh per-state (not cloned, per the lesson from the Button session) and entirely from the existing token system:
|
||||
- **Closed**: just the summary bar - `color.accent` background, `rounded-lg`, header text left / `+` indicator right (Alexandria 700, 2em). No outer background, matching the template (`bg-card` only applies when open).
|
||||
- **Open**: the same summary bar (indicator now `−`) plus an expanded body area below, both wrapped in an outer container using `color.card` background and `rounded-lg` - matching `open:bg-card` applying to the outer `<details>`, with the body's `p-[1em]` padding around placeholder body text.
|
||||
|
||||
## Verification
|
||||
- Both states exported and visually inspected individually before combining - correct token-driven colors, correct layout (row-fill summary bar, centered/padded body).
|
||||
- Variant group verified: `isVariantContainer()` true, `State` property with `Closed`/`Open` values, no `variantError` on either.
|
||||
- Final combined export confirms both states render correctly together.
|
||||
|
||||
## Follow-ups / not done here
|
||||
- `TutorialTooltip` (`templates/components/tutorial-tooltip.html.tmpl`) is the last of the three components surveyed at the start of this work - not yet built.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Work Summary — 2026-08-10 20:40
|
||||
|
||||
## Task
|
||||
Final piece of the Penpot design-system work: build `TutorialTooltip` from `templates/components/tutorial-tooltip.html.tmpl`, completing the three components surveyed at the start of this thread.
|
||||
|
||||
## What was built
|
||||
`TutorialTooltip` - the popover panel itself (not the invisible anchor wrapper `<div>`, which only carries positioning/click-handler behavior, no visual style of its own). Unlike Button and Accordion, this component has no meaningful *visual* state variants - its only dynamic behavior is show/hide (CSS `popover`/`open`) and a multi-step content walkthrough (swapping which `<span>` is visible on click), neither of which changes its appearance. Built as a single library component rather than a Variant group, which is the correct fit here, not a shortcut.
|
||||
|
||||
Structure: `color.card` background, 1px border using `color.sidebar-border` (a token not used by Button or Accordion, since this is the only component that references `--sidebar-border` in its source CSS), `rounded-lg`, a literal drop shadow (`2px 2px 2px 1px rgb(0 0 0 / 20%)` - not backed by any token in the source CSS, so applied as a direct shape shadow rather than invented as one), flex row with content text (fills available space) and a `×` close glyph (fixed, right-aligned) - mirroring the template's `grid-template-columns: 1fr max-content`.
|
||||
|
||||
## Verification
|
||||
- Colors confirmed correct before exporting (`fill: #ffffff`, `stroke: #e6e7f3` - card/sidebar-border in Light theme).
|
||||
- Visual export matches the template's intent: card panel, subtle border, drop shadow, content + close button.
|
||||
- Final full-library sweep: 3 components (`Button` and `Accordion` as Variant groups, `TutorialTooltip` as a single component, confirmed via `isVariant()`), token sets in expected state (`Primitives` + `Semantic/Light` active, `Semantic/Dark` inactive), all 6 typographies present. No leftover test/diagnostic artifacts anywhere in the file (swept with a name-based search across all pages).
|
||||
|
||||
## Outcome
|
||||
This closes out the original "generalize the common elements" request. All three reusable UI components implemented in `templates/components/` now exist as real, token-driven Penpot library components, built from the app's actual CSS values (not guessed), with the color/typography foundation they're built on independently reusable for any future component work in this file.
|
||||
|
||||
## Follow-ups / not done here
|
||||
- Two things flagged during this whole thread that are worth a decision on the *code* side, independent of Penpot: `h5 { font-size: var(--text-md) }` in `styles/typography.css` references an undefined variable (no typography asset was created for H5 as a result); and the `TutorialTooltip`'s box-shadow and `animate-pulse` (a pulsing opacity animation on the popover, not represented in the static Penpot export) aren't tied to any reusable token, unlike everything else in the file.
|
||||
Reference in New Issue
Block a user