Compare commits

...
10 Commits
Author SHA1 Message Date
angelandClaude Sonnet 5 e78d5c4246 auth: split dev-mode auth constructor and wire up dev-login/logout UI
Squeamish about New()'s empty-domain-string sentinel for "dev mode, skip
OIDC discovery" - split into New (always makes a real OIDC discovery
call, all params required) and NewDev (no ctx/domain/credentials at all,
since none are used). main.go now branches on cfg.DevAuthEnabled to pick
the right constructor instead of main.go/config.go coordinating on when
it's safe to pass empty strings.

Also finishes out the dev-auth flow this enables: config.Load reads a
DEV_AUTH_ENABLED-aware env file and only requires Auth0 vars when dev
auth is off; a PORT config var replaces the hardcoded :8082; and the nav
UI (layout/index templates, ui router) points login/logout links at
/api/auth/dev-login and a new /api/auth/dev-logout route when dev auth
is enabled, so the whole login/logout loop works locally without a real
Auth0 app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 14:19:10 -06:00
angel 465fb107b2 penpot design work summaries 2026-08-10 23:26:10 -06:00
angelandClaude Sonnet 5 7932e5d90c docs: fill in AGENTS.md
Setup, common commands, architecture overview, and the database/testing
gotchas actually hit while working in this repo recently: migration
files that can drift to the point of being unrunnable (not just
stale), inconsistent platform-string casing between Go constants and
SQL objects, global (unscoped) mock-platform NOTIFY channels, the
require.Eventually-runs-on-a-goroutine hazard, and why domains/reports'
fixtures use Etsy rather than Amazon. CLAUDE.md already points here via
@AGENTS.md, so no change needed there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-06 22:11:50 -06:00
angelandClaude Sonnet 5 e1268316bd reports: switch test fixtures from Amazon to Etsy to fix test isolation
domains/reports' fixtures used Amazon as their example platform - same
as domains/amazon's own tests. Both packages' test binaries run
concurrently under go test ./... by default, both wrote to the shared
mock.raw_shop_events table with platform='amazon', and the DB trigger
routed 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.

Switched to Etsy instead (test-only change, no production code
touched). Verified the casing first since it mattered here:
accounts.Etsy's Go value is "Etsy" (capital), and separately Etsy's
raw_shop_events trigger checks for lowercase 'etsy' - but the view
these tests actually depend on (mock.shop_etsy_listing_event_sequence)
filters on 'Etsy', matching the Go constant, confirmed via
pg_get_viewdef. So the fixtures work correctly and, as a side effect,
never fire the lowercase-gated trigger at all - keeping
mock.shop_etsy_events untouched by these tests regardless.

Combined with the previous commit's goroutine-leak fix, go test ./...
and make test are both reliably green as single commands again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-06 20:45:27 -06:00
angelandClaude Sonnet 5 7c65f928ed amazon: fix a goroutine/connection leak on ProcessEvents shutdown
listenForNotifications' notification-forwarding goroutine sent on ch
unconditionally after each WaitForNotification. If ProcessEvents' main
loop had already exited (ctx cancelled) at the exact moment this
goroutine had a notification to forward, nobody was left reading from
the unbuffered channel - the send blocked forever, the goroutine never
reached its deferred pc.Release(), and the pooled connection leaked
permanently. Real in production (a shutdown racing an in-flight
NOTIFY), not just a test artifact.

Surfaced by a go test ./... hang inside domains/amazon (a goroutine
stuck in pgxpool.Pool.Close's WaitGroup.Wait) - increased cross-package
NOTIFY traffic from domains/reports' Amazon-platform fixtures made the
race easy to hit, but didn't cause it.

Fixed with a select alongside the send so the goroutine notices
ctx.Done() instead of blocking forever when nobody's listening anymore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-06 20:39:45 -06:00
angelandClaude Sonnet 5 a9b51bcad9 tests: migrate assertions to testify's assert/require
Replaces raw t.Error/t.Errorf/t.Fatal/t.Fatalf across every test file
that has any (domains/accounts, domains/authentication,
domains/raw_events, domains/amazon, domains/reports x2) with testify's
assert (non-halting) / require (halting) equivalents. The three
Example-based tests (server/ui/svg, server/ui/charts) have no
*testing.T at all - nothing to convert there.

require.Eventually replaces several hand-rolled polling loops in
domains/amazon/mock_test.go. Its condition function runs on a separate
goroutine (confirmed in testify's source), so calling require.* from
inside one - which two of the new Eventually calls initially did, via
the isProcessed helper - is unsafe per Go's testing rules (t.FailNow
must only be called from the test's own goroutine). Fixed by splitting
a *testing.T-free queryIsProcessed(ctx, pool, shopID, eventID) out of
isProcessed for use inside those closures specifically.

github.com/stretchr/testify promoted from an indirect to a direct
dependency (go.mod only - it was already present transitively, so
go.sum is unchanged).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-06 20:39:30 -06:00
angel 56feb931bf stubbed CLAUDE.md and AGENTS.md 2026-08-06 20:20:42 -06:00
angel e286f04df9 updated readme 2026-08-06 20:18:50 -06:00
angelandClaude Sonnet 5 e4e805d139 reports: add test coverage for GetListingCountsOverTime/Report
mock.shop_amazon_listing_counts is a recursive view that computes a
running inventory count: starting from the listing's base count in
mock.shop_amazon_listings, it walks mock.raw_shop_events in order,
applying each event as a delta (sale/refund) or an absolute reset
(inventory-reset), per mock.shop_amazon_listing_event_sequence's
interpretation of each row's JSON payload. This is the first test to
exercise that view directly rather than just the Go code around it.

Fixture uses the real SaveNewMockSale/SaveNewMockRefund/
SaveNewMockInventoryReset methods - the same entry points the
simulate-sale/refund/inventory UI uses - rather than hand-rolling the
JSON payload shape, so the test tracks the real payload contract.

Covers the full running-count sequence (base -> sale -> refund ->
reset), the unknown-listing ErrNotFound case, and
GetListingCountsReport's MaxCount/MinCount over that sequence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-06 19:47:09 -06:00
angelandClaude Sonnet 5 345e78753b reports: add test coverage for GetRawShopEvents
Covers the happy path (ordering by event_timestamp DESC/event_id ASC,
Platform/ShopID fields, RawPayload round-tripping through jsonb), the
empty-shop case, and the unknown-shop ErrNotFound case (via the
accts.GetMockShop check GetRawShopEvents does before querying).

setupAmazonMockShop creates a real account + Amazon mock shop via the
same Store methods the app uses (CreateAccount, CreateMockShop) and
registers cleanup in FK-safe order.

GetListingCountsOverTime/GetListingCountsReport coverage is a separate,
larger task (needs a listing plus count-changing history feeding a DB
view) - not done here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-06 15:17:52 -06:00
26 changed files with 1081 additions and 334 deletions
+183
View File
@@ -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
View File
@@ -0,0 +1 @@
@AGENTS.md
+59 -1
View File
@@ -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
View File
@@ -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)
}
}
+21 -51
View File
@@ -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()")
}
+5 -1
View File
@@ -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
View File
@@ -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(&notified)
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)
}
+16 -1
View File
@@ -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 {
+16 -37
View File
@@ -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()")
}
+13 -28
View File
@@ -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()")
}
+129
View File
@@ -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()")
}
+128
View File
@@ -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")
}
+3
View File
@@ -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
+18 -13
View File
@@ -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)
+19
View File
@@ -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
}
+1
View File
@@ -65,6 +65,7 @@ func NewRouter(
reps,
etsy,
authM.Authenticate(),
devAuthEnabled,
)
// non-html content: scripts, styles, images, etc
+19 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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.