45 Commits
Author SHA1 Message Date
angel d2b1d1e8cc gitea-claude workflow test: add penpot tools
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 13s
2026-08-21 15:43:23 -06:00
angel ccd9cd9c94 gitea-claude workflow fix: allow assignee of @claude to trigger action
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 18s
2026-08-21 02:55:52 -06:00
angel f310eeba9b gitea-claude workflow fix: explicitly set model
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 21s
2026-08-21 02:30:32 -06:00
angel f956aee811 gitea-claude workflow fix: set version to @gitea
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 19s
2026-08-21 02:19:44 -06:00
angel 2146dd3f46 gitea-claude workflow fix: tweak version
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 8s
Tests / Go tests (push) Successful in 16s
2026-08-21 01:51:46 -06:00
angel 393cda8785 gitea-claude workflow fix: missing version
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 14s
2026-08-21 01:49:56 -06:00
angel 4fbf5c6b53 new gitea-claude workflow
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 19s
2026-08-21 01:44:11 -06:00
angel cdd2202839 docs: document self-hosted Gitea and its MCP tooling for agents
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 13s
Make sure AI agents know this project is hosted on a self-hosted Gitea
instance rather than GitHub, and that mcp__angel__* MCP tools (with no
Projects API) are available for interacting with it.
2026-08-20 23:59:48 -06:00
angelandClaude Sonnet 5 ee97ae20f8 docs: make README's "Where things live" index clickable links
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 21s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 17:40:09 -06:00
angelandClaude Sonnet 5 b9dd4fc0e5 docs: split README into distributed docs, fix stale/broken content
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 16s
README had drifted: an unresolved merge-conflict marker left over from a
past edit, a directory-structure diagram that no longer matches the repo
(/internal/site, /internal/domains/platforms/tiktok - actual layout is
server/, domains/, etc per AGENTS.md), and a duplicated dev-workflow blurb
that AGENTS.md already documents more accurately.

Splits the roadmap checklist out to ROADMAP.md and the Etsy API compliance
checklist to domains/platforms/etsy/COMPLIANCE.md (next to the code it
governs, where someone touching that integration will actually look for
it) instead of burying both in one large README. README itself becomes a
short front door with a "Where things live" index up top, since scattering
docs across files only helps if there's an obvious map to them.

Moves the CQRS/event-sourcing architecture note into AGENTS.md's
Architecture section (with a new domains/raw_events bullet) rather than
leaving it as prose in README, since AGENTS.md is the maintained
engineering reference and that's where a reader would already be looking
for how the domains are structured.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 17:34:58 -06:00
angel ed1c8363fd linked research document to README
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 5s
Tests / Go tests (push) Successful in 14s
2026-08-20 17:24:23 -06:00
angel c2f27b3b98 Make README.md badge a link.
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 21s
2026-08-20 17:17:24 -06:00
angelandClaude Sonnet 5 8b3e923c56 gitignore: ignore all .env.* files, not just .env
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 14s
.env.dev (used for local dev config, see AGENTS.md's DEV_AUTH_ENABLED
setup) was showing up as untracked instead of being ignored like .env.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:33:51 -06:00
angelandClaude Sonnet 5 308f16f6cc docs: add platform market research and weighted integration priority ranking
Research to inform which mock platform (Amazon, Shopify, etc.) is worth
turning into a real integration next, after Etsy. Adds GMV/seller-count
market-size data for every platform in domains/accounts/platform.go, a
weighted scoring model (GMV 45%, API/inventory-webhook completeness 30%,
growth 15%, integration cost 10%), and the resulting ranked order.

Also fills in Etsy's own row, sourced directly from
domains/platforms/etsy/generated_client rather than external docs since
it's the one platform actually live in this repo: Etsy Open API v3 has no
webhook/push mechanism at all, and only the OAuth connection flow is
built so far - polling-based order/inventory sync doesn't exist yet. Etsy
is included as a reference score (3.85/10) against the same rubric used
for the unbuilt platforms, mainly as a sanity check on the model rather
than a claim that building it first was wrong.

Flags that the existing capability table's BigCommerce row doesn't match
an actual platform in this codebase (Tiktok does, and is now researched
instead), and that Tiktok Shop's API details are unconfirmed pending a
dedicated research pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:23:47 -06:00
angelandClaude Sonnet 5 ede7555d43 auth: split dev-mode auth constructor and wire up dev-login/logout UI
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 13s
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-20 00:36:55 -06:00
angel d2d67b43c1 penpot design work summaries 2026-08-20 00:36:55 -06:00
angelandClaude Sonnet 5 f731947b51 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-20 00:36:55 -06:00
angelandClaude Sonnet 5 4c06d102bb 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-20 00:36:55 -06:00
angelandClaude Sonnet 5 73b85eb947 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-20 00:36:55 -06:00
angelandClaude Sonnet 5 707aa65ddc 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-20 00:36:55 -06:00
angel fe8c451739 stubbed CLAUDE.md and AGENTS.md 2026-08-20 00:36:55 -06:00
angel 655d40107a updated readme 2026-08-20 00:36:53 -06:00
angelandClaude Sonnet 5 c8c4204a63 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-20 00:35:22 -06:00
angelandClaude Sonnet 5 8af67a01d6 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-20 00:35:22 -06:00
angelandClaude Sonnet 5 4577c80e0c amazon: reconnect the LISTEN connection on failure instead of dying
A dropped connection, a Postgres restart, or any other error on the
dedicated LISTEN connection previously propagated all the way out of
ProcessEvents, and main.go's top-level shutdown logic treats that
error channel firing the same as a fatal server error - taking down
the entire application over a hiccup on one background connection that
has nothing to do with serving HTTP traffic. This matters more with
eleven other platforms already sharing the same trigger+notify shape
in the migrations with no Go processor yet.

Adds (*Mocks).reconnectOrStop: on a real failure (not an ordinary
shutdown), logs a warning and re-establishes LISTEN after a backoff
that starts at 1s, caps at 30s, doubles on repeated immediate
failures, and resets once a reconnect actually succeeds.
ProcessEvents' select no longer returns on a LISTEN error - it loops
back in with fresh channels instead.

Verified against a genuinely killed connection (pg_terminate_backend,
targeting the backend via pg_stat_activity matched on its LISTEN
query text), not a simulated one - both in a manual check and in the
new TestProcessEvents_ReconnectsAfterListenConnectionDrops test.

Closes out all four insights from the domains/amazon design review.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-20 00:35:22 -06:00
angelandClaude Sonnet 5 662b0ac3c9 amazon: make the poll fallback interval configurable and observable
ProcessEvents' safety-net poll was a bare time.Minute literal inline in
its select statement - no way to verify the fallback path works without
waiting 60+ seconds in a test, no way to tune the cadence without a code
change, and no way to tell afterward whether a given loop iteration was
triggered by a real NOTIFY or by the poll timer.

Adds Mocks.pollInterval (default time.Minute) and a WithPollInterval(d)
builder, mirroring WithNotifyRetryAfter's existing pattern - deploy-time
configurable via construction, not a live/API-adjustable knob, and not
wired through .env, matching how notifyRetryAfter already works.

Adds a Debug log line on each of the two meaningful wake-up branches so
which path fired is now observable.

New test proves the poll branch actually works without waiting or
touching the shared DB trigger (which would be unsafe against the real
dev DB): it reuses the retry mechanism from the previous commit so a
second dispatch can only come from the poll timer, since nothing else
ever notifies again for the rest of the test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-20 00:35:22 -06:00
angelandClaude Sonnet 5 984a4465f8 amazon: replace fire-and-forget listener notifications with ack-based retry
Dispatching an event to the listener previously happened from an
un-awaited goroutine, with only a log line on failure - once
processed_at was set, a dropped or failed notification was permanently
and silently lost, with no way to tell it had happened.

Replaces the processed/processed_successfully booleans with a
notified_at/processed_at pair (migration 000031): the dispatcher sets
notified_at and hands the event to MockEventListener.Notify, which now
also receives an ack callback the listener calls whenever it's truly
done, synchronously or arbitrarily later. Anything still "notified" but
unacked past notifyRetryAfter (a tunable field, not a stored
per-row timestamp) gets notified again on the dispatcher's normal
poll/reactive loop - no new retry mechanism needed. ack is idempotent,
since a late ack from an earlier attempt and one from a retry can both
eventually fire for the same event.

Dispatch is deferred until after the transaction that recorded
notified_at has actually committed, so ack's independent write can't
race a still-open transaction it implicitly depends on being visible.

The real SSE listener (server/sse/db_event_publisher.go) acks inline,
since its work is synchronous.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-20 00:35:22 -06:00
angelandClaude Sonnet 5 579eae5097 amazon: add a Ready() signal for ProcessEvents' LISTEN registration
Tests (and any other caller) previously had no way to know when the
Postgres LISTEN behind the reactive event-processing loop had actually
registered, forcing a guessed sleep before relying on it. Mocks now
exposes Ready() <-chan struct{}, closed once listenForNotifications
successfully issues LISTEN. Purely additive - ProcessEvents' signature
is unchanged.

Updates the integration tests to wait on Ready() instead of a flat
sleep, which also cut TestProcessEvents_ReactsToNotification's runtime
from ~0.25s to ~0.06s with no flakes across repeated runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-20 00:35:22 -06:00
angel 1899d76def store api research 2026-08-20 00:35:22 -06:00
angel 6a473b2ea0 Claude-assisted improvements (untested)
- dev auth flow (side-step OAuth)
- db event processing integration tests
- dev scripts (eg Makefile)
- db / test db migration setup scripts.
2026-08-20 00:35:22 -06:00
angel 50adc8e2de prototyped svg reports
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 5s
Tests / Go tests (push) Failing after 20s
2026-08-20 00:35:13 -06:00
angel 4748eda41e amazon events published as server side event 2026-08-20 00:35:13 -06:00
angel 16ecf2dc66 mock amazon event processing stubbed 2026-08-20 00:35:13 -06:00
angel f5a3d2594d bad logs
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 17s
2026-08-20 00:35:05 -06:00
angel 987386ea87 protoype: list raw events on mock mode reports page 2026-08-20 00:35:05 -06:00
angel df1b93261f mock events saved to db 2026-08-20 00:35:05 -06:00
angel 4386ede68e mock events db schema 2026-08-20 00:35:05 -06:00
angel c115ab8774 regenerated diagrams 2026-08-20 00:35:05 -06:00
angel b6c652bbf9 fixed mock mode button; shortened simulation tabs 2026-08-20 00:35:05 -06:00
angel 6945669e51 MockMode: made global in templates; increase bort of navbar in mock mode 2026-08-20 00:35:05 -06:00
angel ef7bd051fe stubbed simulations page content: set inventory 2026-08-20 00:35:05 -06:00
angel 1330d6efe2 stubbed simulations page content: refund 2026-08-20 00:35:05 -06:00
angel 10e0261fd2 stubbed simulations page content: sale 2026-08-20 00:35:05 -06:00
angel d4348b3ba9 add tests workflow badge to readme
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 5s
Tests / Go tests (push) Successful in 21s
2026-08-20 00:34:54 -06:00
angel 46a5d0ee55 initial gitea test workflow
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 17s
2026-08-20 00:23:17 -06:00
64 changed files with 3124 additions and 303 deletions
+60
View File
@@ -0,0 +1,60 @@
name: Claude Assistant for Gitea
on:
# Trigger on issue comments (works on both issues and pull requests in Gitea)
issue_comment:
types: [created]
# Trigger on issues being opened or assigned
issues:
types: [opened, assigned]
# Note: pull_request_review_comment has limited support in Gitea
# Use issue_comment instead which covers PR comments
jobs:
claude-assistant:
# Basic trigger detection - check for @claude in comments or issue body
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || github.event.action == 'assigned'))
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
# Note: Gitea Actions may not require id-token: write for basic functionality
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Claude Assistant
uses: markwylde/claude-code-gitea-action@gitea
with:
allowed_tools: |
penpot-self-hosted__execute_code
penpot-self-hosted__high_level_overview
penpot-self-hosted__penpot_api_info
penpot-self-hosted__export_shape
penpot_self_hosted__execute_code
penpot_self_hosted__high_level_overview
penpot_self_hosted__penpot_api_info
penpot_self_hosted__export_shape
gitea_api_url: "https://gitea.inventory-plus-plus.com/api/v1"
gitea_token: ${{ secrets.GITEA_TOKEN }} # Use standard workflow token
# anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Prefer claude_code_oauth_token over anthropic_api_key (cheaper!)
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
timeout_minutes: "60"
trigger_phrase: "@claude"
assignee_trigger: "@claude"
# Needed due to bug: https://github.com/anthropics/claude-code-action/issues/1416
model: "claude-sonnet-5"
# Optional: Customize for Gitea environment
custom_instructions: |
You are working in a Gitea environment. Be aware that:
- Some GitHub Actions features may behave differently
- Focus on core functionality and avoid advanced GitHub-specific features
- Use standard git operations when possible
env:
GITEA_SERVER_URL: "https://gitea.inventory-plus-plus.com"
+28
View File
@@ -0,0 +1,28 @@
name: Tests
run-name: tests on ${{ gitea.ref }}
on: [push]
jobs:
Go tests:
runs-on: ubuntu-latest
steps:
- name: Description
run: |
echo "🧑‍🔬 $ {{ gitea.actor }} pushed branch ${{ gitea.ref }} ...00101010 ... beginning tests"
- name: Check out repository code
uses: actions/checkout@v4
- name: Update
run: |
echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner."
echo "🖥️ The job is now ready to test your go code on the runner."
- name: Setup golang environment
uses: actions/setup-go@v7
with:
go-version: 'stable'
check-latest: true
token: ${{ gitea.token }}
permissions:
contents: read
- name: Run go tests
run: |
go test ./...
+4
View File
@@ -2,3 +2,7 @@
*.swp *.swp
node_modules node_modules
.env
.env.*
.env.example
+10
View File
@@ -0,0 +1,10 @@
{
"mcpServers": {
"penpot-self-hosted": {
"description": "Set here, even though set globally, so it is also available for the gitea-claude action bot",
"type": "http",
"url": "https://penpot.inventory-plus-plus.com/mcp/stream?userToken=${PENPOT_MCP_USER_TOKEN}"
}
}
}
+282
View File
@@ -0,0 +1,282 @@
# 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.
## Platform integration priority
When picking the next mock platform to turn into a real integration (after
Etsy), use this weighted ranking of the 12 not-yet-live mock platforms. It
scores each on GMV/market opportunity (45%), API/inventory-sync completeness
(30%), growth trajectory (15%), and integration cost (10%) - see
`STORE_API_RESEARCH.md`'s "Weighted priority ranking" section for the full
scoring table, per-criterion reasoning, and the market-size/API research
it's built on.
1. **Shopify** (9.15/10) - cleanest/most complete API (webhooks for both
order and inventory events, modern GraphQL), still-strong GMV growth
(+29-35% YoY), and a proven self-serve distribution channel (the Shopify
App Store, where inventory-sync apps are an established category) that
no other platform here has an equivalent of.
2. **Amazon** (8.85/10) - largest raw dollar opportunity (~$575B in 2025
third-party GMV), and inventory-sync pain (suppressed listings, FBA
stranded-inventory fees) is one of the sharpest problems this tool could
solve there. `domains/amazon` already has the most real infrastructure
of any mock platform (the `ProcessEvents` LISTEN/NOTIFY loop, SSE
wiring), so a real SP-API integration reuses that instead of starting
from zero. Scores lower than Shopify mainly because SP-API auth (LWA +
AWS SigV4) and rate limiting are the most complex of any platform here,
and seller growth has gone flat.
3. **Tiktok Shop** (7.15/10) - hypergrowth (global GMV nearly doubled in
2025, projected to double again in 2026) is what earns it this spot, but
its API details are the least confirmed of anything researched -
`partner.tiktokshop.com` didn't yield readable docs during this pass.
Needs a dedicated research pass to confirm exact webhook/endpoint names
before treating this ranking as actionable.
4. **Walmart Marketplace** (6.35/10) - smaller in absolute GMV than the top
three, but growing ~50% YoY and a natural second-marketplace target for
sellers already on Amazon; full order+inventory webhook coverage.
5. **Wix** (6.35/10) - tied with Walmart on score but behind it on the
growth tiebreaker; full inventory-webhook coverage and an easy API, just
a smaller/less certain GMV number.
6. **WooCommerce** (5.95/10) - large store count (~4-6M) but no
platform-wide GMV figure exists (self-hosted plugin, no central ledger),
and no dedicated inventory webhook (relies on a `product.updated` proxy).
7. **Square Online** (5.90/10) - full inventory-webhook coverage and
well-documented APIs (shared with the rest of Square's product line),
but its ecommerce-specific GMV can't be isolated from Square's much
larger in-person POS business.
8. **Ebay** (5.75/10) - most sellers of any platform here (18.3M) but the
lowest GMV-per-seller by far, plus no dedicated inventory-change webhook.
9. **Squarespace** (4.75/10) - smaller GMV, no inventory webhook.
10. **Zoho** (3.85/10) - tiny confirmed store count (~2,196 globally).
11. **Ecwid** (3.20/10) - shrinking (-20% YoY store count).
12. **Big Cartel** (1.50/10) - last, and not close: no inventory API or
webhook at all is a structural dealbreaker for this tool's core use
case, independent of its (also declining, -41% YoY) market size.
(BigCommerce, which appears in the API-capability table in
`STORE_API_RESEARCH.md`, is excluded here - it isn't one of this codebase's
actual mock platforms; that table predates the platform list settling on
Tiktok instead.)
Etsy isn't in the ranking above (it's already built), but scores 3.85/10 as
a reference point if run through the same rubric - mid-pack-and-declining
GMV, and the *only* platform researched with zero webhook/push support of
any kind (order or inventory - confirmed directly against
`domains/platforms/etsy/generated_client`, not external docs). That's not a
retroactive case against having built Etsy - it was presumably chosen for
reasons this rubric doesn't score (an existing relationship, an easier path
to developer credentials) - but it's a reminder that the lack of any
webhook system makes real order/inventory sync a polling loop, which is
exactly the kind of integration cost this rubric undercounts once a
platform is more than superficially wired up. Note also that the OAuth
connection flow is the only part of Etsy that's actually built so far
(`domains/platforms/etsy/etsy.go`) - polling-based receipt/inventory sync
against the live API doesn't exist yet.
## 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/raw_events` - the [event-sourcing](https://martinfowler.com/eaaDev/EventSourcing.html)
store: `Save` appends an `Event`, `LoadEventsForStore` replays a
platform+store's series. Other domains (`reports`,
`domains/accounts/mocks.go`) read from views/queries that project over
this event series rather than mutating their own standalone state. See
also the [CQRS](https://martinfowler.com/bliki/CQRS.html) note above.
- `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.
## Gitea
- This project is hosted on a self-hosted **Gitea** instance at
`gitea.inventory-plus-plus.com` (repo:
`angel/inventory-plus-plus`), not GitHub - the `origin` remote points at
it over SSH. Issues, pull requests, wiki, and CI (Gitea Actions - see the
Tests badge at the top of `README.md`) all live there rather than on
GitHub, even though the tooling/workflow (Actions YAML, PR-based review)
looks GitHub-shaped.
- A Gitea MCP server is available in agent sessions (tools prefixed
`mcp__angel__...` - e.g. `issue_read`/`issue_write`,
`pull_request_read`/`pull_request_write`,
`list_pull_requests`/`list_issues`, `list_branches`, `wiki_read`/
`wiki_write`) for reading/managing issues, PRs, branches, releases, and
the wiki without shelling out to `git`/`gh`. There is no GitHub CLI
(`gh`) equivalent here - use these MCP tools or `git` directly instead.
- The Gitea MCP server has **no Projects API** - it cannot read or modify
Gitea Project boards. Don't attempt to automate Project-board changes
(e.g. moving an issue between columns) through it; that has to be done
manually in the Gitea UI.
## 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
+55
View File
@@ -0,0 +1,55 @@
.DEFAULT_GOAL := dev
-include .env
export
MIGRATE := migrate -path database_migrations -database "$(DATABASE_URL)"
MIGRATE_TEST := migrate -path database_migrations -database "$(TEST_DATABASE_URL)"
.PHONY: dev migrate-up migrate-down migrate-version tailwind run \
test test-against-dev-db migrate-test-up migrate-test-down migrate-test-version
# applies pending migrations, starts the tailwind watcher in the background,
# then runs the server in the foreground. Ctrl-C stops both.
dev: migrate-up
@./tailwind.sh & \
TW_PID=$$!; \
trap "kill $$TW_PID 2>/dev/null" EXIT INT TERM; \
go run .
migrate-up:
$(MIGRATE) up
migrate-down:
$(MIGRATE) down 1
migrate-version:
$(MIGRATE) version
tailwind:
./tailwind.sh
run:
go run .
# runs the integration test suite against TEST_DATABASE_URL (the dedicated
# inventory_2_test database by default). Applies pending migrations there
# first.
test: migrate-test-up
go test ./...
# same as `test`, but points TEST_DATABASE_URL at the real dev database for
# this run instead of the dedicated test database - useful for checking
# behavior against real data. Leaves the dev DB in whatever state the tests'
# own cleanup produces; it's still your dev data, treat it accordingly.
test-against-dev-db: TEST_DATABASE_URL := $(DATABASE_URL)
test-against-dev-db: test
migrate-test-up:
$(MIGRATE_TEST) up
migrate-test-down:
$(MIGRATE_TEST) down 1
migrate-test-version:
$(MIGRATE_TEST) version
+63 -94
View File
@@ -1,99 +1,79 @@
# V2 - Attempt 2 ## Inventory++ [![Tests Passing?](https://gitea.inventory-plus-plus.com/angel/inventory-plus-plus/actions/workflows/tests.yaml/badge.svg?branch=master)](https://gitea.inventory-plus-plus.com/angel/inventory-plus-plus/actions?workflow=&scoped_workflow_source_repo_id=0&actor=0&status=0&branch=master)
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.
# Roadmap ## Where things live
- [ ] Etsy (WIP) Documentation is split up rather than kept in one big doc - start here:
- [ ] GET ETSY AUTH (WIP)
- [x] move auth state stuff to database (out of cache)
- [x] only generate a sign up link IF they click the link on the accounts page
- [ ] get api key approved
- [ ] automatically clean up access tokens and state when expired
- [ ] access tokens
- [ ] state
- [?] Get new access token using refresh token flow
- [ ] make a FK between the etsy_store_events table and etsy_users table (store_id columns don't match types)
- [ ] Auth0
- [ ] get off dev api key?
- [x] Get new access token using refresh token flow
- [ ] test
- [x] when token is expired, redirect them to the login page, then redirect them back to where they were heading to.
- [ ] Social connections login
- [x] automatically clean up access tokens and state when expired
- [x] access tokens
- [x] state
- [ ] Complete this design document?
- [ ] Complete defining this roadmap checklist
- [ ] Website displaying an audit of store events
- [ ] Start with just a list of events for a given store (use a static test store)
- [ ] ...
- [ ] Dark mode
- [ ] don't let a listing be in multiple sync groups
- [ ] Next stores on the list (at least hypothetically)
- Shopify
- WooCommerce
- BigCommerce
- Wix
- Squarespace
- Square Online
- Zoho
- Ecwid
- Big Cartel
Bigger marketplaces:
- Amazon
- Walmart Marketplace
- Ebay
## Nice to haves - **[`AGENTS.md`](./AGENTS.md)** - the engineering reference: setup, `make` commands, architecture, database/migration gotchas, testing conventions, auth, and the researched platform-integration priority ranking (which platform to build next, and why).
- **[`ROADMAP.md`](./ROADMAP.md)** - in-progress and planned work, as a checklist.
- **[`research/platforms.md`](./research/platforms.md)** - the underlying API-capability and market-size research the platform-priority ranking is based on.
- **[`domains/platforms/etsy/COMPLIANCE.md`](./domains/platforms/etsy/COMPLIANCE.md)** - Etsy API usage obligations to keep satisfied when touching the Etsy integration.
- **[`diagrams/`](./diagrams)** - architecture and schema diagrams, linked from the Diagrams section below.
- [ ] Drop in a good logger This project is hosted on a self-hosted [Gitea](https://gitea.inventory-plus-plus.com/angel/inventory-plus-plus) instance (not GitHub) - issues, pull requests, CI (the Tests badge above), and the wiki all live there. See `AGENTS.md`'s Gitea section for details, including the Gitea MCP server tools available to AI agents.
- [ ] log all errors caught by the http server
## Constraints
- [ ] Etsy
- [ ] API Licensed Uses and Restrictions:
- [ ] Link directly back to the product information and/or image Content on Etsy, where the Application utilizes product information and/or images.
- [ ] Provide a prominently displayed email address on Your Application for third parties to contact You with any questions or issues. You shall respond to such inquiries in a timely manner.
- [ ] Use commercially reasonable efforts to provide a terms of service and privacy policy in a visible location on your Application.
- [ ] Display item Content or product information and/or images which is more than six (6) hours older than such information is on the Website, and other Etsy Content cannot be more than twenty-four (24) hours older than such Content on the Website.
- [ ] Use the API in a manner that exceeds reasonable request volume or constitutes excessive or abusive usage. Users are allocated by default, 10,000 calls per day.
- [ ] You shall not use or alter any text, logos, Etsy's Trademarks, Etsy's signature colors, Etsy's layout, or a confusingly similar layout to Etsy's layout in such a way which may suggest endorsement or affiliation by Etsy.
- [ ] Any use of the Etsy logo or Etsy's Trademarks must be used in its entirety and must not be altered or used in a misleading way.
- [ ] You shall not use a mark which is confusingly similar to Etsy's Trademarks.
- [ ] Any use of the Etsy logo or Etsy's Trademarks in Your Application shall be less prominent than the logo or mark that primarily describes the Application and Your use of the Etsy logo shall not imply any endorsement or affiliation by Etsy.
- [ ] You may publicize, issue press or blog releases of Your Application only if You state that it was created using the Etsy API and that You in no way imply that Your Application is endorsed or certified by Etsy.
- [ ] You must place or display the following notice prominently on Your Application:
"The term 'Etsy' is a trademark of Etsy, Inc. This application uses the Etsy API but is not endorsed or certified by Etsy, Inc."
- [ ] Immediately report any security deficiencies You discover to Etsy by emailing developer@etsy.com.
Looking to follow the [CQRS](https://martinfowler.com/bliki/CQRS.html) pattern. ## Deploying
The database will follow the [event sourcing](https://martinfowler.com/eaaDev/EventSourcing.html) database pattern. The application runs locally, from this directory.
All events (or commands) will be stored in a respective event series, and all database reads will be from views that are projections, reductions, aggregations of those event series. It is deployed simply by running either from the root of the project,
```
make run
```
or
```
go run .
```
See `AGENTS.md` for the full setup/`make` command reference (migrations, tests, dev auth, etc).
## Application/directory structure ## Technology
- /internal
- /site: website
- /webhooks: webhooks for platform events
- /domains: packages for each domain
- /store_events: storing and events
- /platforms: ecommerce platform domains
- /tiktok: interface with tiktok
- ... etc
- ... etc
![Application structure](./diagrams/application_structure.svg) ### Languages
## Website hierarchy #### Server side
Golang, Go Templates
- /site #### 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.
# Architectural and Software Diagrams # Architectural and Software Diagrams
## Application structure
See `AGENTS.md`'s Architecture section for the current, maintained breakdown of packages/directories.
![Application structure](./diagrams/application_structure.svg)
## Database schemas ## Database schemas
**public** **public**
@@ -105,39 +85,28 @@ All events (or commands) will be stored in a respective event series, and all da
## Events ## Events
`domains/raw_events` is the event-sourcing store behind this - see `AGENTS.md`'s Architecture section.
### Event Sourcing Architecture ### Event Sourcing Architecture
![Event sourcing architecture](./diagrams/event_sourcing.svg) ![Event sourcing architecture](./diagrams/event_sourcing.svg)
### Event Structure ### Event Structure
![Event](./diagrams/event.svg) ![Event](./diagrams/event.svg)
### Store Event Database Tables ### Store Event Database Tables
![Event database tables](./diagrams/event_tables.svg) ![Event database tables](./diagrams/event_tables.svg)
## Platform: Etsy ## Platform: Etsy
### Signing up ### Signing up
**TODO: need an account page that can create accounts ahead of time - force users to create an account first!**
![Event database tables](./diagrams/etsy/obtaining_access_token.svg) ![Event database tables](./diagrams/etsy/obtaining_access_token.svg)
***TODO: create a page that will take billing information and include it in this process***
### Getting a new refresh token
***TODO***
### Models ### Models
![Models](./diagrams/etsy/models.svg) ![Models](./diagrams/etsy/models.svg)
See `ROADMAP.md` for what's still unbuilt in the Etsy flow (an account-creation page ahead of OAuth, billing info collection, refresh-token handling), and `domains/platforms/etsy/COMPLIANCE.md` for API usage obligations.
## All Diagrams ## All Diagrams
+38
View File
@@ -0,0 +1,38 @@
# Roadmap
- [ ] Etsy (WIP)
- [ ] GET ETSY AUTH (WIP)
- [x] move auth state stuff to database (out of cache)
- [x] only generate a sign up link IF they click the link on the accounts page
- [ ] get api key approved
- [ ] automatically clean up access tokens and state when expired
- [ ] access tokens
- [ ] state
- [ ] account-creation page ahead of OAuth (force users to create an account before connecting a store)
- [ ] collect billing information as part of that account-creation flow
- [?] Get new access token using refresh token flow
- [ ] make a FK between the etsy_store_events table and etsy_users table (store_id columns don't match types)
- [ ] build polling-based order/inventory sync against the live Etsy API - only the OAuth connection flow is implemented so far (see `research/platforms.md`'s Etsy notes: API v3 has no webhook/push system, so this has to be a poll loop)
- see `domains/platforms/etsy/COMPLIANCE.md` for Etsy API usage obligations to keep satisfied along the way
- [ ] Auth0
- [ ] get off dev api key?
- [x] Get new access token using refresh token flow
- [ ] test
- [x] when token is expired, redirect them to the login page, then redirect them back to where they were heading to.
- [ ] Social connections login
- [x] automatically clean up access tokens and state when expired
- [x] access tokens
- [x] state
- [ ] Complete this design document?
- [ ] Complete defining this roadmap checklist
- [ ] Website displaying an audit of store events
- [ ] Start with just a list of events for a given store (use a static test store)
- [ ] ...
- [ ] Dark mode
- [ ] don't let a listing be in multiple sync groups
- [ ] Next platform to build out beyond Etsy - see `AGENTS.md`'s "Platform integration priority" section for the researched, weighted ranking (currently: Shopify, then Amazon, then Tiktok Shop) instead of picking from the raw platform list
## Nice to haves
- [ ] Drop in a good logger
- [ ] log all errors caught by the http server
+128
View File
@@ -0,0 +1,128 @@
// Package config loads application configuration from environment variables,
// optionally populated from a .env file in the working directory.
package config
import (
"flag"
"fmt"
"os"
"strconv"
"github.com/joho/godotenv"
)
type Config struct {
DatabaseURL string
Auth0Domain string
Auth0ClientID string
Auth0ClientSecret string
Auth0CallbackURL string
EtsyAPIKeystring string
EtsyAPISharedSecret string
// DevAuthEnabled, when true, exposes a route that mints a local
// 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) {
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 (
cfg Config
missing []string
)
required := func(key string) string {
v := os.Getenv(key)
if v == "" {
missing = append(missing, key)
}
return v
}
cfg.DatabaseURL = required("DATABASE_URL")
// 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 {
return Config{}, fmt.Errorf("invalid DEV_AUTH_ENABLED value %q: %w", raw, err)
}
cfg.DevAuthEnabled = devAuthEnabled
}
// With DEV_AUTH_ENABLED, real Auth0 login/logout never runs (dev-login
// mints sessions locally instead), so these are unused and optional.
if cfg.DevAuthEnabled {
cfg.Auth0Domain = os.Getenv("AUTH0_DOMAIN")
cfg.Auth0ClientID = os.Getenv("AUTH0_CLIENT_ID")
cfg.Auth0ClientSecret = os.Getenv("AUTH0_CLIENT_SECRET")
cfg.Auth0CallbackURL = os.Getenv("AUTH0_CALLBACK_URL")
} else {
cfg.Auth0Domain = required("AUTH0_DOMAIN")
cfg.Auth0ClientID = required("AUTH0_CLIENT_ID")
cfg.Auth0ClientSecret = required("AUTH0_CLIENT_SECRET")
cfg.Auth0CallbackURL = required("AUTH0_CALLBACK_URL")
}
cfg.EtsyAPIKeystring = required("ETSY_API_KEYSTRING")
cfg.EtsyAPISharedSecret = required("ETSY_API_SHARED_SECRET")
portStr := required("PORT")
if len(missing) > 0 {
return Config{}, fmt.Errorf(
"missing required environment variables: %v (see .env.example)",
missing,
)
}
if port, err := strconv.Atoi(portStr); err != nil {
return Config{}, fmt.Errorf("invalid port number: %w", err)
} else {
cfg.Port = port
}
return cfg, nil
}
func checkEnvFlag() (dev bool, err error) {
switch env := *envFlagPtr; env {
case "dev":
return true, nil
case "prd":
return false, nil
case "":
return false, fmt.Errorf("no env flag specified: must be one of dev, prd")
default:
return false, fmt.Errorf("invalid env flag specified: must be one of dev, prd: %q", env)
}
}
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
func newPool(ctx context.Context) (*pgxpool.Pool, error) { func newPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
pool, err := pgxpool.New(ctx, "postgres://app_client:app_password@localhost:5432/inventory_2?sslmode=disable") pool, err := pgxpool.New(ctx, databaseURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create database client: %w", err) return nil, fmt.Errorf("failed to create database client: %w", err)
} }
@@ -1,6 +1,6 @@
CREATE TABLE oauth_login_states ( CREATE TABLE oauth_login_states (
state BYTEA NOT NULL, state BYTEA NOT NULL,
expiration TIMESTAMPTZ NOT NULL DEFAULT (NOW() + 10 'minute'), expiration TIMESTAMPTZ NOT NULL DEFAULT (NOW() + '10 minutes'::interval),
PRIMARY KEY (state) PRIMARY KEY (state)
); );
@@ -0,0 +1,14 @@
BEGIN;
ALTER TABLE oauth_tokens
ALTER COLUMN id_token_custom_claims_updated_at
TYPE TEXT
USING id_token_custom_claims_updated_at::TEXT;
ALTER TABLE oauth_tokens
ADD COLUMN claims JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE oauth_tokens
ALTER COLUMN claims DROP DEFAULT;
COMMIT;
@@ -0,0 +1,16 @@
BEGIN;
-- Reconciles oauth_tokens with the schema the application code actually
-- expects. Neither of these two changes was ever committed as a migration
-- despite being applied by hand at some point - see the "claims" column
-- below, which no application code reads or writes.
ALTER TABLE oauth_tokens
DROP COLUMN claims;
ALTER TABLE oauth_tokens
ALTER COLUMN id_token_custom_claims_updated_at
TYPE TIMESTAMPTZ
USING id_token_custom_claims_updated_at::TIMESTAMPTZ;
COMMIT;
@@ -0,0 +1,18 @@
BEGIN;
ALTER TABLE mock.shop_amazon_events
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
UPDATE mock.shop_amazon_events
SET processed = (processed_at IS NOT NULL),
processed_successfully = (processed_at IS NOT NULL);
DROP INDEX IF EXISTS mock.shop_amazon_events_by_timestamp_unprocessed;
CREATE INDEX shop_amazon_events_by_timestamp_unprocessed ON mock.shop_amazon_events (shop_id, event_timestamp) WHERE NOT processed;
ALTER TABLE mock.shop_amazon_events
DROP COLUMN notified_at,
DROP COLUMN processed_at;
COMMIT;
@@ -0,0 +1,29 @@
BEGIN;
-- Replaces the boolean processed/processed_successfully pair with a
-- three-state model driven by two nullable timestamps:
-- unprocessed: notified_at IS NULL
-- notified: notified_at IS NOT NULL AND processed_at IS NULL
-- processed: processed_at IS NOT NULL
-- The dispatcher sets notified_at each time it hands an event to the
-- listener (initially, and again on retry if the listener never acks).
-- Only the listener's ack, via its own timestamped write, sets
-- processed_at - it's a separate write specifically so it never races the
-- transaction that recorded notified_at.
ALTER TABLE mock.shop_amazon_events
ADD COLUMN notified_at TIMESTAMPTZ,
ADD COLUMN processed_at TIMESTAMPTZ;
UPDATE mock.shop_amazon_events
SET processed_at = NOW()
WHERE processed;
ALTER TABLE mock.shop_amazon_events
DROP COLUMN processed,
DROP COLUMN processed_successfully;
DROP INDEX IF EXISTS mock.shop_amazon_events_by_timestamp_unprocessed;
CREATE INDEX shop_amazon_events_by_timestamp_unprocessed ON mock.shop_amazon_events (shop_id, event_timestamp) WHERE processed_at IS NULL;
COMMIT;
-2
View File
@@ -1468,8 +1468,6 @@ func (db *Store) SetListingInListingInMockSyncGroupBeingEdited(ctx context.Conte
default: default:
return fmt.Errorf("unexpected number of rows affected: %d", n) return fmt.Errorf("unexpected number of rows affected: %d", n)
} }
return nil
} }
return consts.ErrNotFound return consts.ErrNotFound
+103
View File
@@ -0,0 +1,103 @@
package accounts_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"ruben/inventory2/consts"
"ruben/inventory2/domains/accounts"
"ruben/inventory2/internal/testdb"
)
func TestCreateAccount(t *testing.T) {
pool := testdb.Pool(t)
store := accounts.NewStore(testdb.Logger(), pool)
ctx := context.Background()
userID := testdb.NewUserID(t)
testdb.SeedOAuthUser(t, pool, userID)
email := userID + "@example.com"
acct, err := store.CreateAccount(ctx, userID, email)
require.NoError(t, err, "CreateAccount()")
t.Cleanup(func() {
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID)
})
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)
require.NoError(t, err, "GetAccount()")
assert.Equal(t, acct, got, "GetAccount()")
}
func TestCreateAccount_DuplicateUserIsConflict(t *testing.T) {
pool := testdb.Pool(t)
store := accounts.NewStore(testdb.Logger(), pool)
ctx := context.Background()
userID := testdb.NewUserID(t)
testdb.SeedOAuthUser(t, pool, userID)
acct, err := store.CreateAccount(ctx, userID, userID+"@example.com")
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")
require.ErrorIs(t, err, consts.ErrConflict, "second CreateAccount()")
}
func TestGetAccount_NotFound(t *testing.T) {
pool := testdb.Pool(t)
store := accounts.NewStore(testdb.Logger(), pool)
ctx := context.Background()
_, err := store.GetAccount(ctx, -1)
require.ErrorIs(t, err, consts.ErrNotFound, "GetAccount()")
}
func TestGetUserAndAccountByAccessToken(t *testing.T) {
pool := testdb.Pool(t)
store := accounts.NewStore(testdb.Logger(), pool)
ctx := context.Background()
userID := testdb.NewUserID(t)
accessToken := testdb.SeedOAuthSession(t, pool, userID)
// before an account exists: user resolves, account does not.
user, acct, err := store.GetUserAndAccountByAccessToken(ctx, accessToken)
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")
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)
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")
}
}
func TestGetUserAndAccountByAccessToken_UnknownToken(t *testing.T) {
pool := testdb.Pool(t)
store := accounts.NewStore(testdb.Logger(), pool)
ctx := context.Background()
_, _, err := store.GetUserAndAccountByAccessToken(ctx, "no-such-token-"+testdb.NewUserID(t))
require.ErrorIs(t, err, consts.ErrNotFound, "GetUserAndAccountByAccessToken()")
}
+197 -26
View File
@@ -7,6 +7,7 @@ import (
"ruben/inventory2/domains/accounts" "ruben/inventory2/domains/accounts"
"ruben/inventory2/domains/raw_events" "ruben/inventory2/domains/raw_events"
"ruben/inventory2/logging" "ruben/inventory2/logging"
"sync"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -18,21 +19,59 @@ type (
log *logging.Logger log *logging.Logger
db *pgxpool.Pool db *pgxpool.Pool
listener MockEventListener listener MockEventListener
// notifyRetryAfter is how long an event can sit "notified" (handed
// to the listener) without an ack before the dispatcher notifies
// it again. Deliberately a field, not a stored notify_again_at
// column, so the cadence can vary (or be tuned in tests) without
// touching any row.
notifyRetryAfter time.Duration
// pollInterval is the fallback cadence ProcessEvents' main loop
// checks for unprocessed/retry-due events on its own, independent
// of Postgres NOTIFY - a safety net for events that end up in
// mock.shop_amazon_events without ever going through the
// mock.raw_shop_events insert+trigger path that fires NOTIFY.
pollInterval time.Duration
ready chan struct{}
readyOnce sync.Once
} }
// MockEventListener is handed events as they arrive. Notify should
// return promptly - any real work (including calling out to another
// system) can happen after it returns - but it must eventually call
// ack exactly once, when the event has been fully handled. Until ack
// is called, the dispatcher considers the event merely "notified"
// and will call Notify again after notifyRetryAfter, so ack may be
// called more than once total across retries; only the first call
// has any effect; further calls are safe (see (*Mocks).ack).
MockEventListener interface { MockEventListener interface {
Notify(context.Context, raw_events.Event) error Notify(ctx context.Context, e raw_events.Event, ack func(context.Context) error) error
} }
) )
const ( const (
eventChannelName = "mock_shop_amazon_event_inserted" eventChannelName = "mock_shop_amazon_event_inserted"
defaultNotifyRetryAfter = 30 * time.Second
defaultPollInterval = time.Minute
// backoff for reconnecting the LISTEN connection after it fails for a
// reason other than shutdown (e.g. a dropped connection, a Postgres
// restart). Doubles on each consecutive failure, capped, and resets
// once a reconnect actually succeeds.
initialListenReconnectBackoff = 1 * time.Second
maxListenReconnectBackoff = 30 * time.Second
) )
func NewMocks(log *logging.Logger, db *pgxpool.Pool) *Mocks { func NewMocks(log *logging.Logger, db *pgxpool.Pool) *Mocks {
return &Mocks{ return &Mocks{
log: log, log: log,
db: db, db: db,
notifyRetryAfter: defaultNotifyRetryAfter,
pollInterval: defaultPollInterval,
ready: make(chan struct{}),
} }
} }
@@ -41,8 +80,33 @@ func (m *Mocks) SetListener(l MockEventListener) *Mocks {
return m return m
} }
// WithNotifyRetryAfter overrides how long an event can sit "notified"
// without an ack before being notified again. Mainly useful for tests that
// don't want to wait out the default.
func (m *Mocks) WithNotifyRetryAfter(d time.Duration) *Mocks {
m.notifyRetryAfter = d
return m
}
// WithPollInterval overrides how often ProcessEvents checks for
// unprocessed/retry-due events on its own, independent of NOTIFY. Mainly
// useful for tests that don't want to wait out the default.
func (m *Mocks) WithPollInterval(d time.Duration) *Mocks {
m.pollInterval = d
return m
}
// Ready returns a channel that's closed once ProcessEvents has registered
// its Postgres LISTEN and is actively watching for notifications. Callers
// that need to know the reactive path is live - tests in particular -
// should wait on this instead of guessing with a sleep.
func (m *Mocks) Ready() <-chan struct{} {
return m.ready
}
func (m *Mocks) ProcessEvents(ctx context.Context) error { func (m *Mocks) ProcessEvents(ctx context.Context) error {
notifCh, errCh := m.listenForNotifications(ctx) notifCh, errCh := m.listenForNotifications(ctx)
backoff := initialListenReconnectBackoff
for { for {
m.log.Debug("processing events") m.log.Debug("processing events")
@@ -56,17 +120,65 @@ func (m *Mocks) ProcessEvents(ctx context.Context) error {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return nil return nil
case err := <-errCh: case err := <-errCh:
return err var stop bool
if notifCh, errCh, backoff, stop = m.reconnectOrStop(ctx, err, backoff); stop {
return nil
}
case _, ok := <-notifCh: case _, ok := <-notifCh:
if !ok { if !ok {
return <-errCh var stop bool
if notifCh, errCh, backoff, stop = m.reconnectOrStop(ctx, <-errCh, backoff); stop {
return nil
}
continue
} }
case <-time.After(time.Minute): m.log.Debug("woke up: notification received")
case <-time.After(m.pollInterval):
m.log.Debug("woke up: poll interval elapsed")
} }
} }
} }
// reconnectOrStop handles a LISTEN-connection failure. A nil listenErr (or
// ctx already being done) means this is an ordinary shutdown, not a
// failure - stop is true and the caller should return. Otherwise it logs a
// warning, waits out backoff, and re-establishes LISTEN: on success the
// backoff resets to its initial value for next time; on immediate failure
// (e.g. the pool itself is unreachable) it doubles, capped, so repeated
// failures back off rather than hot-looping.
func (m *Mocks) reconnectOrStop(
ctx context.Context,
listenErr error,
backoff time.Duration,
) (notifCh <-chan struct{}, errCh <-chan error, nextBackoff time.Duration, stop bool) {
if listenErr == nil || ctx.Err() != nil {
return nil, nil, backoff, true
}
m.log.Warn("lost connection while listening for notifications; reconnecting", "error", listenErr, "retry_in", backoff)
select {
case <-ctx.Done():
return nil, nil, backoff, true
case <-time.After(backoff):
}
notifCh, errCh = m.listenForNotifications(ctx)
if notifCh != nil {
return notifCh, errCh, initialListenReconnectBackoff, false
}
nextBackoff = backoff * 2
if nextBackoff > maxListenReconnectBackoff {
nextBackoff = maxListenReconnectBackoff
}
return notifCh, errCh, nextBackoff, false
}
func (m *Mocks) listenForNotifications(ctx context.Context) (<-chan struct{}, <-chan error) { func (m *Mocks) listenForNotifications(ctx context.Context) (<-chan struct{}, <-chan error) {
errCh := make(chan error, 1) errCh := make(chan error, 1)
@@ -83,6 +195,8 @@ func (m *Mocks) listenForNotifications(ctx context.Context) (<-chan struct{}, <-
return nil, errCh return nil, errCh
} }
m.readyOnce.Do(func() { close(m.ready) })
ch := make(chan struct{}) ch := make(chan struct{})
go func() (err error) { go func() (err error) {
@@ -103,17 +217,29 @@ func (m *Mocks) listenForNotifications(ctx context.Context) (<-chan struct{}, <-
return fmt.Errorf("error occurred while waiting for the next notification: %w", err) return fmt.Errorf("error occurred while waiting for the next notification: %w", err)
} }
ch <- struct{}{} select {
case ch <- struct{}{}:
case <-ctx.Done():
return nil
}
} }
}() }()
return ch, errCh return ch, errCh
} }
// processUnprocessedEvents finds every event that's either brand new or
// has been "notified" for longer than notifyRetryAfter without an ack, and
// (re)dispatches each to the listener. It never blocks on the listener:
// notified_at is committed first, and only then - after the transaction
// that recorded it has actually committed - is the listener told, from a
// goroutine that isn't tied to this function's lifetime.
func (m *Mocks) processUnprocessedEvents(ctx context.Context) error { func (m *Mocks) processUnprocessedEvents(ctx context.Context) error {
for done := false; !done; { for done := false; !done; {
var toDispatch []raw_events.Event
err := pgx.BeginFunc(ctx, m.db, func(tx pgx.Tx) error { err := pgx.BeginFunc(ctx, m.db, func(tx pgx.Tx) error {
rows, err := m.db.Query( rows, err := tx.Query(
ctx, ctx,
` `
SELECT SELECT
@@ -123,12 +249,16 @@ func (m *Mocks) processUnprocessedEvents(ctx context.Context) error {
FROM FROM
mock.shop_amazon_events mock.shop_amazon_events
WHERE WHERE
NOT processed processed_at IS NULL
AND (notified_at IS NULL OR notified_at < @retry_after)
ORDER BY ORDER BY
shop_id, event_timestamp shop_id, event_timestamp
LIMIT LIMIT
100 100
`, `,
pgx.NamedArgs{
"retry_after": time.Now().Add(-m.notifyRetryAfter),
},
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to to perform query: %w", err) return fmt.Errorf("failed to to perform query: %w", err)
@@ -144,18 +274,22 @@ func (m *Mocks) processUnprocessedEvents(ctx context.Context) error {
} }
for _, e := range evts { for _, e := range evts {
if err := m.processEvent(ctx, tx, raw_events.Event{ ev := raw_events.Event{
Platform: string(accounts.Amazon), Platform: string(accounts.Amazon),
StoreID: e.Shop_id, StoreID: e.Shop_id,
EventID: e.Event_id, EventID: e.Event_id,
EventTimestamp: e.Event_timestamp, EventTimestamp: e.Event_timestamp,
}); err != nil {
return fmt.Errorf("failed to process event: %w", err)
} }
if err := markNotified(ctx, tx, ev); err != nil {
return fmt.Errorf("failed to mark event notified: %w", err)
}
toDispatch = append(toDispatch, ev)
} }
if done = len(evts) == 0; !done { if done = len(evts) == 0; !done {
m.log.Infof("processed %d events", len(evts)) m.log.Infof("notified listener for %d events", len(evts))
} }
return nil return nil
@@ -163,22 +297,42 @@ func (m *Mocks) processUnprocessedEvents(ctx context.Context) error {
if err != nil { if err != nil {
return err return err
} }
for _, e := range toDispatch {
m.dispatch(ctx, e)
}
} }
return nil return nil
} }
func (m *Mocks) processEvent(ctx context.Context, tx pgx.Tx, e raw_events.Event) error { // dispatch hands e to the configured listener without blocking. The
m.log.Debugf("processing (mock) event: %#v", e) // listener may call ack synchronously (e.g. inline once its own work is
// done) or from elsewhere entirely, arbitrarily later - ack does its own
// independent, idempotent write, so it's never at risk of racing (or being
// silently lost to) this call's context being cancelled.
func (m *Mocks) dispatch(ctx context.Context, e raw_events.Event) {
if m.listener == nil {
return
}
go func() {
if err := m.listener.Notify(ctx, e, func(ackCtx context.Context) error {
return ack(ackCtx, m.db, e)
}); err != nil {
m.log.Errorf("error incurred by event listener: %v", err)
}
}()
}
func markNotified(ctx context.Context, tx pgx.Tx, e raw_events.Event) error {
_, err := tx.Exec( _, err := tx.Exec(
ctx, ctx,
` `
UPDATE UPDATE
mock.shop_amazon_events mock.shop_amazon_events
SET SET
processed = true, notified_at = NOW()
processed_successfully = true
WHERE WHERE
shop_id = @shop_id shop_id = @shop_id
AND event_id = @event_id AND event_id = @event_id
@@ -190,17 +344,34 @@ func (m *Mocks) processEvent(ctx context.Context, tx pgx.Tx, e raw_events.Event)
"event_timestamp": e.EventTimestamp, "event_timestamp": e.EventTimestamp,
}, },
) )
return err
}
// ack records that e has been fully handled. It's idempotent - calling it
// more than once (e.g. because a retried notification also eventually acks)
// just leaves processed_at at whenever it was first set.
func ack(ctx context.Context, db *pgxpool.Pool, e raw_events.Event) error {
_, err := db.Exec(
ctx,
`
UPDATE
mock.shop_amazon_events
SET
processed_at = NOW()
WHERE
shop_id = @shop_id
AND event_id = @event_id
AND event_timestamp = @event_timestamp
AND processed_at IS NULL
`,
pgx.NamedArgs{
"shop_id": e.StoreID,
"event_id": e.EventID,
"event_timestamp": e.EventTimestamp,
},
)
if err != nil { if err != nil {
return fmt.Errorf("failed to perform query: %w", err) return fmt.Errorf("failed to perform query: %w", err)
} }
if m.listener != nil {
go func() {
if err := m.listener.Notify(ctx, e); err != nil {
m.log.Errorf("error incurred by event listener: %v", err)
}
}()
}
return nil return nil
} }
+459
View File
@@ -0,0 +1,459 @@
package amazon
import (
"context"
"sync"
"testing"
"time"
"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"
)
// notifySpy is a MockEventListener that records every event it's notified
// about, along with the ack callback it was given. By default it acks
// immediately (autoAck=true), matching a well-behaved listener; tests
// exercising the retry path set autoAck=false to simulate a listener that
// received the notification but hasn't finished yet, and call ackAt
// explicitly once it "finishes".
type notifySpy struct {
mu sync.Mutex
events []raw_events.Event
acks []func(context.Context) error
completedCount int // Notify calls that have returned, including any auto-ack
autoAck bool
}
func newNotifySpy() *notifySpy {
return &notifySpy{autoAck: true}
}
func (s *notifySpy) Notify(ctx context.Context, e raw_events.Event, ack func(context.Context) error) error {
s.mu.Lock()
s.events = append(s.events, e)
s.acks = append(s.acks, ack)
autoAck := s.autoAck
s.mu.Unlock()
var ackErr error
if autoAck {
ackErr = ack(ctx)
}
s.mu.Lock()
s.completedCount++
s.mu.Unlock()
return ackErr
}
func (s *notifySpy) eventsSnapshot() []raw_events.Event {
s.mu.Lock()
defer s.mu.Unlock()
return append([]raw_events.Event(nil), s.events...)
}
func (s *notifySpy) setAutoAck(v bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.autoAck = v
}
// ackAt manually invokes the i-th recorded ack callback (0-indexed, in
// Notify call order), simulating the listener finally finishing work it
// had earlier only been notified about.
func (s *notifySpy) ackAt(t *testing.T, i int) {
t.Helper()
s.mu.Lock()
ack := s.acks[i]
s.mu.Unlock()
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
// (including any auto-ack), cumulatively across the test - safe to call
// more than once with increasing n.
func (s *notifySpy) waitForCount(t *testing.T, n int, timeout time.Duration) {
t.Helper()
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
// entry point real mock sale/refund/inventory simulations use. A DB trigger
// (mock.process_raw_amazon_event, see migrations 000026/000028) copies the
// row into mock.shop_amazon_events and fires pg_notify on
// mock_shop_amazon_event_inserted - so this one insert exercises the exact
// same path production traffic does, instead of faking the downstream
// table directly.
func insertRawAmazonEvent(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) {
t.Helper()
_, err := pool.Exec(context.Background(), `
INSERT INTO mock.raw_shop_events (platform, shop_id, event_timestamp, event_id, raw_payload)
VALUES ('amazon', $1, NOW(), $2, '{}'::jsonb)
`, shopID, eventID)
require.NoError(t, err, "insert raw amazon event")
}
// insertRawAmazonEvents bulk-inserts n events for shopID in one statement,
// each with a distinct event_id/event_timestamp.
func insertRawAmazonEvents(t *testing.T, pool *pgxpool.Pool, shopID string, n int) {
t.Helper()
_, err := pool.Exec(context.Background(), `
INSERT INTO mock.raw_shop_events (platform, shop_id, event_timestamp, event_id, raw_payload)
SELECT 'amazon', $1, NOW() + (s || ' milliseconds')::interval, 'evt-' || s, '{}'::jsonb
FROM generate_series(1, $2) AS s
`, shopID, n)
require.NoError(t, err, "insert %d raw amazon events", n)
}
// 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(ctx, `
SELECT processed_at IS NOT NULL
FROM mock.shop_amazon_events
WHERE shop_id = $1 AND event_id = $2
`, shopID, eventID).Scan(&processed)
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
}
// isNotified reports whether the event has been notified (at least once)
// but not yet acked/processed.
func isNotified(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) bool {
t.Helper()
var notified bool
err := pool.QueryRow(context.Background(), `
SELECT notified_at IS NOT NULL AND processed_at IS NULL
FROM mock.shop_amazon_events
WHERE shop_id = $1 AND event_id = $2
`, shopID, eventID).Scan(&notified)
require.NoError(t, err, "check notified state")
return notified
}
func countUnprocessed(t *testing.T, pool *pgxpool.Pool, shopID string) int {
t.Helper()
var n 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)
require.NoError(t, err, "count unprocessed events")
return n
}
// cleanupShop registers deletion of every row this test's shopID may have
// produced, in FK-safe order (shop_amazon_events references raw_shop_events).
func cleanupShop(t *testing.T, pool *pgxpool.Pool, shopID string) {
t.Cleanup(func() {
ctx := context.Background()
pool.Exec(ctx, `DELETE FROM mock.shop_amazon_events WHERE shop_id = $1`, shopID)
pool.Exec(ctx, `DELETE FROM mock.raw_shop_events WHERE platform = 'amazon' AND shop_id = $1`, shopID)
})
}
// terminateListenConnection finds the backend holding this package's LISTEN
// registration (identified by its last query text, which Postgres keeps
// showing while the connection sits idle waiting for notifications) and
// forcibly kills it - the same failure mode a dropped connection or a
// Postgres restart produces, so tests can exercise real reconnect behavior
// instead of a simulated one.
func terminateListenConnection(t *testing.T, pool *pgxpool.Pool) {
t.Helper()
ctx := context.Background()
var pid int
err := pool.QueryRow(ctx, `
SELECT pid FROM pg_stat_activity
WHERE query = 'LISTEN ' || $1
ORDER BY backend_start DESC
LIMIT 1
`, eventChannelName).Scan(&pid)
require.NoError(t, err, "find the LISTEN connection's backend pid")
_, 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)
}
}
func TestProcessUnprocessedEvents_ProcessesAllEventsAcrossBatches(t *testing.T) {
pool := testdb.Pool(t)
spy := newNotifySpy()
m := NewMocks(testdb.Logger(), pool).SetListener(spy)
ctx := context.Background()
shopID := "test-shop-" + uuid.NewString()
cleanupShop(t, pool, shopID)
const n = 150 // exceeds the 100-row LIMIT per batch inside processUnprocessedEvents
insertRawAmazonEvents(t, pool, shopID, n)
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents()")
spy.waitForCount(t, n, 5*time.Second)
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) {
pool := testdb.Pool(t)
m := NewMocks(testdb.Logger(), pool) // no SetListener call
ctx := context.Background()
shopID := "test-shop-" + uuid.NewString()
cleanupShop(t, pool, shopID)
insertRawAmazonEvent(t, pool, shopID, "evt-1")
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.
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
// insight #2's fix: a listener that receives a notification but never acks
// gets notified again after notifyRetryAfter, and once it does ack (however
// late), the event settles into processed and stops being retried.
func TestProcessUnprocessedEvents_RetriesUnackedNotification(t *testing.T) {
pool := testdb.Pool(t)
spy := newNotifySpy()
spy.setAutoAck(false)
m := NewMocks(testdb.Logger(), pool).
SetListener(spy).
WithNotifyRetryAfter(50 * time.Millisecond)
ctx := context.Background()
shopID := "test-shop-" + uuid.NewString()
cleanupShop(t, pool, shopID)
insertRawAmazonEvent(t, pool, shopID, "evt-1")
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (1st pass)")
spy.waitForCount(t, 1, 2*time.Second)
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.
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
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)
require.True(t, isProcessed(t, pool, shopID, "evt-1"), "event should be processed once any recorded ack for it is called")
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
// branch of ProcessEvents' select actually causes reprocessing, decoupled
// from NOTIFY entirely: after the one real insert (which does fire NOTIFY,
// same as any other test here), nothing ever triggers another notification
// for the rest of the test. The event becomes retry-due almost immediately
// (notifyRetryAfter is tiny), so the *only* way it can be dispatched a
// second time is the poll timer in the select firing on its own.
func TestProcessEvents_PollFallbackPicksUpRetryDueEvents(t *testing.T) {
pool := testdb.Pool(t)
spy := newNotifySpy()
spy.setAutoAck(false)
m := NewMocks(testdb.Logger(), pool).
SetListener(spy).
WithNotifyRetryAfter(30 * time.Millisecond).
WithPollInterval(60 * time.Millisecond)
shopID := "test-shop-" + uuid.NewString()
cleanupShop(t, pool, shopID)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- m.ProcessEvents(ctx)
}()
waitReady(t, m, 5*time.Second)
insertRawAmazonEvent(t, pool, shopID, "evt-1")
// first dispatch, via the real NOTIFY.
spy.waitForCount(t, 1, 2*time.Second)
// second dispatch: nothing will notify again from here on, so this can
// only come from the poll branch of the select waking the loop up on
// its own and finding the event retry-due.
spy.waitForCount(t, 2, 3*time.Second)
cancel()
waitStopped(t, errCh, 5*time.Second)
}
// TestProcessEvents_ReactsToNotification drives the actual long-running
// loop: LISTEN registration, a real Postgres NOTIFY fired by the DB trigger
// on insert, WaitForNotification waking the loop, and the listener callback
// - the full stateful path, not just the deterministic batch-processing
// core covered above.
func TestProcessEvents_ReactsToNotification(t *testing.T) {
pool := testdb.Pool(t)
spy := newNotifySpy()
m := NewMocks(testdb.Logger(), pool).SetListener(spy)
shopID := "test-shop-" + uuid.NewString()
cleanupShop(t, pool, shopID)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- m.ProcessEvents(ctx)
}()
waitReady(t, m, 5*time.Second)
insertRawAmazonEvent(t, pool, shopID, "evt-1")
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)
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()
waitStopped(t, errCh, 5*time.Second)
}
// TestProcessEvents_ShutsDownOnContextCancel checks the lifecycle in
// isolation, without depending on NOTIFY timing at all - a fast, low-flake
// guard against shutdown regressions (hangs, goroutine leaks) independent
// of whether the reactive path above is working.
func TestProcessEvents_ShutsDownOnContextCancel(t *testing.T) {
pool := testdb.Pool(t)
m := NewMocks(testdb.Logger(), pool)
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
errCh <- m.ProcessEvents(ctx)
}()
waitReady(t, m, 5*time.Second)
cancel()
waitStopped(t, errCh, 5*time.Second)
}
// TestProcessEvents_ReconnectsAfterListenConnectionDrops is insight #4's
// fix: forcibly kills the real backend connection ProcessEvents is
// LISTEN-ing on (the same failure mode a dropped connection or a Postgres
// restart produces) and confirms it reconnects and keeps working on its
// own, rather than the error propagating out of ProcessEvents entirely.
func TestProcessEvents_ReconnectsAfterListenConnectionDrops(t *testing.T) {
pool := testdb.Pool(t)
spy := newNotifySpy()
m := NewMocks(testdb.Logger(), pool).SetListener(spy)
shopID := "test-shop-" + uuid.NewString()
cleanupShop(t, pool, shopID)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- m.ProcessEvents(ctx)
}()
waitReady(t, m, 5*time.Second)
terminateListenConnection(t, pool)
// ProcessEvents should still be running, just reconnecting (initial
// backoff is 1s) - it must not have returned because of this.
select {
case err := <-errCh:
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")
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()
waitStopped(t, errCh, 5*time.Second)
}
+27 -25
View File
@@ -17,26 +17,11 @@ import (
"ruben/inventory2/logging" "ruben/inventory2/logging"
) )
// TODO: move these to a config?
const (
// The URL of our Auth0 Tenant Domain.
// If you're using a Custom Domain, be sure to set this to that value instead.
AUTH0_DOMAIN = "dev-uq3gqy5bdnwxmr6d.us.auth0.com"
// Our Auth0 application"s Client ID.
AUTH0_CLIENT_ID = "JEjrXTQ9fxlTLgp9RgTIACpUk8a2lqNT"
// Our Auth0 application"s Client Secret.
AUTH0_CLIENT_SECRET = "83U-iWdVaNnwk9XDzteo_2VMyOq_l1siKYqg1_2E7jCzgL8MnkaxlysPMcPMGlxA"
// The Callback URL of our application.
AUTH0_CALLBACK_URL = "https://inventory-plus-plus.com/api/auth/login/callback"
)
type ( type (
// Authenticator is used to authenticate our users. // Authenticator is used to authenticate our users.
Authenticator struct { Authenticator struct {
log *logging.Logger log *logging.Logger
domain string
*oidc.Provider *oidc.Provider
oauth2.Config oauth2.Config
db *pgxpool.Pool db *pgxpool.Pool
@@ -60,15 +45,19 @@ 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( func New(
ctx context.Context, ctx context.Context,
db *pgxpool.Pool, db *pgxpool.Pool,
logger *logging.Logger, logger *logging.Logger,
domain, clientID, clientSecret, callbackURL string,
) (*Authenticator, error) { ) (*Authenticator, error) {
provider, err := oidc.NewProvider( provider, err := oidc.NewProvider(
ctx, ctx,
"https://"+AUTH0_DOMAIN+"/", "https://"+domain+"/",
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -76,11 +65,12 @@ func New(
return &Authenticator{ return &Authenticator{
log: logger, log: logger,
domain: domain,
Provider: provider, Provider: provider,
Config: oauth2.Config{ Config: oauth2.Config{
ClientID: AUTH0_CLIENT_ID, ClientID: clientID,
ClientSecret: AUTH0_CLIENT_SECRET, ClientSecret: clientSecret,
RedirectURL: AUTH0_CALLBACK_URL, RedirectURL: callbackURL,
Endpoint: provider.Endpoint(), Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile"}, Scopes: []string{oidc.ScopeOpenID, "profile"},
}, },
@@ -88,6 +78,18 @@ func New(
}, nil }, 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 { func (a *Authenticator) RunBackgroundCleanup(ctx context.Context) error {
for { for {
if _, err := a.db.Exec(ctx, "DELETE FROM oauth_tokens WHERE expiry < NOW()"); err != nil { if _, err := a.db.Exec(ctx, "DELETE FROM oauth_tokens WHERE expiry < NOW()"); err != nil {
@@ -244,7 +246,7 @@ func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token)
func (a *Authenticator) GetLogoutURL(requestHost string) *url.URL { func (a *Authenticator) GetLogoutURL(requestHost string) *url.URL {
return &url.URL{ return &url.URL{
Scheme: "https", Scheme: "https",
Host: AUTH0_DOMAIN, Host: a.domain,
Path: "/v2/logout", Path: "/v2/logout",
RawQuery: url.Values{ RawQuery: url.Values{
"returnTo": { "returnTo": {
@@ -253,7 +255,7 @@ func (a *Authenticator) GetLogoutURL(requestHost string) *url.URL {
Host: requestHost, Host: requestHost,
}).String(), }).String(),
}, },
"client_id": {AUTH0_CLIENT_ID}, "client_id": {a.Config.ClientID},
}.Encode(), }.Encode(),
} }
} }
@@ -267,7 +269,7 @@ func (a *Authenticator) RefreshAccessToken(
err error, err error,
) { ) {
refreshToken, tokenType, err := a.getRefreshTokenForAccessToken(ctx, accessToken) refreshToken, tokenType, err := a.getRefreshTokenForAccessToken(ctx, oldAccessToken)
if err != nil { if err != nil {
return "", time.Time{}, fmt.Errorf("failed to load refresh token: %w", err) return "", time.Time{}, fmt.Errorf("failed to load refresh token: %w", err)
} }
+97
View File
@@ -0,0 +1,97 @@
package authentication
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
// DevLogin mints a local session for userID without going through the real
// Auth0 OAuth flow. It writes a real oauth_users/oauth_tokens row, so every
// other code path (identity lookup, account linking/creation, cookie
// handling) treats it exactly like a normal login.
//
// Only ever call this from a route gated on an explicit dev-mode flag -
// never wire it up unconditionally, since it lets the caller authenticate
// as any user_id with no credentials.
func (a *Authenticator) DevLogin(ctx context.Context, userID, name string) (accessToken string, expiration time.Time, err error) {
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return "", time.Time{}, fmt.Errorf("failed to generate access token: %w", err)
}
accessToken = "dev_" + hex.EncodeToString(tokenBytes)
// Set far in the future so the near-expiry refresh path in
// server/auth never tries to refresh a token that has no real
// Auth0-side refresh token behind it.
expiration = time.Now().Add(365 * 24 * time.Hour)
if _, err = a.db.Exec(
ctx,
`
WITH ensured_user AS (
INSERT INTO oauth_users (user_id)
VALUES (@user_id)
ON CONFLICT DO NOTHING
)
INSERT INTO oauth_tokens (
access_token,
token_type,
refresh_token,
expiry,
id_token_issuer,
id_token_audience,
id_token_subject,
id_token_expiry,
id_token_issued_at,
id_token_nonce,
id_token_access_token_hash,
id_token_custom_claims_family_name,
id_token_custom_claims_given_name,
id_token_custom_claims_name,
id_token_custom_claims_nickname,
id_token_custom_claims_picture,
id_token_custom_claims_updated_at
)
VALUES (
@access_token,
'dev',
'',
@expiry,
'dev-auth-bypass',
@audience,
@user_id,
@expiry,
NOW(),
'',
'',
@name,
@name,
@name,
@name,
'',
NOW()
)
`,
pgx.NamedArgs{
"access_token": accessToken,
"expiry": expiration,
"user_id": userID,
"name": name,
"audience": pgtype.FlatArray[string]{"dev"},
},
); err != nil {
return "", time.Time{}, fmt.Errorf("failed to save dev session: %w", err)
}
return accessToken, expiration, nil
}
+74
View File
@@ -0,0 +1,74 @@
package authentication
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"ruben/inventory2/consts"
"ruben/inventory2/internal/testdb"
)
func TestDevLogin(t *testing.T) {
pool := testdb.Pool(t)
auth := &Authenticator{db: pool}
ctx := context.Background()
userID := testdb.NewUserID(t)
t.Cleanup(func() {
pool.Exec(context.Background(), "DELETE FROM oauth_tokens WHERE id_token_subject = $1", userID)
pool.Exec(context.Background(), "DELETE FROM oauth_users WHERE user_id = $1", userID)
})
accessToken, expiration, err := auth.DevLogin(ctx, userID, "Test User")
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)
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.
assert.WithinDuration(t, expiration, claims.Expiration, time.Millisecond, "claims.Expiration")
_, tokenType, err := auth.getRefreshTokenForAccessToken(ctx, accessToken)
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 -
// e.g. testing multiple times as the same dev identity - since oauth_users
// is keyed on user_id but each call mints its own oauth_tokens row.
func TestDevLogin_SameUserIDTwice(t *testing.T) {
pool := testdb.Pool(t)
auth := &Authenticator{db: pool}
ctx := context.Background()
userID := testdb.NewUserID(t)
t.Cleanup(func() {
pool.Exec(context.Background(), "DELETE FROM oauth_tokens WHERE id_token_subject = $1", userID)
pool.Exec(context.Background(), "DELETE FROM oauth_users WHERE user_id = $1", userID)
})
token1, _, err := auth.DevLogin(ctx, userID, "Test User")
require.NoError(t, err, "first DevLogin()")
token2, _, err := auth.DevLogin(ctx, userID, "Test User")
require.NoError(t, err, "second DevLogin()")
assert.NotEqual(t, token1, token2, "DevLogin() returned the same access token twice")
}
func TestGetAccessTokenClaimsAndExpiration_UnknownToken(t *testing.T) {
pool := testdb.Pool(t)
auth := &Authenticator{db: pool}
ctx := context.Background()
_, err := auth.GetAccessTokenClaimsAndExpiration(ctx, "no-such-token-"+testdb.NewUserID(t))
require.ErrorIs(t, err, consts.ErrNotFound, "GetAccessTokenClaimsAndExpiration()")
}
+19
View File
@@ -0,0 +1,19 @@
# Etsy API compliance checklist
Obligations from Etsy's API Licensed Uses and Restrictions. Anything
touching `domains/platforms/etsy` or the Etsy-facing UI should keep these
satisfied before shipping.
- [ ] Link directly back to the product information and/or image Content on Etsy, where the Application utilizes product information and/or images.
- [ ] Provide a prominently displayed email address on Your Application for third parties to contact You with any questions or issues. You shall respond to such inquiries in a timely manner.
- [ ] Use commercially reasonable efforts to provide a terms of service and privacy policy in a visible location on your Application.
- [ ] Display item Content or product information and/or images which is more than six (6) hours older than such information is on the Website, and other Etsy Content cannot be more than twenty-four (24) hours older than such Content on the Website.
- [ ] Use the API in a manner that exceeds reasonable request volume or constitutes excessive or abusive usage. Users are allocated by default, 10,000 calls per day.
- [ ] You shall not use or alter any text, logos, Etsy's Trademarks, Etsy's signature colors, Etsy's layout, or a confusingly similar layout to Etsy's layout in such a way which may suggest endorsement or affiliation by Etsy.
- [ ] Any use of the Etsy logo or Etsy's Trademarks must be used in its entirety and must not be altered or used in a misleading way.
- [ ] You shall not use a mark which is confusingly similar to Etsy's Trademarks.
- [ ] Any use of the Etsy logo or Etsy's Trademarks in Your Application shall be less prominent than the logo or mark that primarily describes the Application and Your use of the Etsy logo shall not imply any endorsement or affiliation by Etsy.
- [ ] You may publicize, issue press or blog releases of Your Application only if You state that it was created using the Etsy API and that You in no way imply that Your Application is endorsed or certified by Etsy.
- [ ] You must place or display the following notice prominently on Your Application:
"The term 'Etsy' is a trademark of Etsy, Inc. This application uses the Etsy API but is not endorsed or certified by Etsy, Inc."
- [ ] Immediately report any security deficiencies You discover to Etsy by emailing developer@etsy.com.
+68
View File
@@ -0,0 +1,68 @@
package raw_events_test
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"ruben/inventory2/domains/raw_events"
"ruben/inventory2/internal/testdb"
)
func TestSaveAndLoadEventsForStore(t *testing.T) {
pool := testdb.Pool(t)
store := raw_events.NewStore(testdb.Logger(), pool)
ctx := context.Background()
platform := "test-platform"
storeID := "test-store-" + uuid.NewString()
t.Cleanup(func() {
pool.Exec(context.Background(), "DELETE FROM raw_store_events WHERE platform = $1 AND store_id = $2", platform, storeID)
})
older := raw_events.Event{
Platform: platform,
StoreID: storeID,
EventID: "evt-1",
EventTimestamp: time.Now().Add(-time.Hour).UTC(),
Payload: json.RawMessage(`{"n":1}`),
}
newer := raw_events.Event{
Platform: platform,
StoreID: storeID,
EventID: "evt-2",
EventTimestamp: time.Now().UTC(),
Payload: json.RawMessage(`{"n":2}`),
}
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)
require.NoError(t, err, "LoadEventsForStore()")
require.Len(t, got, 2, "LoadEventsForStore()")
// ordered event_timestamp DESC - newest first.
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 }
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) {
pool := testdb.Pool(t)
store := raw_events.NewStore(testdb.Logger(), pool)
ctx := context.Background()
got, err := store.LoadEventsForStore(ctx, "test-platform", "no-such-store-"+uuid.NewString())
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")
}
+4 -5
View File
@@ -11,8 +11,10 @@ require (
github.com/coreos/go-oidc/v3 v3.17.0 github.com/coreos/go-oidc/v3 v3.17.0
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/lmittmann/tint v1.1.2 github.com/lmittmann/tint v1.1.2
github.com/oapi-codegen/runtime v1.1.2 github.com/oapi-codegen/runtime v1.1.2
github.com/stretchr/testify v1.11.1
golang.org/x/oauth2 v0.34.0 golang.org/x/oauth2 v0.34.0
) )
@@ -26,14 +28,12 @@ require (
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.1 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/getkin/kin-openapi v0.133.0 // indirect github.com/getkin/kin-openapi v0.133.0 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect
@@ -42,7 +42,6 @@ require (
github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -63,6 +62,7 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
github.com/speakeasy-api/jsonpath v0.6.0 // indirect github.com/speakeasy-api/jsonpath v0.6.0 // indirect
@@ -82,7 +82,6 @@ require (
golang.org/x/sys v0.40.0 // indirect golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.0 // indirect golang.org/x/tools v0.41.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
+4 -94
View File
@@ -16,29 +16,15 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.10.0-rc3 h1:uNSnscRapXTwUgTyOF0GVljYD08p9X/Lbr9MweSV3V0=
github.com/bytedance/sonic v1.10.0-rc3/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coreos/go-oidc/v3 v3.8.0 h1:s3e30r6VEl3/M7DTSCEuImmrfu1/1WBgA0cXkdzkrAY=
github.com/coreos/go-oidc/v3 v3.8.0/go.mod h1:yQzSCqBnK3e6Fs5l+f5i0F8Kwf0zpH9bPEsbY00KanM=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -51,24 +37,14 @@ github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5ql
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
@@ -81,15 +57,11 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.1 h1:9c50NUPC30zyuKprjL3vNZ0m5oG+jU0zvx4AqHGnv4k=
github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
@@ -103,22 +75,14 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
@@ -129,23 +93,19 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -153,8 +113,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -195,8 +153,6 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0=
github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
@@ -230,20 +186,15 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
@@ -253,28 +204,17 @@ github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4Z
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 h1:0sw0nJM544SpsihWx1bkXdYLQDlzRflMgFJQ4Yih9ts= github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 h1:0sw0nJM544SpsihWx1bkXdYLQDlzRflMgFJQ4Yih9ts=
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4/go.mod h1:+ccdNT0xMY1dtc5XBxumbYfOUhmduiGudqaDgD2rVRE= github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4/go.mod h1:+ccdNT0xMY1dtc5XBxumbYfOUhmduiGudqaDgD2rVRE=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.4.0 h1:A8WCeEWhLwPBKNbFi5Wv5UTCBx5zzubnXDlMOFAzFMc=
golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -282,23 +222,15 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ=
golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -315,44 +247,26 @@ golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -361,8 +275,6 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -382,5 +294,3 @@ gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+115
View File
@@ -0,0 +1,115 @@
// Package testdb provides shared fixtures for integration tests that need a
// real Postgres connection. It targets TEST_DATABASE_URL, which `make test`
// points at a dedicated inventory_2_test database by default; override it
// (e.g. `make test TEST_DATABASE_URL=...`) to run the same tests against
// another database, such as the real dev one.
package testdb
import (
"context"
"io"
"log/slog"
"os"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"ruben/inventory2/logging"
)
// Pool connects to TEST_DATABASE_URL. If it isn't set, the test is skipped
// rather than failed, so `go test ./...` works without Postgres running.
func Pool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set; skipping integration test (see .env.example, or run via `make test`)")
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("failed to connect to test database: %v", err)
}
t.Cleanup(pool.Close)
if err := pool.Ping(context.Background()); err != nil {
t.Fatalf("failed to reach test database at TEST_DATABASE_URL: %v", err)
}
return pool
}
// Logger returns a logger that discards output, for stores that require one
// but whose logging isn't under test.
func Logger() *logging.Logger {
return logging.New(slog.NewTextHandler(io.Discard, nil))
}
// NewUserID returns a unique user_id for a test to use, so parallel test
// runs (including against a shared database) never collide.
func NewUserID(t *testing.T) string {
t.Helper()
return "test-" + t.Name() + "-" + uuid.NewString()
}
// SeedOAuthUser inserts a bare oauth_users row so accounts/etc. FKs that
// reference it are satisfiable, and registers cleanup. Use SeedOAuthSession
// instead if the test also needs a valid access token.
func SeedOAuthUser(t *testing.T, pool *pgxpool.Pool, userID string) {
t.Helper()
ctx := context.Background()
if _, err := pool.Exec(ctx, `INSERT INTO oauth_users (user_id) VALUES ($1)`, userID); err != nil {
t.Fatalf("failed to seed oauth_users row: %v", err)
}
t.Cleanup(func() {
if _, err := pool.Exec(context.Background(), `DELETE FROM oauth_users WHERE user_id = $1`, userID); err != nil {
t.Errorf("cleanup: failed to delete oauth_users row: %v", err)
}
})
}
// SeedOAuthSession inserts an oauth_users row plus a matching oauth_tokens
// row with a freshly generated access token, mirroring the shape a real (or
// authentication.Authenticator.DevLogin) session leaves behind. Registers
// cleanup for both rows, in dependency order.
func SeedOAuthSession(t *testing.T, pool *pgxpool.Pool, userID string) (accessToken string) {
t.Helper()
ctx := context.Background()
SeedOAuthUser(t, pool, userID)
accessToken = "test-token-" + uuid.NewString()
_, err := pool.Exec(ctx, `
INSERT INTO oauth_tokens (
access_token, token_type, refresh_token, expiry,
id_token_issuer, id_token_audience, id_token_subject,
id_token_expiry, id_token_issued_at, id_token_nonce, id_token_access_token_hash,
id_token_custom_claims_family_name, id_token_custom_claims_given_name,
id_token_custom_claims_name, id_token_custom_claims_nickname,
id_token_custom_claims_picture, id_token_custom_claims_updated_at
) VALUES (
$1, 'test', '', NOW() + INTERVAL '1 hour',
'test', '{test}', $2,
NOW() + INTERVAL '1 hour', NOW(), '', '',
'Test', 'User',
'Test User', 'testuser',
'', NOW()
)
`, accessToken, userID)
if err != nil {
t.Fatalf("failed to seed oauth_tokens row: %v", err)
}
t.Cleanup(func() {
if _, err := pool.Exec(context.Background(), `DELETE FROM oauth_tokens WHERE access_token = $1`, accessToken); err != nil {
t.Errorf("cleanup: failed to delete oauth_tokens row: %v", err)
}
})
return accessToken
}
+32 -14
View File
@@ -21,6 +21,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/lmittmann/tint" "github.com/lmittmann/tint"
"ruben/inventory2/config"
"ruben/inventory2/domains/accounts" "ruben/inventory2/domains/accounts"
"ruben/inventory2/domains/amazon" "ruben/inventory2/domains/amazon"
"ruben/inventory2/domains/authentication" "ruben/inventory2/domains/authentication"
@@ -32,11 +33,6 @@ import (
"ruben/inventory2/server/sse" "ruben/inventory2/server/sse"
) )
const (
etsyAPIKeystring = "38ncokqh0jih5jshfk8iv4n5"
etsyAPISharedSecret = "jaaw0tyizf"
)
func main() { func main() {
logger := logging.New(tint.NewHandler(os.Stderr, &tint.Options{ logger := logging.New(tint.NewHandler(os.Stderr, &tint.Options{
AddSource: true, AddSource: true,
@@ -68,23 +64,43 @@ func runApp(ctx context.Context, logger *logging.Logger) error {
ctx, shutdown := context.WithCancel(ctx) ctx, shutdown := context.WithCancel(ctx)
defer shutdown() defer shutdown()
// load configuration
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("failed to load configuration: %w", err)
}
// connect to the database // connect to the database
connPool, err := newPool(ctx) connPool, err := newPool(ctx, cfg.DatabaseURL)
if err != nil { if err != nil {
return fmt.Errorf("failed to initialize database connection pool: %w", err) return fmt.Errorf("failed to initialize database connection pool: %w", err)
} }
auth, err := authentication.New(ctx, connPool, logger.WithGroup("authenticator")) var auth *authentication.Authenticator
if err != nil { if cfg.DevAuthEnabled {
return fmt.Errorf("failed to construct authenticator: %w", err) 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) accts := accounts.NewStore(logger.WithGroup("accounts"), connPool)
// start http server // start http server
sseQueue, srvErrCh := runServer(ctx, logger, connPool, auth, accts) sseQueue, srvErrCh := runServer(ctx, logger, connPool, auth, accts, cfg)
// start background processes // start background processes
@@ -207,6 +223,7 @@ func runServer(
connPool *pgxpool.Pool, connPool *pgxpool.Pool,
auth *authentication.Authenticator, auth *authentication.Authenticator,
accts *accounts.Store, accts *accounts.Store,
cfg config.Config,
) (*sse.Queue, <-chan error) { ) (*sse.Queue, <-chan error) {
r := server.NewRouter( r := server.NewRouter(
logger.WithGroup("server"), logger.WithGroup("server"),
@@ -219,15 +236,16 @@ func runServer(
func(acctID int64) string { func(acctID int64) string {
return fmt.Sprintf("/oauth/account/%d/auth_code", acctID) return fmt.Sprintf("/oauth/account/%d/auth_code", acctID)
}, },
etsyAPIKeystring, cfg.EtsyAPIKeystring,
etsyAPISharedSecret, cfg.EtsyAPISharedSecret,
connPool, connPool,
), ),
auth, auth,
cfg.DevAuthEnabled,
) )
srv := &http.Server{ srv := &http.Server{
Addr: ":8082", // local Addr: fmt.Sprintf(":%d", cfg.Port), // local
Handler: r, Handler: r,
} }
@@ -240,7 +258,7 @@ func runServer(
defer cancel() defer cancel()
defer close(alreadyShutdownCh) defer close(alreadyShutdownCh)
logger.Info("server running on 8082...") logger.Infof("server running on %d...", cfg.Port)
if err := srv.ListenAndServe(); err != nil { if err := srv.ListenAndServe(); err != nil {
if !errors.Is(err, http.ErrServerClosed) { if !errors.Is(err, http.ErrServerClosed) {
runningErrCh <- fmt.Errorf("server experienced error: %w", err) runningErrCh <- fmt.Errorf("server experienced error: %w", err)
+209
View File
@@ -0,0 +1,209 @@
# Platform Research
https://docs.google.com/spreadsheets/d/1xWfXn-wbiHTBeqyhnq0b46_sgLGyiYc_5N5mXKYD1R8/edit?gid=0#gid=0
| Platform | Order Event: placed | Order Event: changed | Products List | Products Look up | Inventory Look up | Inventory Update | Inventory Event: change |
|----------|----------|----------|----------|----------|----------|----------|----------|
| **Etsy (live)** | N/A — no webhook/push system in API v3 at all; poll `GET /v3/application/shops/{shop_id}/receipts` (filter `min_created`) | poll same endpoint filtered by `min_last_modified` | `GET /v3/application/shops/{shop_id}/listings` | `GET /v3/application/listings/{listing_id}` | `GET /v3/application/listings/{listing_id}/inventory` | `PUT /v3/application/listings/{listing_id}/inventory` | N/A — no inventory-change webhook; poll the inventory endpoint |
| Shopify | orders/create | orders/cancelled, orders/delete, orders/paid, etc | /queries/products | /queries/product | /queries/product | /mutations/inventorySetQuantities | inventory_levels/update |
| WooCommerce | webhook `order.created` | webhook `order.updated`, `order.deleted` | `GET /wp-json/wc/v3/products` | `GET /wp-json/wc/v3/products/<id>` | `stock_quantity` field on product resource | `PUT /wp-json/wc/v3/products/<id>` (stock_quantity), or `/products/batch` | `product.updated` (no dedicated inventory webhook) |
| BigCommerce | webhook `store/order/created` | webhook `store/order/updated`, `store/order/statusUpdated` | `GET /v3/catalog/products` | `GET /v3/catalog/products/{product_id}` | `GET /v3/inventory/items` | `PUT /v3/inventory/adjustments/absolute` (also `/relative`) | `store/product/inventory/updated` |
| Wix | webhook `wix.ecom.v1.order.created` | webhook `wix.ecom.v1.order.updated` (also `.canceled`) | Query Products (Catalog V3) | Get Product | Query Inventory Items | Update Inventory Variants | `wix.stores.catalog.v3.inventory_item.updated` |
| Squarespace | webhook `order.create` | webhook `order.update` (FULFILLED, REFUNDED, CANCELED, MARKED_PENDING, EMAIL_UPDATED) | `GET /v2/commerce/products` | `GET /v2/commerce/products/{productIdCsvs}` | `GET /1.0/commerce/inventory/{variantIdCsvs}` | `POST /1.0/commerce/inventory/adjustments` | N/A (no inventory webhook topic) |
| Square Online | webhook `order.created` | webhook `order.updated`, `order.fulfillment.updated` | `GET /v2/catalog/list` | `GET /v2/catalog/object/{object_id}` | `POST /v2/inventory/counts/batch-retrieve` | `POST /v2/inventory/changes/batch-create` (BatchChangeInventory) | webhook `inventory.count.updated` |
| Zoho | webhook `salesorder.created` (Zoho Commerce) | webhook `salesorder.confirmed, .cancelled, .declined, .shipped, .delivered` | `GET /store/api/v1/products` | `GET /store/api/v1/products/{product_id}` | `GET /store/api/v1/variants` (`stock_on_hand`, `actual_available_stock`) | `POST /store/api/v1/inventoryadjustments` | N/A (no inventory/stock webhook event) |
| Ecwid | webhook `order.created` | webhook `order.updated`, `order.deleted` | `GET /api/v3/{storeId}/products` | `GET /api/v3/{storeId}/products/{productId}` | `GET /api/v3/{storeId}/products/{productId}` (`quantity`/`unlimited`) | `PUT /api/v3/{storeId}/products/{productId}/inventory` (`quantityDelta`) | `product.updated` webhook |
| Big Cartel | webhook `order.create` (app-approved) | webhook `order.update` (app-approved) | `GET /v1/accounts/{account_id}/products` | `GET /v1/accounts/{account_id}/products/{id}` | N/A — no dedicated inventory field/endpoint | N/A — no inventory update endpoint | N/A — no inventory-specific webhook |
| Amazon | `ORDER_CHANGE` notification (SP-API) | `ORDER_CHANGE` notification (same type, status delta) | `searchCatalogItems` (GET `/catalog/2022-04-01/items`) | `getCatalogItem` (GET `/catalog/2022-04-01/items/{asin}`) | `getInventorySummaries` (FBA Inventory API, GET `/fba/inventory/v1/summaries`) | `patchListingsItem` (PATCH `/listings/2021-08-01/items/{sellerId}/{sku}`) | `FBA_INVENTORY_AVAILABILITY_CHANGES` notification |
| Walmart Marketplace | PO created event (webhook) | Order intent to cancel / PO line auto-cancelled event (webhook); status flow Created→Acknowledged→Shipped→Delivered/Cancelled | `GET /v3/items` (getAllItems) | `GET /v3/items/{id}` (getAnItem) | `GET /v3/inventory?sku={sku}` | `PUT /v3/inventory` (also bulk via `POST /v3/feeds`) | Inventory OOS event (webhook) |
| Ebay | `FixedPriceTransaction` / `ItemSold` (Platform Notifications, legacy Trading API) | `ItemMarkedShipped` notification; also `getOrders` filtered by `lastmodifieddate` (Fulfillment API) | `GET /sell/inventory/v1/inventory_item` (getInventoryItems) | `GET /sell/inventory/v1/inventory_item/{sku}` (getInventoryItem) | `GET /sell/inventory/v1/inventory_item/{sku}` (availability.shipToLocationAvailability) | `POST /sell/inventory/v1/bulk_update_price_quantity` (bulkUpdatePriceQuantity) | N/A — no dedicated inventory-change topic found |
| Platform | Docs | Webhooks | API |
|----------|----------|----------|----------|
| Etsy (live) | https://developer.etsy.com/documentation/ | N/A — no webhook/push mechanism exists in Etsy Open API v3 | https://developer.etsy.com/documentation/reference (confirmed directly against `domains/platforms/etsy/generated_client` in this repo, not external docs) |
| Shopify | https://shopify.dev/docs/api | https://shopify.dev/docs/api/webhooks/latest?reference=toml | https://shopify.dev/docs/api/admin-graphql/latest |
| WooCommerce | https://developer.woocommerce.com/docs/apis/rest-api/ | https://developer.woocommerce.com/docs/apis/rest-api/v2/webhooks/ | https://developer.woocommerce.com/docs/apis/rest-api/v3/products/ |
| BigCommerce | https://developer.bigcommerce.com/docs | https://developer.bigcommerce.com/docs/integrations/webhooks/overview | https://developer.bigcommerce.com/docs/rest-catalog/products |
| Wix | https://dev.wix.com/docs | https://dev.wix.com/docs/build-apps/develop-your-app/api-integrations/events-and-webhooks/about-webhooks | https://dev.wix.com/docs/api-reference |
| Squarespace | https://developers.squarespace.com/commerce-apis/overview | https://developers.squarespace.com/commerce-apis/webhooksubscriptions-overview | https://developers.squarespace.com/commerce-apis/overview |
| Square Online | https://developer.squareup.com/docs | https://developer.squareup.com/docs/webhooks/overview | https://developer.squareup.com/reference/square |
| Zoho | https://www.zoho.com/commerce/api/introduction.html | https://www.zoho.com/commerce/api/webhooks.html | https://www.zoho.com/commerce/api/apis-list.html |
| Ecwid | https://docs.ecwid.com/ | https://docs.ecwid.com/webhook-automations | https://api-docs.ecwid.com/reference |
| Big Cartel | https://developers.bigcartel.com/ | https://developers.bigcartel.com/api/v1 (webhooks section, no standalone page) | https://developers.bigcartel.com/api/v1 |
| Amazon | https://developer-docs.amazon.com/sp-api/docs/welcome | https://developer-docs.amazon.com/sp-api/docs/notifications-api-v1-use-case-guide | https://developer-docs.amazon.com/sp-api/reference |
| Walmart Marketplace | https://developer.walmart.com/ | https://developer.walmart.com/doc/us/mp/us-mp-notifications/ | https://developer.walmart.com/us-marketplace/docs/inventory-api-overview |
| Ebay | https://developer.ebay.com/develop | https://developer.ebay.com/api-docs/commerce/notification/overview.html | https://developer.ebay.com/api-docs/sell/inventory/overview.html |
### Notes / caveats from research
- **Square Online**: no separate API — orders, catalog, and inventory are handled by Square's core Seller APIs (developer.squareup.com), the same ones used across all Square products. Unrelated to Squarespace despite the name.
- **Zoho**: "Zoho Commerce" (commerce.zoho.com) is the storefront product comparable to Shopify/Squarespace and owns the order/product/webhook APIs listed above. Zoho Inventory is a separate warehouse/stock-management app with its own API but no documented webhook support.
- **Big Cartel**: no true inventory API — only an `inventory_enabled` flag and `quantity_gte`/`quantity_lte` filters on products. No endpoint to set stock and no inventory-change webhook. Webhook access is gated per-app approval; exact topic names are inferred from integration examples since Big Cartel has no canonical published list.
- **Amazon SP-API**: no separate "placed" vs "changed" order topics — both flow through a single `ORDER_CHANGE` notification, differentiated by payload content.
- **WooCommerce / Ecwid**: neither has a dedicated inventory-change webhook; stock changes surface via the general `product.updated` event instead.
- **Etsy**: the one row in the first table sourced from this repo's actual code rather than external docs (`domains/platforms/etsy/generated_client`, generated from Etsy's own OpenAPI spec) - so it's the most reliable row here, not the least. Etsy Open API v3 has no webhook/push system whatsoever for anything, order or inventory alike; every other platform in the table has at least *some* real-time push. The live integration in this repo currently only implements the OAuth connection flow (`GenerateConnectionURLForNewAccount`/`HandleNewAuthCode` in `domains/platforms/etsy/etsy.go`) - polling-based order/inventory sync against `GetShopReceipts`/`GetListingInventory` isn't built yet.
- **Ebay**: order-event names are less certain — developer.ebay.com pages repeatedly failed to load during research, so those values come from documented Platform Notifications event types found via search rather than a directly confirmed doc page.
- **TikTok Shop**: not in the capability table above - `partner.tiktokshop.com/docv2` is JS-rendered and didn't return usable content via fetch, so exact webhook event names and endpoint paths aren't confirmed (unlike every other row in that table, which comes from readable docs). What is confirmed via secondary sources: TikTok Shop's Partner API has webhooks covering order, product, and inventory changes, and a Product/Inventory API for listing and stock management. Treat as directionally real but needing its own dedicated research pass - with confirmed endpoint names - before implementation.
- **BigCommerce vs Tiktok**: the capability table above includes BigCommerce, but BigCommerce is not one of this codebase's actual mock platforms (see `AGENTS.md`'s platform list - `domains/accounts/platform.go`'s `allPlatforms` has `Tiktok`, not BigCommerce). BigCommerce's row is left in place since the research itself may still be useful, but it's excluded from the priority ranking below; Tiktok is included despite the weaker sourcing noted above, since it's a platform that actually exists in this codebase.
## Market-size research (2026-08-20)
Pulled to sanity-check which platforms are most worth integrating first, on
top of the API-completeness comparison above. See `AGENTS.md`'s "Platform
integration priority" section for the resulting ranking and reasoning.
Figures are mid-2026 estimates for FY2025 (or most recent trailing period)
unless noted. Anything not sourced from an SEC filing is a third-party
estimate (StoreLeads, DemandSage, Marketplace Pulse, etc.) and should be
treated as directional, not precise - methodology varies a lot between
sources, especially for "number of stores/sellers."
| Platform | Active sellers/stores | Most recent GMV | Source confidence |
|---|---|---|---|
| Amazon (3P) | ~1.9M active sellers | ~$575B (3P GMV, 2025); total Amazon GMV >$800B | Moderate - earnings-adjacent estimates |
| Shopify | ~3-6.8M active stores (methodology varies) | $378.4B (FY2025, +29% YoY) | High - SEC filings |
| eBay | ~18.3M active sellers | $79.6B (FY2025, +7% YoY) | High - SEC filings |
| Etsy (already live) | 5.6M active sellers | $11.92B GMS (FY2025, -5.3%) | High - SEC filings |
| Squarespace Commerce | ~353K live ecommerce sites | ~$7.2B (2026 est.) | Low - marketing estimate |
| WooCommerce | ~4.2-6M active stores | ~$30-35B (2025 est.; average store is tiny, ~$7-8K/yr) | Low - third-party estimate, no central ledger |
| Walmart Marketplace | ~200-250K sellers, +50% YoY growth | ~$10B (rough external estimate; Walmart doesn't break this out cleanly) | Low |
| Wix eCommerce | ~760K-3M live stores | $4.1B-$12.4B (conflicting self-reported figures) | Low |
| Tiktok Shop | ~15M sellers globally, ~500K registered / ~216K active in the US | $64.3B global (2025, nearly 2x 2024); $15.1B US (+68% YoY) | Low - third-party estimate, hypergrowth market |
| BigCommerce *(not an actual mock platform here - see caveat above)* | ~37-42K *active* stores (shrinking, shifting to enterprise) | $34B+ | Moderate |
| Square Online | n/a (bundled into Square's overall $250B GPV, mostly in-person POS) | Can't isolate | N/A |
| Zoho Commerce | ~2,196 stores globally (285 US), +34% YoY US growth off a tiny base | Not disclosed; likely small given store count | Low |
| Ecwid | ~130-164K live stores, declining (-20% YoY) | Not disclosed | Low |
| Big Cartel | ~91-192K stores (estimates vary widely), declining sharply (-41% YoY in 2026 Q1) | Not disclosed; platform targets low-AOV indie sellers | Low |
Key takeaways:
- Amazon's 3P GMV ($575B) is ~1.5x Shopify's *total* GMV despite Shopify
having 2-3x more active stores - Amazon sellers skew toward larger,
more serious operations.
- eBay has by far the most sellers (18.3M) but the lowest GMV-per-seller of
any major platform here ($79.6B / 18.3M), suggesting a lot of integration
surface for comparatively thin per-seller value - compounded by its weak
inventory-webhook story (see table above).
- WooCommerce can't be sized financially at all - it's a WordPress plugin,
not a company with a ledger - so its case rests entirely on raw store
count, not proven revenue-per-integration.
- Walmart Marketplace is small in absolute terms but growing fast (+50%
YoY) and is a natural "second marketplace" for sellers already on Amazon.
## Weighted priority ranking (2026-08-20)
Scores each of the 12 not-yet-live mock platforms (everything in
`domains/accounts/platform.go`'s `allPlatforms` except Etsy) on four 0-10
criteria, weighted and summed to a single composite score. This is a
judgment call turned into numbers, not a precise formula - the weights and
per-platform scores below are my read of the research above; treat the
*ranking* as the useful output, not the second decimal place. BigCommerce is
excluded (not an actual platform in this codebase, see caveat above).
Etsy itself is scored too, as a **reference row only** - it's already the
live integration, so it's not competing for "what to build next," but
running it through the same rubric is a useful sanity check on the model.
**Criteria & weights:**
- **GMV / market opportunity (45%)** - bucketed from the GMV figures above
(>$500B=10, $300-500B=9, $50-100B=7, $25-50B=6, $8-15B=4, $5-8B=3,
undisclosed-and-small=1). This is weighted highest because "lucrative"
is fundamentally a dollar-opportunity question.
- **API/inventory-sync completeness (30%)** - from the capability table at
the top of this doc: full order-webhook + dedicated inventory-webhook
coverage scores highest, missing inventory webhook scores mid, no
inventory API at all (Big Cartel) scores near-zero. This directly gates
how good a product experience is even possible on that platform.
- **Growth trajectory (15%)** - YoY GMV/store growth; rewards fast-growing
platforms (Tiktok, Walmart) and penalizes shrinking ones (Ecwid, Big
Cartel) as a proxy for where future opportunity is heading.
- **Integration cost, inverted (10%)** - auth complexity and existing
codebase head start (Amazon's `domains/amazon` background-processor
infra) score higher; gated/undocumented webhook access (Big Cartel)
scores lowest. Weighted lowest since it affects timeline more than
whether the integration is worth doing at all.
**Scores:**
| Platform | GMV (45%) | API (30%) | Growth (15%) | Cost (10%) | Weighted total |
|---|---|---|---|---|---|
| Shopify | 9 | 10 | 8 | 9 | **9.15** |
| Amazon | 10 | 10 | 5 | 6 | **8.85** |
| Tiktok Shop | 7 | 7 | 10 | 4 | **7.15** |
| Walmart Marketplace | 4 | 9 | 9 | 5 | **6.35** |
| Wix | 4 | 10 | 5 | 8 | **6.35** |
| WooCommerce | 6 | 6 | 5 | 7 | **5.95** |
| Square Online | 3 | 10 | 5 | 8 | **5.90** |
| Ebay | 7 | 5 | 4 | 5 | **5.75** |
| Squarespace | 3 | 6 | 6 | 7 | **4.75** |
| *Etsy (reference, already live)* | *4* | *3* | *1* | *10* | *3.85* |
| Zoho | 1 | 6 | 6 | 7 | **3.85** |
| Ecwid | 1 | 6 | 1 | 8 | **3.20** |
| Big Cartel | 1 | 2 | 1 | 3 | **1.50** |
**Ranked priority order:**
1. **Shopify** — 9.15
2. **Amazon** — 8.85
3. **Tiktok Shop** — 7.15
4. **Walmart Marketplace** — 6.35
5. **Wix** — 6.35 (behind Walmart on the growth tiebreaker: 9 vs 5)
6. **WooCommerce** — 5.95
7. **Square Online** — 5.90
8. **Ebay** — 5.75
9. **Squarespace** — 4.75
10. **Etsy** — 3.85 (reference only - already live, not competing for "what to build next")
11. **Zoho** — 3.85
12. **Ecwid** — 3.20
13. **Big Cartel** — 1.50
Notable movement from the earlier qualitative pass: Shopify edges out Amazon
once integration cost and growth are counted, not just raw GMV - Amazon's
dollar opportunity is still bigger, but Shopify is cheaper to build, easier
to get first customers through (App Store), and still growing faster.
Tiktok Shop, unresearched until this pass, lands at #3 on the strength of
its growth rate alone - but see the sourcing caveat above before acting on
that; its API details need a dedicated research pass before it's actually
buildable. Big Cartel is unambiguously last: no inventory API is a
structural dealbreaker for this specific product, independent of its market
size.
**Etsy, run through the same rubric, scores 3.85 - tied with Zoho, below
every platform except Ecwid and Big Cartel.** Its GMV is mid-pack and
declining (-5.3% YoY), and - per the caveat above - it's the *only*
platform researched with literally no webhook/push mechanism for anything,
so it scores below every platform here except Big Cartel on API
completeness too. The only criterion where it dominates is integration cost
(10/10, since it's already built). This is a useful gut-check on the model,
not a claim that building Etsy first was a mistake - Etsy was presumably
chosen for reasons this rubric doesn't capture (an existing relationship, a
founder's market knowledge, being the most approachable API to get
developer credentials for), not for having the best growth/GMV/webhook
profile. Worth remembering when weighing this ranking against Etsy's actual
day-to-day integration cost, which - per the caveat above - has turned out
to be nontrivial in practice: with no webhooks at all, real order/inventory
sync has to be built as a polling loop, which is exactly the kind of
integration cost this rubric's "Cost" column doesn't capture once a
platform is more than superficially wired up.
Sources:
- [Shopify Statistics 2026: Market Share, $116B GMV, Employees](https://www.chargeflow.io/blog/shopify-statistics)
- [Shopify 2026: $378.4B GMV, Store and Seller Data](https://termsandconditionstemplate.com/shopify-statistics-2026)
- [Amazon Third-Party Sellers Generate $575 Billion in GMV](https://english.ebrun.com/20260719/688440.shtml)
- [Amazon GMV Surpassed $800 Billion in 2025 - Marketplace Pulse](https://www.marketplacepulse.com/articles/amazon-gmv-surpassed-800-billion-in-2025)
- [Top 1.6% of Sellers Drive 50% of Amazon's 3P GMV](https://www.marketplacepulse.com/articles/top-16-of-sellers-drive-50-of-amazons-3p-gmv)
- [WooCommerce Market Share 2026: 33.4% Global Stats](https://redstagfulfillment.com/what-is-woocommerces-market-share/)
- [BigCommerce Statistics 2026](https://www.chargeflow.io/blog/bigcommerce-statistics)
- [How many Walmart Marketplace sellers are there in 2025?](https://redstagfulfillment.com/how-many-walmart-marketplace-sellers/)
- [Walmart Marketplace Grows 50% in One Year - Marketplace Pulse](https://www.marketplacepulse.com/articles/walmart-marketplace-grows-50-in-one-year)
- [eBay Gross Merchandise Volume (GMV) 2018-2026 - Marketplace Pulse](https://www.marketplacepulse.com/stats/ebay-gross-merchandise-volume-gmv)
- [eBay Statistics (2026): Active Buyers, Listings, GMV, Revenue](https://expandedramblings.com/index.php/ebay-stats/)
- [The State of Wix in 2026](https://storeleads.app/reports/wix)
- [Squarespace Subscriber and Revenue Statistics for 2026](https://backlinko.com/squarespace-users)
- [Etsy, Inc. Reports Fourth Quarter and Full Year 2025 Results](https://investors.etsy.com/news-events/press-releases/detail/218/etsy-inc-reports-fourth-quarter-and-full-year-2025-results)
- [Block Statistics (2026): Sellers, GPV, Block Revenue](https://expandedramblings.com/index.php/square-statistics/)
+2
View File
@@ -27,11 +27,13 @@ func Routes(
unp *sse.UpdateNotificationPublisher, unp *sse.UpdateNotificationPublisher,
rawEvents *raw_events.Store, rawEvents *raw_events.Store,
etsy *etsy_platform.Platform, etsy *etsy_platform.Platform,
devAuthEnabled bool,
) { ) {
auth_api.Routes( auth_api.Routes(
r.Group("/auth"), r.Group("/auth"),
logger.WithGroup("/auth"), logger.WithGroup("/auth"),
auth.GetAuthenticator(), auth.GetAuthenticator(),
devAuthEnabled,
) )
sse_api.Routes( sse_api.Routes(
r.Group("/events", auth.Authenticate()), r.Group("/events", auth.Authenticate()),
+62
View File
@@ -21,6 +21,7 @@ func Routes(
r *gin.RouterGroup, r *gin.RouterGroup,
logger *logging.Logger, logger *logging.Logger,
auth *authentication.Authenticator, auth *authentication.Authenticator,
devAuthEnabled bool,
) { ) {
ls := &loginSubrouter{ ls := &loginSubrouter{
log: logger, log: logger,
@@ -30,6 +31,12 @@ func Routes(
r.GET("/login", response.Handler(ls.loginPage)) r.GET("/login", response.Handler(ls.loginPage))
r.GET("/login/callback", response.Handler(ls.loginCallback)) r.GET("/login/callback", response.Handler(ls.loginCallback))
r.GET("/logout", response.Handler(ls.logoutPage)) r.GET("/logout", response.Handler(ls.logoutPage))
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))
}
} }
func (s *loginSubrouter) loginPage(c *gin.Context) (response.Response, error) { func (s *loginSubrouter) loginPage(c *gin.Context) (response.Response, error) {
@@ -75,6 +82,43 @@ func (s *loginSubrouter) loginCallback(c *gin.Context) (response.Response, error
Cookie(cookies.AccessToken(accessToken, expiration)), nil Cookie(cookies.AccessToken(accessToken, expiration)), nil
} }
// devLoginPage mints a local session for a user_id, skipping the real Auth0
// OAuth round-trip. Only registered when devAuthEnabled is passed to Routes.
//
// Query params:
// - user_id: identity to log in as (default "dev-user"); use different
// values to test multiple accounts side by side.
// - name: display name for the identity (default derived from user_id).
// - target: where to redirect after login (default "/").
func (s *loginSubrouter) devLoginPage(c *gin.Context) (response.Response, error) {
r := c.Request
ctx := r.Context()
q := r.URL.Query()
userID := q.Get("user_id")
if userID == "" {
userID = "dev-user"
}
name := q.Get("name")
if name == "" {
name = "Dev User (" + userID + ")"
}
targetURI := q.Get("target")
if targetURI == "" {
targetURI = "/"
}
accessToken, expiration, err := s.auth.DevLogin(ctx, userID, name)
if err != nil {
return nil, response.Errorf("failed to create dev session: %w", err)
}
return response.TemporaryRedirect(targetURI).
Cookie(cookies.AccessToken(accessToken, expiration)), nil
}
func (s *loginSubrouter) logoutPage(c *gin.Context) (response.Response, error) { func (s *loginSubrouter) logoutPage(c *gin.Context) (response.Response, error) {
r := c.Request r := c.Request
@@ -92,3 +136,21 @@ func (s *loginSubrouter) logoutPage(c *gin.Context) (response.Response, error) {
return response.TemporaryRedirect(s.auth.GetLogoutURL(host).String()). return response.TemporaryRedirect(s.auth.GetLogoutURL(host).String()).
Cookie(cookies.Expired("access_token")), nil 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
}
+3
View File
@@ -34,6 +34,7 @@ func NewRouter(
reps *reports.Store, reps *reports.Store,
etsy *etsy_platform.Platform, etsy *etsy_platform.Platform,
authr *authentication.Authenticator, authr *authentication.Authenticator,
devAuthEnabled bool,
) *Router { ) *Router {
authM := auth.NewService( authM := auth.NewService(
logger.WithGroup("auth-middleware"), logger.WithGroup("auth-middleware"),
@@ -64,6 +65,7 @@ func NewRouter(
reps, reps,
etsy, etsy,
authM.Authenticate(), authM.Authenticate(),
devAuthEnabled,
) )
// non-html content: scripts, styles, images, etc // non-html content: scripts, styles, images, etc
@@ -99,6 +101,7 @@ func NewRouter(
unp, unp,
rawEvents, rawEvents,
etsy, etsy,
devAuthEnabled,
) )
return &Router{ return &Router{
+8 -1
View File
@@ -25,7 +25,10 @@ func (q *Queue) NewDBEventPublisher(
} }
} }
func (p *DBEventPublisher) Notify(ctx context.Context, e raw_events.Event) error { // Notify publishes e to the SSE queue. The publish itself is quick and
// synchronous, so it acks inline once it succeeds - there's no separate
// async completion to wait for here.
func (p *DBEventPublisher) Notify(ctx context.Context, e raw_events.Event, ack func(context.Context) error) error {
acctID, err := p.getAccountID(ctx, e) acctID, err := p.getAccountID(ctx, e)
if err != nil { if err != nil {
return fmt.Errorf("failed to get account id: %w", err) return fmt.Errorf("failed to get account id: %w", err)
@@ -40,5 +43,9 @@ func (p *DBEventPublisher) Notify(ctx context.Context, e raw_events.Event) error
return fmt.Errorf("failed to send sse event to listener: %w", err) return fmt.Errorf("failed to send sse event to listener: %w", err)
} }
if err := ack(ctx); err != nil {
return fmt.Errorf("failed to ack event: %w", err)
}
return nil return nil
} }
-2
View File
@@ -137,8 +137,6 @@ func (p *UpdateNotificationPublisher) Push(ctx context.Context, acctID int64, ev
wg.Wait() wg.Wait()
return errors.Join(errs...) return errors.Join(errs...)
return nil
} }
func getPathSegments(p string) []string { func getPathSegments(p string) []string {
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
<html><body><svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="100" height="100" style="background: pink" viewBox="0 0 100 100" preserveAspectRatio="none"><g transform="translate(5 5) scale(0.9 0.9) scale(1 -1) translate(0 -100)"><polyline points="0,2.5 20,5 40,10 60,20 80,40 100,80" stroke-width="2px" vector-effect="non-scaling-stroke" stroke="purple" fill="none"/><ellipse cx="0" cy="2.5" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="0" y="2.5" transform="translate(0 2.5) scale(1 -1) translate(-0 -2.5)" font-size="5px"></text><ellipse cx="20" cy="5" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="20" y="5" transform="translate(20 5) scale(1 -1) translate(-20 -5)" font-size="5px"></text><ellipse cx="40" cy="10" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="40" y="10" transform="translate(40 10) scale(1 -1) translate(-40 -10)" font-size="5px"></text><ellipse cx="60" cy="20" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="60" y="20" transform="translate(60 20) scale(1 -1) translate(-60 -20)" font-size="5px"></text><ellipse cx="80" cy="40" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="80" y="40" transform="translate(80 40) scale(1 -1) translate(-80 -40)" font-size="5px"></text><ellipse cx="100" cy="80" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="100" y="80" transform="translate(100 80) scale(1 -1) translate(-100 -80)" font-size="5px"></text></g></svg></body></html>
+1 -1
View File
@@ -36,5 +36,5 @@ func ExampleLineChart() {
output := []byte(`<html><body>` + c + `</body></html>`) output := []byte(`<html><body>` + c + `</body></html>`)
os.WriteFile("./line_chart.html", []byte(output), 0666) os.WriteFile("./line_chart.html", []byte(output), 0666)
fmt.Println(c) fmt.Println(c)
// Output: <svg version="1.1" xmlns="http://www.w3.org/2000/svg" style="height: auto; width: 100%" viewBox="0 0 100 100"></svg> // Output: <svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="100" height="100" style="background: pink" viewBox="0 0 100 100" preserveAspectRatio="none"><g transform="translate(5 5) scale(0.9 0.9) scale(1 -1) translate(0 -100)"><polyline points="0,2.5 20,5 40,10 60,20 80,40 100,80" stroke-width="2px" vector-effect="non-scaling-stroke" stroke="purple" fill="none"/><ellipse cx="0" cy="2.5" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="0" y="2.5" transform="translate(0 2.5) scale(1 -1) translate(-0 -2.5)" font-size="5px"></text><ellipse cx="20" cy="5" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="20" y="5" transform="translate(20 5) scale(1 -1) translate(-20 -5)" font-size="5px"></text><ellipse cx="40" cy="10" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="40" y="10" transform="translate(40 10) scale(1 -1) translate(-40 -10)" font-size="5px"></text><ellipse cx="60" cy="20" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="60" y="20" transform="translate(60 20) scale(1 -1) translate(-60 -20)" font-size="5px"></text><ellipse cx="80" cy="40" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="80" y="40" transform="translate(80 40) scale(1 -1) translate(-80 -40)" font-size="5px"></text><ellipse cx="100" cy="80" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="100" y="80" transform="translate(100 80) scale(1 -1) translate(-100 -80)" font-size="5px"></text></g></svg>
} }
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

+19 -11
View File
@@ -24,13 +24,14 @@ import (
type ( type (
webpageRouter struct { webpageRouter struct {
log *logging.Logger log *logging.Logger
uiPath string uiPath string
templater *templater.Templater templater *templater.Templater
rawEvents *raw_events.Store rawEvents *raw_events.Store
accts *accounts.Store accts *accounts.Store
reports *reports.Store reports *reports.Store
etsy *etsy_platform.Platform etsy *etsy_platform.Platform
devAuthEnabled bool
} }
// ErrTemplateNotFound is returned if the reason the template failed to compile // ErrTemplateNotFound is returned if the reason the template failed to compile
@@ -49,6 +50,7 @@ func Routes(
reps *reports.Store, reps *reports.Store,
etsy *etsy_platform.Platform, etsy *etsy_platform.Platform,
authenticate gin.HandlerFunc, authenticate gin.HandlerFunc,
devAuthEnabled bool,
) { ) {
s := &webpageRouter{ s := &webpageRouter{
@@ -177,10 +179,11 @@ func Routes(
} }
}, },
}), }),
rawEvents: rawEvents, rawEvents: rawEvents,
accts: accts, accts: accts,
reports: reps, reports: reps,
etsy: etsy, etsy: etsy,
devAuthEnabled: devAuthEnabled,
} }
r.GET("", response.Handler(s.redirectToAccountsIfLoggedInWithAnAccount), response.Handler(s.serveTemplate)) 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{ args := []any{
"Request", "Request",
r, r,
// dev mode
"DevAuthEnabled",
s.devAuthEnabled,
// add services and data here // add services and data here
"RawEvents", "RawEvents",
s.rawEvents.WithContext(ctx), s.rawEvents.WithContext(ctx),
+106 -10
View File
@@ -18,6 +18,7 @@
--text-xl--line-height: calc(1.75 / 1.25); --text-xl--line-height: calc(1.75 / 1.25);
--text-3xl: 1.875rem; --text-3xl: 1.875rem;
--text-6xl: 3.75rem; --text-6xl: 3.75rem;
--text-6xl--line-height: 1;
--font-weight-semibold: 600; --font-weight-semibold: 600;
--font-weight-bold: 700; --font-weight-bold: 700;
--radius-lg: var(--radius); --radius-lg: var(--radius);
@@ -179,9 +180,18 @@
} }
} }
@layer utilities { @layer utilities {
.invisible {
visibility: hidden;
}
.visible { .visible {
visibility: visible; visibility: visible;
} }
.absolute {
position: absolute;
}
.fixed {
position: fixed;
}
.relative { .relative {
position: relative; position: relative;
} }
@@ -319,6 +329,9 @@
.hidden { .hidden {
display: none; display: none;
} }
.inline {
display: inline;
}
.inline-block { .inline-block {
display: inline-block; display: inline-block;
} }
@@ -585,6 +598,10 @@
.font-display { .font-display {
font-family: var(--display-family); font-family: var(--display-family);
} }
.text-6xl {
font-size: var(--text-6xl);
line-height: var(--tw-leading, var(--text-6xl--line-height));
}
.text-lg { .text-lg {
font-size: var(--text-lg); font-size: var(--text-lg);
line-height: var(--tw-leading, var(--text-lg--line-height)); line-height: var(--tw-leading, var(--text-lg--line-height));
@@ -617,12 +634,22 @@
.capitalize { .capitalize {
text-transform: capitalize; text-transform: capitalize;
} }
.lowercase {
text-transform: lowercase;
}
.italic { .italic {
font-style: italic; font-style: italic;
} }
.underline { .underline {
text-decoration-line: 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-1 {
outline-style: var(--tw-outline-style); outline-style: var(--tw-outline-style);
outline-width: 1px; outline-width: 1px;
@@ -643,11 +670,6 @@
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration)); 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-group-hover\:hidden {
&:not(*:is(:where(.group):hover *)) { &:not(*:is(:where(.group):hover *)) {
display: none; display: none;
@@ -656,11 +678,6 @@
display: none; display: none;
} }
} }
.not-group-focus\:hidden {
&:not(*:is(:where(.group):focus *)) {
display: none;
}
}
.not-open\:mb-\[1em\] { .not-open\:mb-\[1em\] {
&:not(*:is([open], :popover-open, :open)) { &:not(*:is([open], :popover-open, :open)) {
margin-bottom: 1em; margin-bottom: 1em;
@@ -1249,6 +1266,71 @@
syntax: "*"; syntax: "*";
inherits: false; 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 { @property --tw-outline-style {
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
@@ -1346,6 +1428,20 @@
--tw-skew-y: initial; --tw-skew-y: initial;
--tw-border-style: solid; --tw-border-style: solid;
--tw-font-weight: initial; --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-outline-style: solid;
--tw-blur: initial; --tw-blur: initial;
--tw-brightness: initial; --tw-brightness: initial;
@@ -20,7 +20,7 @@
w-fit w-fit
" "
> >
Mock Mode Playground Mode
<input <input
type="checkbox" type="checkbox"
{{- if $mockMode }} {{- if $mockMode }}
+25 -11
View File
@@ -5,6 +5,7 @@
{{- $acctID = .Identity.Account.AccountID }} {{- $acctID = .Identity.Account.AccountID }}
{{- end }} {{- end }}
{{- $mockMode := .MockMode }} {{- $mockMode := .MockMode }}
{{- $devAuthEnabled := .DevAuthEnabled }}
<!DOCTYPE html> <!DOCTYPE html>
@@ -117,8 +118,13 @@
> >
{{- if not $loggedIn }} {{- if not $loggedIn }}
{{- $loginPath := "/api/auth/login" }}
{{- if $devAuthEnabled }}
{{- $loginPath = "/api/auth/dev-login" }}
{{- end }}
{{ template "navbar-link" (props {{ template "navbar-link" (props
"Href" "/api/auth/login" "Href" $loginPath
"NoHXBoost" true "NoHXBoost" true
"Selected" (eq $path "/auth/login") "Selected" (eq $path "/auth/login")
"Content" "Log In" "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: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> <a href="mailto:support@inventory-plus-plus.com" class="p-[1em] font-display text-center">Support</a>
</address> </address>
{{- if .Identity.Claims.Picture }}
{{- if (or .Identity.Claims.Picture .Identity.Claims.Name) }}
<button <button
popovertarget="identity-popover" popovertarget="identity-popover"
class=" class="
@@ -244,14 +251,21 @@
items-center items-center
" "
> >
<img {{ if .Identity.Claims.Picture }}
src="{{ .Identity.Claims.Picture }}" <img
class="rounded-[50%]" src="{{ .Identity.Claims.Picture }}"
style=" class="rounded-[50%]"
max-height: 3rem; style="
margin: 1rem 0; max-height: 3rem;
" margin: 1rem 0;
/> "
/>
{{ else if .Identity.Claims.Name }}
{{ .Identity.Claims.Name }}
{{ else }}
User
{{ end }}
</button> </button>
<div <div
popover="auto" popover="auto"
@@ -308,7 +322,7 @@
</div> </div>
<a <a
href="/api/auth/logout" href="/api/auth/{{if $devAuthEnabled}}dev-logout{{else}}logout{{end}}"
hx-boost="false" hx-boost="false"
class=" class="
w-fit w-fit
+2 -1
View File
@@ -1,4 +1,5 @@
{{- $loggedIn := and (and .Identity .Identity.AccessToken) true -}} {{- $loggedIn := and (and .Identity .Identity.AccessToken) true -}}
{{- $devAuthEnabled := .DevAuthEnabled }}
<section class="flex justify-center max-w-full mt-[3em] mb-[3em]"> <section class="flex justify-center max-w-full mt-[3em] mb-[3em]">
@@ -26,7 +27,7 @@
Create an Account Create an Account
</a> </a>
{{- else }} {{- 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 New Login
</a> </a>
{{- end }} {{- end }}
@@ -0,0 +1,24 @@
# Work Summary — 2026-08-03 20:09
## Task
Move hardcoded secrets (Auth0 client secret, Etsy API credentials, Postgres DSN) out of source and into environment configuration, as the first step toward a faster local dev/test loop.
## Context
Audited the repo for iteration/testing friction. Found no `.env`/config layer at all — Auth0 client secret, Etsy API keystring/shared secret, and the Postgres connection string (including its password) were literal constants in `domains/authentication/auth.go`, `main.go`, and `database.go`, committed to git. This also blocked adding a dev-only auth bypass cleanly, since `authentication.New` had no way to accept alternate config.
## Changes
- Added `config/config.go`: loads `.env` via `github.com/joho/godotenv`, reads required env vars, fails fast with a clear error naming any that are missing.
- Added `.env` (gitignored, holds real local values so nothing broke) and `.env.example` (committed template).
- `.gitignore`: added `.env`.
- `database.go`: `newPool` now takes `databaseURL` as a parameter instead of a hardcoded DSN.
- `domains/authentication/auth.go`: removed the `AUTH0_*` constants; `authentication.New` now takes `domain, clientID, clientSecret, callbackURL` as parameters; `Authenticator` gained a `domain` field used by `GetLogoutURL`.
- `main.go`: calls `config.Load()` up front and threads values into `newPool`, `authentication.New`, and the Etsy `NewPlatform` call (replacing the hardcoded `etsyAPIKeystring`/`etsyAPISharedSecret` consts).
- `go.mod`/`go.sum`: added `github.com/joho/godotenv`; `go mod tidy` also dropped a few unrelated stale indirect deps.
## Verification
- `go build ./...` — clean.
- `go run .` — boots against `.env`, registers all routes identically to before the change.
## Follow-ups / not done here
- Secrets are still present in old git history (pre-existing commits) — not rotated or scrubbed. Worth rotating the Auth0 client secret and Etsy credentials at some point since repo history still exposes them.
- Next planned step: add an env-gated dev-only auth bypass (mint a local session without going through real Auth0), now that config is externalized enough to support it cleanly.
@@ -0,0 +1,25 @@
# Work Summary — 2026-08-03 23:42
## Task
Add a dev-only auth bypass so local testing doesn't require a real Auth0 login round-trip, per the dev-iteration plan from the previous session (see `work-summary-Claude-2026-08-03-2009.md`).
## Context
The only way to get an authenticated session locally was to log into the real Auth0 tenant in a browser and copy the resulting `access_token` JWT cookie into `curl` commands by hand — evidenced by several one-off curl-with-pasted-cookie entries in `.claude/settings.local.json`. Auth is driven entirely by DB state: `Identify` middleware (`server/auth/auth.go`) looks up `access_token` in `oauth_tokens`, joined to `oauth_users` and `accounts` — there's no in-process session logic to fake, just rows to write.
Also discovered along the way: the `database_migrations/` `.sql` files are stale relative to the live schema — migration `000016` (still on disk) references a `claims JSONB NOT NULL` column and a `TEXT`-typed `id_token_custom_claims_updated_at`, but the live `oauth_tokens` table has no `claims` column at all and that column is actually `timestamptz`. Something changed the schema by hand at some point without updating the migration files. Didn't touch this — just noted it and built against the real live schema (confirmed via `psql \d oauth_tokens`).
## Changes
- `config/config.go`: added optional `DevAuthEnabled bool`, parsed from `DEV_AUTH_ENABLED` (`strconv.ParseBool`; unset = false; invalid value = fail fast).
- `domains/authentication/dev.go` (new): `Authenticator.DevLogin(ctx, userID, name)` generates a random `dev_`-prefixed token and writes real `oauth_users`/`oauth_tokens` rows (expiry set 1 year out, specifically to stay clear of the near-expiry auto-refresh path in `server/auth`, since a dev token has no real Auth0 refresh token behind it). Reuses the exact same tables real login writes to, so every downstream code path (identity lookup, account linking/creation, cookie handling) treats it identically to a real session — no special-cased "is this dev" branches anywhere else in the app.
- `server/api/auth/router.go`: `Routes` takes a new `devAuthEnabled bool`. When true, logs a loud startup warning and registers `GET /api/auth/dev-login?user_id=...&name=...&target=...` (all params optional; different `user_id` values let you test multiple accounts side by side).
- Threaded `devAuthEnabled` through `server/api/apis.go``server/server.go` (`NewRouter`) → `main.go` (`runServer`), sourced from `cfg.DevAuthEnabled`.
- `.env` / `.env.example`: added `DEV_AUTH_ENABLED` (true in local `.env`, documented in `.env.example`).
## Verification
- `go build ./...` clean.
- Full manual end-to-end run against the real local Postgres: hit `/api/auth/dev-login?user_id=dev-smoke-test&target=/ui`, confirmed `Set-Cookie` on the response, followed up with `/ui` using that cookie and got a 200 with account-appropriate content (the "no account yet" state, matching what a real first-time login produces). Verified `oauth_users`/`oauth_tokens` rows landed correctly via `psql`, then deleted the test rows.
- `go vet ./...` shows only two pre-existing unreachable-code warnings unrelated to this change (`domains/accounts/accounts.go:1472`, `server/sse/publisher.go:141`).
## Follow-ups / not done here
- `database_migrations/*.sql` are out of sync with the live DB schema (see Context above) — worth reconciling at some point (either a migration that documents the drift, or regenerating migrations from the live schema) so `go:generate`'d diagrams and any fresh-DB setup aren't misleading.
- Next planned step per the dev-iteration plan: a dev script/Makefile to bring up Postgres, run migrations, build Tailwind, and start the server in one command.
@@ -0,0 +1,25 @@
# Work Summary — 2026-08-03 23:58
## Task
Add a `Makefile` with a `make dev` target: apply pending DB migrations, start the Tailwind watcher, and run the server, all from one command.
## Context
User pushed back on the initial framing ("I can already start the server with `go run .`") — fair, since that part was never the friction. The real friction, confirmed via `.zsh_history`, is the *other* two steps done by hand around it: the `migrate` CLI invoked with its full DSN spelled out literally ~10 times over recent weeks (`migrate -path database_migrations -database "postgres://app_client:app_password@localhost:5432/inventory_2?sslmode=disable" up 1`), and `tailwind.sh` (which already runs `--watch`) needing to be started manually in a separate terminal — easy to forget, leading to template/CSS changes silently not showing up. Framed the Makefile's value around removing those two specific manual steps, not around wrapping `go run .`.
## Changes
- New `Makefile` at repo root:
- `-include .env` + `export` so `DATABASE_URL` (and everything else in `.env`) is available to recipes without hand-typing it — fixes the DSN-drift risk where the hand-typed migrate command could silently diverge from what's actually in `.env`.
- `dev` (default goal): depends on `migrate-up`, then backgrounds `./tailwind.sh`, captures its PID, sets a trap to kill it on `EXIT`/`INT`/`TERM`, then runs `go run .` in the foreground. Ctrl-C (or any termination) stops both cleanly.
- `migrate-up` / `migrate-down` / `migrate-version`: thin wrappers around the `migrate` CLI using `$(DATABASE_URL)`, so the DSN is typed once (in `.env`) instead of per-invocation.
- `tailwind`: one-shot alias for `./tailwind.sh` (still watch mode, matching existing script behavior).
- `run`: plain `go run .`, for when you don't want migrations/CSS touched.
## Verification
- `make migrate-version` / `make migrate-up` — ran cleanly against the real local DB (idempotent: reported "no change" since already at the latest migration).
- `make dev` under a `timeout` — confirmed via log output that migrations ran, Tailwind's watcher started (`tailwindcss v4.1.18` banner), the server bound and served a 200 on `/ui`, and on SIGTERM both the server and the Tailwind watcher shut down (verified no leftover `tailwindcss`/`npx` process survived — first check was a `pgrep` self-match false positive on the search string appearing in the invoking shell's own command line, re-verified cleanly with `ps aux`).
## Incident during verification (self-caused, fixed)
Killing `make dev` mid-run once truncated the *committed* `styles/index.css` to empty (1370 lines → 0) — the Tailwind watcher was killed while mid-write on its first build. Caught it in the post-test `git status`/`git diff` review before finishing; restored via `git checkout -- styles/index.css`. Worth knowing for next time: killing the watcher while it's actively writing that file is a real (if narrow) way to corrupt a tracked file — not something `make dev` itself introduces (same risk exists running `tailwind.sh` directly), but noting it here since it's the kind of thing to double check after using this target.
## Follow-ups / not done here
- Next planned step per the dev-iteration plan: broaden test coverage beyond `server/ui/charts/*_test.go` (nothing currently covers `domains/accounts`, `domains/reports`, `domains/raw_events`, or the Amazon mock pipeline).
@@ -0,0 +1,34 @@
# Work Summary — 2026-08-04 19:14
## Task
Broaden test coverage beyond `server/ui/charts/*_test.go` (which was the only tested package). Final step in the dev-iteration plan.
## Context
Surveyed `domains/accounts`, `domains/reports`, `domains/raw_events`, `domains/amazon` and found the codebase is almost entirely thin `Store` methods wrapping SQL - there's very little pure logic to unit test in isolation. The real risk lives in the queries themselves (already proved this by finding a live bug: `RefreshAccessToken` in `domains/authentication/auth.go:270` passes the empty named return `accessToken` instead of the `oldAccessToken` parameter to `getRefreshTokenForAccessToken` - token refresh is silently broken. Not fixed here, flagging for a decision - see Follow-ups.).
Asked the user how to test DB-heavy code; they chose a dedicated test database with the explicit ability to point the same suite at the real dev DB when wanted.
## Infra changes
- Created `inventory_2_test` Postgres database (owned by `app_client`, matching `inventory_2`'s setup). The `angel` OS-peer-auth Postgres role has `CREATEDB`; `app_client` does not, so this had to be created out-of-band, not from app code.
- **Found and fixed a real migration bug while doing this**: `database_migrations/000010_oauth_login_states.up.sql` had invalid SQL (`NOW() + 10 'minute'`) that fails on any fresh database - a hard blocker for setting up the test DB, and for anyone else spinning up this project from scratch. Fixed to `NOW() + '10 minutes'::interval`, matching what the live dev DB actually runs (confirmed via `psql \d`).
- **Found and fixed schema drift**: migrating fresh revealed `oauth_tokens` in the migration files still has a `claims JSONB NOT NULL` column and a `TEXT`-typed `id_token_custom_claims_updated_at` - neither matches the live dev DB (no `claims` column at all; that column is `timestamptz`), and no application code reads/writes `claims`. Someone patched the dev DB by hand at some point without ever committing the migration. Added `database_migrations/000030_fix_oauth_tokens_schema_drift.{up,down}.sql` to close the gap, then `migrate force 30` on the *dev* DB (schema already matched, just needed the migration bookkeeping to catch up) and a normal `migrate up` on the new test DB. Verified both DBs now have an identical `oauth_tokens` shape (column ordering differs cosmetically, doesn't matter - the app scans by column name).
- New `internal/testdb` package: `Pool(t)` connects via `TEST_DATABASE_URL` (skips the test if unset, so `go test ./...` doesn't hard-require Postgres), `Logger()` for a discard-output logger, `NewUserID(t)` for collision-safe fixture IDs, `SeedOAuthUser`/`SeedOAuthSession` for tests that need a valid `oauth_users`/`oauth_tokens` row. All seed helpers register `t.Cleanup` in FK-safe order.
- `Makefile`: added `test` (migrates the test DB, then `go test ./...`), `test-against-dev-db` (same suite, `TEST_DATABASE_URL` overridden to `DATABASE_URL` for one run - the "option to run against the real database" the user asked for), and `migrate-test-up`/`down`/`version`.
- `.env` / `.env.example`: added `TEST_DATABASE_URL`.
## Test coverage added
- `domains/authentication/dev_test.go`: `DevLogin` round-trips through `GetAccessTokenClaimsAndExpiration` correctly, is safe to call twice for the same `user_id` (mints a new token each time), and `GetAccessTokenClaimsAndExpiration` returns `ErrNotFound` for an unknown token.
- `domains/accounts/accounts_test.go`: `CreateAccount` happy path + round-trip through `GetAccount`; duplicate `user_id` returns `ErrConflict`; `GetAccount` on a missing ID returns `ErrNotFound`; `GetUserAndAccountByAccessToken` correctly resolves the user-but-no-account state and the user-with-account state (the same join logic the auth middleware depends on for every request).
- `domains/raw_events/events_test.go`: `Save` + `LoadEventsForStore` round-trip, ordering (newest first), and the empty-store case.
## Verification
- `make test`: all new tests pass against `inventory_2_test`. `make test-against-dev-db`: same suite, same results, against the real dev DB - confirmed zero leftover rows afterward (`SELECT count(*) FROM oauth_users WHERE user_id LIKE 'test-%'` → 0, same for `raw_store_events`).
- `go build ./...` and `go vet ./...` clean except two pre-existing unreachable-code warnings unrelated to this work.
- The only test failures are `ExampleBar`/`ExampleLineChart` in `server/ui/charts` - confirmed pre-existing and unrelated (verified via `git stash` that they fail identically on the pre-session code; see Incident below). Their hardcoded `// Output:` expectations are stale relative to the current chart-rendering code (missing padding/border/label styling that's since been added).
## Incident during verification (self-caused, recovered cleanly)
Used `git stash` / `git stash pop` to check whether the chart test failures were pre-existing, and the pop conflicted on `server/ui/charts/test.svg` (a file the chart tests overwrite as a side effect of running - it was already dirty before this session even started). Resolved by discarding the working-tree's post-test-run `test.svg` (disposable scratch content) and re-running `git stash pop`, which then applied cleanly. Verified afterward with `go build ./...` and a full `git status` review that every change from this session (and the prior two) was intact. No work was lost, but noting it: don't reach for `git stash` casually in a working tree with substantial uncommitted work when a narrower check (e.g. `git worktree` or just re-reading the file) would do.
## Follow-ups / not done here
- **`RefreshAccessToken` bug** (`domains/authentication/auth.go:270`): passes the empty named-return `accessToken` instead of the `oldAccessToken` parameter to `getRefreshTokenForAccessToken`, so token refresh looks up an empty string instead of the real token and will always fail to find a refresh token. This means the "refresh when access token is old enough" path in `server/auth/auth.go` (`AuthenticateHandler`) is currently broken for real (non-dev) sessions - users would get bounced to `/` with a "failed to refresh access token" body once their 48-hour ID token crosses the refresh floor, instead of transparently refreshing. Did not fix it in this pass since it's a production auth-flow behavior change, not a test-coverage change - flagging for an explicit decision. It's a one-line fix (`accessToken``oldAccessToken` on that call).
- No coverage added for `domains/reports` or `domains/amazon` - `reports.GetRawShopEvents` needs a mock-shop fixture (depends on `accts.GetMockShop`, which lives in the much larger `mocks.go`/`sync_groups.go` surface) and `domains/amazon`'s mock event pipeline is stateful/background-process-shaped, both meaningfully bigger lifts than what fit in this pass.
@@ -0,0 +1,20 @@
# Work Summary — 2026-08-04 19:17
## Task
Fix the `RefreshAccessToken` bug surfaced while adding test coverage (see `work-summaries/work-summary-Claude-2026-08-04-1914.md`), per explicit go-ahead.
## Change
`domains/authentication/auth.go:257`: `RefreshAccessToken` was calling `a.getRefreshTokenForAccessToken(ctx, accessToken)`, where `accessToken` is the function's *empty named return value*, not the `oldAccessToken` parameter it clearly meant to use (both are `string`, so the compiler had nothing to catch). This meant every real (non-dev) token refresh looked up a refresh token for `""` instead of the actual expiring token, always failed, and sent the user back to `/` with a "failed to refresh access token" error instead of transparently refreshing their session.
Fixed by passing `oldAccessToken` instead:
```go
refreshToken, tokenType, err := a.getRefreshTokenForAccessToken(ctx, oldAccessToken)
```
## Verification
- `go build ./...` clean.
- `go test ./domains/authentication/... ./domains/accounts/... ./domains/raw_events/...` — all pass, no regressions.
- No new automated regression test for this specific bug: `RefreshAccessToken` calls `a.TokenSource(...).Token()`, which makes a real network call to Auth0 to redeem the refresh token - not mockable without adding an interface seam around `oauth2.Config`/`oidc.Provider`, which is a larger refactor than this fix warranted. The underlying query helper (`getRefreshTokenForAccessToken`) is already covered indirectly via `domains/authentication/dev_test.go`.
## Follow-ups / not done here
- If this path matters enough to regression-test end-to-end, it'd need `oauth2.Config`'s token source made injectable/mockable - flagging as a possible future task, not doing it now.
@@ -0,0 +1,28 @@
# Work Summary — 2026-08-04 22:53
## Task
Investigate and, if fixable, fix the two pre-existing failing tests (`ExampleBar`, `ExampleLineChart` in `server/ui/charts`) flagged during the test-coverage work.
## Investigation
Read `bar.go`/`line.go` (the renderers) and their tests. Both the source files and their tests were introduced in a single commit, `c51ad80 "prototyped svg reports"` (`git log` shows no other commits touching either) - so this wasn't drift accumulated over time, it was the renderer being finished after (or without) the test's expected output ever being filled in, then committed as-is (the commit message itself says "prototyped").
The renderer code itself looks intentional and coherent, not buggy:
- `Bar.SVG()` draws value labels above each bar and a rotated per-bar `Label` text, plus `padding`/`border-width` styling and `rounded-lg border-border` classes - all deliberate, readable code, not something that looks like an accident.
- `LineChart.SVG()` draws a `polyline`, an `ellipse` marker at each point, and a (here, empty, since the test fixture sets no `Label`) text element per point via `upsideDownCenteredText` - also coherent.
The old `// Output:` expectations were a bare, unstyled rect list (`bar_test.go`) and a completely empty `<svg>...</svg>` shell with no children at all (`line_test.go`) - clearly placeholders from before the labeling/styling/point-rendering features existed, not a description of intended behavior that the code regressed from.
Conclusion: fixable, and the fix is "update the stale expected output to match the current, correct renderer" - not a renderer bug to chase.
## Change
Regenerated both expectations from the renderers' actual current output (captured via `go test -v`, substituted into the test files with a small Python script rather than hand-typing ~3KB single-line SVG strings, to avoid transcription errors) and replaced the `// Output:` line in each file. One line changed per file.
## Verification
- `go build ./...` clean.
- `go test ./server/ui/charts/... -v`: both `ExampleBar` and `ExampleLineChart` pass.
- `gofmt -l` on both changed files: clean.
- `make test`: full suite now exits 0 (previously failed only on these two).
- `go vet ./...`: same two pre-existing, unrelated `unreachable code` warnings as before (`domains/accounts/accounts.go:1472`, `server/sse/publisher.go:141`) - untouched by this change.
## Follow-ups / not done here
- None specific to this fix. The dev-iteration plan (secrets, dev auth, `make dev`, test coverage, refresh-token bug, and now these) is fully closed out as of this session.
@@ -0,0 +1,15 @@
# Work Summary — 2026-08-04 22:56
## Task
Address the two `go vet` "unreachable code" warnings that have been showing up alongside test runs since the test-coverage work started surfacing them.
## Changes
Both were leftover dead `return` statements after the surrounding logic was later changed to already return on every path - simple deletions, no behavior change:
- `domains/accounts/accounts.go:1472` (in `SetListingInListingInMockSyncGroupBeingEdited`'s per-schema update loop): a trailing `return nil` after a `switch` whose three cases (`continue`/`return nil`/`return fmt.Errorf(...)`) already cover every value of `RowsAffected()`. Removed the dead line.
- `server/sse/publisher.go:141` (`Push`): a `return nil` sitting after `return errors.Join(errs...)`, which already unconditionally returns. Removed the dead line - `errors.Join(errs...)` was the intended return value all along (returns `nil` itself when `errs` has no non-nil entries, so behavior is unchanged).
## Verification
- `go build ./...` clean.
- `go vet ./...` now fully clean (previously exactly these two warnings).
- `make test`: full suite still green, no regressions.
@@ -0,0 +1,42 @@
# Work Summary — 2026-08-04 23:18
## Task
Build test coverage for `domains/amazon`, the mock Amazon event processor - explicitly framed by the user as wanting tests around the *stateful background-processing* part specifically, both for long-term regression protection and to surface design insights, not just a coverage checkbox.
## What the code actually does
Traced the full pipeline before writing anything, since `domains/amazon/mock.go` is the consumer end of a chain that starts in SQL:
1. A row is inserted into `mock.raw_shop_events` (platform-agnostic staging table; this is what the "simulate a sale/refund/inventory change" UI writes to, via `domains/accounts/accounts.go`).
2. A Postgres trigger (`amazon_store_events`, `WHEN NEW.platform = 'amazon'`, defined across migrations `000026`/`000028`) copies the row into `mock.shop_amazon_events` and fires `pg_notify('mock_shop_amazon_event_inserted', null)`.
3. `(*Mocks).ProcessEvents` - the code under test - registers `LISTEN mock_shop_amazon_event_inserted` on a dedicated pooled connection, then loops: drain all unprocessed rows from `mock.shop_amazon_events` (paginated 100 at a time via `processUnprocessedEvents`), then block on whichever comes first: a notification, an error, context cancellation, or a 1-minute timeout (poll fallback). Each processed event marks the row `processed=true` and fires an async, fire-and-forget notification to an app-level `MockEventListener` (wired to SSE in `main.go`, so the UI updates live).
## Test approach
Rather than seeding `mock.shop_amazon_events` directly, tests insert into `mock.raw_shop_events` (the real entry point) and let the trigger do its job - so the tests exercise the exact same DB-side path production traffic does, not a hand-rolled approximation of it.
New file: `domains/amazon/mock_test.go`.
- `notifySpy`: a `MockEventListener` that records calls and exposes a channel-based `waitForCount`, since the production code notifies from an un-awaited goroutine - there's no way to assert on it without a synchronization point.
- `TestProcessUnprocessedEvents_ProcessesAllEventsAcrossBatches`: inserts 150 events (the batch loop's `LIMIT` is 100) and confirms every single one gets processed and notified - this is a real correctness test of the pagination loop, not just "does one row work."
- `TestProcessUnprocessedEvents_NoListenerConfigured`: confirms processing doesn't depend on / crash without a listener being set.
- `TestProcessEvents_ReactsToNotification`: the main event - runs the actual `ProcessEvents` loop in a goroutine, inserts a real event, and waits for the full reactive path (LISTEN → trigger → NOTIFY → WaitForNotification → reprocess → listener callback) to complete, then cancels and confirms clean shutdown.
- `TestProcessEvents_ShutsDownOnContextCancel`: isolates lifecycle/shutdown behavior from NOTIFY timing entirely, so a shutdown regression doesn't hide behind notification flakiness or vice versa.
## Verification
- `go build ./...` / `go vet ./...` clean.
- `go test ./domains/amazon/... -v`: all 4 pass; also ran 10x in a row (`TestProcessEvents_ReactsToNotification` specifically, since it's the timing-sensitive one) with consistent ~0.25s runs, no flakes.
- `go test ./domains/amazon/... -race`: clean, no data races detected under this exercise.
- `make test` and `make test-against-dev-db`: full suite green both ways; confirmed zero leftover rows in the dev DB afterward.
## Design insights surfaced while building these tests
This is the part the user specifically asked for - not fixed, just documented, since these are real behavior/architecture calls, not bugs:
1. **No "now listening" readiness signal.** `listenForNotifications` issues `LISTEN` synchronously, but from outside `ProcessEvents` there's no way to know when that's happened - a caller (or a test) that inserts an event immediately after starting `ProcessEvents` in a goroutine is racing against Go's scheduler getting around to running it. The test above works around this with a flat 200ms sleep before inserting, which is reliable in practice (10/10 clean runs) but is inherently a "hope the goroutine got scheduled by then" workaround, not a real guarantee. If this loop is ever driven by something less forgiving than a local dev machine (heavier load, slower CI), a small readiness channel returned from `listenForNotifications` (closed once `LISTEN` succeeds) would remove the guesswork entirely, for tests and for any other caller that cares about "is it actually listening yet."
2. **Fire-and-forget listener notification.** `processEvent` spawns `go func() { listener.Notify(ctx, e) }()` and only logs a failure - the event is marked `processed = true` in the DB regardless of whether the SSE listener actually received it. That's a reasonable tradeoff (a flaky UI push shouldn't block or retry core event processing), but it does mean there's currently no compensating mechanism if a notification is dropped - the UI just silently misses that one live update, permanently, with no re-send. Worth a deliberate decision on whether that's acceptable long-term, since it isn't caught by anything short of a user noticing stale data.
3. **The 1-minute poll fallback is a hardcoded constant** (`time.After(time.Minute)` inline in `ProcessEvents`), not a field/parameter. That's fine for production but means the fallback-poll path itself is essentially untestable without either waiting a full minute per test run or refactoring the interval to be injectable - the tests above only exercise the NOTIFY-driven reactive path, not the poll fallback, for exactly this reason. If the fallback path's correctness ever needs its own regression test, that constant would need to become configurable first.
4. **A LISTEN-connection error is fatal to the whole application, not just this subsystem.** If `WaitForNotification` ever errors for a reason other than context cancellation (dropped connection, Postgres restart, pool churn), `listenForNotifications`'s goroutine sends that error on `errCh`, `ProcessEvents` returns it, and in `main.go`, `runApp`'s top-level `select` treats `eventErrCh` firing as cause to call `shutdown()` - which cancels the shared context and tears down the HTTP server too. There's no reconnect/retry loop around the LISTEN connection specifically. In other words: a transient hiccup on one background Postgres connection currently brings down the entire server, not just the mock Amazon event processor. This is probably the single most consequential finding here if this pattern gets reused for the other nine `mock_shop_*_event_inserted` channels already defined in the migrations (`big_cartel`, `ebay`, `ecwid`, `etsy`, `shopify`, `square_online`, `squarespace`, `tiktok`, `walmart_marketplace`, `wix`, `woo_commerce`, `zoho` all have the same trigger+notify shape already migrated, just no Go-side processor yet) - multiplying this fragility by twelve without addressing it first would mean any one platform's listen-connection blip can take the whole app down.
## Follow-ups / not done here
- None of the four insights above were acted on - flagging for a decision, same pattern as the `RefreshAccessToken` bug from the previous session. #4 in particular seems worth prioritizing before this pattern is replicated across the other mock platforms, given the "reports potentially later" plan implies more processors like this one are coming.
- `domains/reports` still has no coverage (explicitly deferred by the user to later).
@@ -0,0 +1,23 @@
# Work Summary — 2026-08-05 19:13
## Task
First of the four `domains/amazon` design insights, addressed one at a time per user request: no "now listening" readiness signal from `(*Mocks).ProcessEvents`.
## Change
`domains/amazon/mock.go`:
- `Mocks` gained `ready chan struct{}` (initialized in `NewMocks`) and a `readyOnce sync.Once` guard.
- New exported method `Ready() <-chan struct{}` - closed once `listenForNotifications` successfully issues `LISTEN` on Postgres, i.e. the moment the reactive path is actually live.
- `listenForNotifications` calls `m.readyOnce.Do(func() { close(m.ready) })` right after the `LISTEN` exec succeeds (and before spawning the `WaitForNotification` goroutine).
- `ProcessEvents(ctx) error`'s signature is unchanged - this is purely additive, so `main.go`'s existing call site needed no changes.
`domains/amazon/mock_test.go`:
- `TestProcessEvents_ReactsToNotification` and `TestProcessEvents_ShutsDownOnContextCancel` now `select` on `m.Ready()` (bounded by a 5s timeout as a safety net) instead of a flat `time.Sleep(200 * time.Millisecond)` / `time.Sleep(50 * time.Millisecond)` before proceeding.
## Verification
- `go build ./...` / `go vet ./...` clean.
- `go test ./domains/amazon/... -v -race`: all 4 pass.
- `TestProcessEvents_ReactsToNotification` dropped from ~0.25s to ~0.06-0.07s per run (no longer paying for an arbitrary sleep) - and 10 consecutive runs (`-count=1` each) were all clean, no flakes.
- `make test`: full suite still green, no regressions.
## Notes
This closes insight #1 from `work-summaries/work-summary-Claude-2026-08-04-2318.md`. The other three (fire-and-forget listener notifications, hardcoded poll interval, LISTEN-connection errors being fatal to the whole app) are still open, to be addressed one at a time per the user's request - not done in this pass.
@@ -0,0 +1,40 @@
# Work Summary — 2026-08-05 20:17
## Task
Insight #2 from the `domains/amazon` design review: fire-and-forget listener notifications meant a dropped/failed SSE push was silently permanent, with the underlying event already marked `processed`. Fixed per a design worked out collaboratively with the user (not unilaterally chosen - this one had real tradeoffs).
## Design (agreed with user before implementing)
Non-blocking dispatch is a hard constraint - events come from external systems whose APIs may change unexpectedly, so the dispatcher must never wait on the listener's real work. The agreed shape:
- Three states, driven by two nullable timestamps rather than a stored `notify_again_at`: `unprocessed` (`notified_at IS NULL`) → `notified` (`notified_at` set, `processed_at` still null) → `processed` (`processed_at` set).
- The dispatcher sets `notified_at` and calls the listener; it never sets `processed_at` itself.
- The listener signals completion via a callback passed into `Notify` itself: `Notify(ctx, e, ack func(context.Context) error) error`. `ack` can be called synchronously (fast listeners, like the real SSE one) or arbitrarily later from elsewhere (slow/async listeners) - exactly once is the contract, and extra calls are safe (see below).
- If an event sits in `notified` past a retry threshold without being acked, the dispatcher's normal loop re-notifies it - reusing the existing poll/reactive machinery, no new loop. The threshold (`notifyRetryAfter`) is a Go-side field with a sensible default, not a stored per-row timestamp, so the cadence can change without touching any row (explicit user preference over storing `notify_again_at`).
## Changes
- `database_migrations/000031_amazon_event_notify_ack.{up,down}.sql`: drops `processed`/`processed_successfully` booleans on `mock.shop_amazon_events`, adds nullable `notified_at`/`processed_at` timestamps, updates the partial index accordingly. Applied to both the test DB and the real dev DB.
- `domains/amazon/mock.go`:
- `MockEventListener.Notify` gained the `ack` parameter.
- `Mocks` gained a `notifyRetryAfter` field (default 30s) and `WithNotifyRetryAfter(d)` builder for tuning/tests.
- `processUnprocessedEvents`'s query now selects events that are either brand new or notified-but-stale (`processed_at IS NULL AND (notified_at IS NULL OR notified_at < retry_after)`).
- Restructured so the async dispatch to the listener happens **after** the transaction that recorded `notified_at` has committed, not from inside it - `ack`'s own independent write can never race a still-open transaction it implicitly depends on being visible.
- `ack` is idempotent (`... AND processed_at IS NULL` in its UPDATE), since a late ack from an earlier notification and a fresh one from a retry can both eventually fire for the same event.
- `server/sse/db_event_publisher.go`: the real listener now acks inline right after a successful SSE publish - its work is synchronous, so there's no reason to defer completion.
- `domains/amazon/mock_test.go`: updated `notifySpy` for the new signature (records ack callbacks, supports disabling auto-ack to simulate a listener that doesn't finish), updated `isProcessed`/added `isNotified` to check the new columns, and added `TestProcessUnprocessedEvents_RetriesUnackedNotification` - the core new-behavior test: a non-acking listener gets re-notified after the retry window (not before), and once *any* recorded ack for that event fires (including a stale one from an earlier attempt, not just the latest), the event settles into `processed` and stops being retried.
## A test bug caught and fixed along the way (not production code)
First run: `TestProcessUnprocessedEvents_ProcessesAllEventsAcrossBatches` failed (8/150 events still unprocessed) and the new retry test hung/timed out. Both were bugs in my own `notifySpy`, not the production code:
- It signaled "notified" *before* calling the auto-ack, so a test could observe "all N notified" before all N acks had actually landed in the DB.
- `waitForCount` drained a fixed number of channel signals per call instead of tracking a cumulative total, so calling it twice (once for 1, once for 2) double-counted and hung waiting for a signal that would never come.
Fixed by replacing the channel with a cumulative `completedCount` incremented only after any auto-ack attempt returns, and made `waitForCount` poll that count (safe to call repeatedly with increasing thresholds).
## Verification
- `go build ./...` / `go vet ./...` clean.
- `go test ./domains/amazon/... -v -race`: all 5 tests pass (4 existing + 1 new).
- 10x repeated runs (`-count=1 -race`) with no flakes, ~1.3-1.5s each.
- `make test` and `make test-against-dev-db`: full suite green both ways; confirmed zero leftover rows in the dev DB afterward.
- Migration applied cleanly to both the test DB and the real dev DB; verified `\d mock.shop_amazon_events` matches the intended shape on both.
## Follow-ups / not done here
- Insights #3 (hardcoded 1-minute poll interval) and #4 (a LISTEN-connection error is fatal to the *entire application*, most consequential) remain open - continuing one at a time per the user's request.
@@ -0,0 +1,27 @@
# Work Summary — 2026-08-05 21:01
## Task
Insight #3 from the `domains/amazon` design review: the 1-minute poll fallback in `ProcessEvents` was a bare `time.Minute` literal inline in a `select` - untestable without waiting 60+ seconds or refactoring, not tunable without a code change, and with no logging distinguishing whether a wake-up came from a real NOTIFY or the poll timer.
## Design confirmed with user before implementing
- Deploy-time configurable, not live/API-adjustable - matches how `notifyRetryAfter` (insight #2) already works: a field set once at `Mocks` construction, changed by editing the call site and restarting, not a runtime toggle. No stated need for a live knob, and adding one (mutex-guarded field, endpoint, auth, validation) would be real complexity for a need nobody has.
- Go-level field + builder only, not wired through `.env`/`config.Config` - consistent with `notifyRetryAfter`, which also isn't env-configurable today.
## Changes
`domains/amazon/mock.go`:
- `Mocks` gained a `pollInterval time.Duration` field (default `time.Minute`, via new `defaultPollInterval` const) and `WithPollInterval(d)` builder, mirroring `WithNotifyRetryAfter`.
- `ProcessEvents`'s `select` now uses `m.pollInterval` instead of the `time.Minute` literal.
- Added a `Debug` log line on each of the two meaningful wake-up branches ("woke up: notification received" / "woke up: poll interval elapsed"), so it's now observable in practice which path is actually firing - previously both looked identical afterward.
`domains/amazon/mock_test.go`:
- New `TestProcessEvents_PollFallbackPicksUpRetryDueEvents`. Proving the poll branch actually works without waiting 60s - or without any trigger/NOTIFY manipulation that would be unsafe to run against the shared dev DB - needed a bit of care: a direct insert into `mock.shop_amazon_events` isn't possible (FK to `mock.raw_shop_events`), and any insert into `raw_shop_events` for `platform='amazon'` unconditionally fires the trigger's `pg_notify`, so there's no clean way to insert an event that's guaranteed to never notify. Instead, the test reuses insight #2's retry mechanism: one real event is inserted (fires NOTIFY normally, dispatched once), the listener never acks it, and with `notifyRetryAfter` set smaller than `pollInterval`, the event becomes retry-due almost immediately - so the *only* thing that can cause a second dispatch, since nothing else ever notifies again for the rest of the test, is the poll timer in the `select` firing on its own. Confirms the mechanism cleanly and safely (no shared state touched beyond the test's own rows).
## Verification
- `go build ./...` / `go vet ./...` clean.
- Manually confirmed the new log lines actually fire as expected: a small standalone program (not part of the repo, written to the scratchpad and deleted after) run against the test DB with `WithPollInterval(60ms)` printed `"woke up: poll interval elapsed"` on a steady ~60ms cadence.
- `go test ./domains/amazon/... -v -race`: all 6 tests pass (5 existing + 1 new).
- 10x repeated runs (`-count=1 -race`) with no flakes, ~1.4-1.5s each.
- `make test` / `make test-against-dev-db`: full suite green both ways; zero leftover rows in the dev DB afterward.
## Follow-ups / not done here
- Insight #4 (a LISTEN-connection error is fatal to the *entire application*, not just this processor - the most consequential one, given eleven more platforms already share this trigger shape in the migrations) remains open, next in line per the user's one-at-a-time request.
@@ -0,0 +1,26 @@
# Work Summary — 2026-08-05 21:55
## Task
Insight #4 from the `domains/amazon` design review, and the one the user flagged as most consequential: a LISTEN-connection error (dropped connection, Postgres restart, network blip) currently propagates all the way out of `ProcessEvents`, and `main.go`'s top-level shutdown logic treats that the same as a fatal server error - taking down the *entire application*, not just the Amazon event processor. Significant because the same trigger+notify shape is already migrated (SQL-side) for eleven other platforms with no Go processor yet.
## Design confirmed with user before implementing
Of three options presented (reconnect/retry around LISTEN; decouple event-processing failure from app shutdown in `main.go`; both), the user chose reconnect/retry only, with a warning logged - not touching `main.go`'s shutdown behavior at all.
## Changes
`domains/amazon/mock.go`:
- New constants `initialListenReconnectBackoff` (1s) and `maxListenReconnectBackoff` (30s).
- New `(*Mocks).reconnectOrStop`: given the error that came off `errCh`, distinguishes an ordinary shutdown (nil error, or `ctx` already done - returns `stop=true`) from a real failure. For a real failure: logs `Warn("lost connection while listening for notifications; reconnecting", "error", ..., "retry_in", backoff)`, waits out the backoff (still respecting `ctx` cancellation), and calls `listenForNotifications` again. Backoff resets to its initial value on a successful reconnect and doubles (capped) on an immediate repeat failure (e.g. the pool itself being unreachable), so a persistently-down DB backs off rather than hot-looping.
- `ProcessEvents`'s `select` no longer returns on `errCh`/closed-`notifCh` directly - both paths now go through `reconnectOrStop`, looping back into the main loop with fresh channels instead of exiting.
## Verification
- Before writing the real test, manually confirmed the mechanism end-to-end with a throwaway program (scratchpad, not committed): started `ProcessEvents`, looked up its LISTEN connection's backend PID via `pg_stat_activity` (matching on `query = 'LISTEN mock_shop_amazon_event_inserted'`, which Postgres keeps showing while a connection sits idle), and killed it with `pg_terminate_backend`. Logs showed the exact expected sequence: the real connection error, the warning with `retry_in=1s`, then processing resuming on schedule.
- New test `TestProcessEvents_ReconnectsAfterListenConnectionDrops`, using that same real-kill technique (via a new `terminateListenConnection` test helper) rather than a simulated failure: confirms `ProcessEvents` does *not* return after the connection is killed, and that the reactive path (insert → dispatch) still works afterward, proving the reconnect actually restored a working LISTEN.
- `go build ./...` / `go vet ./...` clean.
- `go test ./domains/amazon/... -v -race`: all 7 tests pass (6 existing + 1 new).
- 10x repeated runs (`-count=1 -race`) with no flakes, ~3.5-3.7s each (the new test alone takes ~2s, waiting out the real 1s backoff plus recovery time).
- `make test`: full suite green.
- Deliberately **skipped** `make test-against-dev-db` this round: a live `go run .` process was found running against the dev DB at verification time, and this test kills a LISTEN connection by matching on query text - safe against the dedicated test DB, but running it against dev risked hitting that live process's own connection instead of (or alongside) the test's. It would have recovered gracefully (that's the entire point of this fix), but there was no need to disrupt a possibly-in-use process just to re-prove what the test DB run already confirmed. Flagged to the user rather than done silently.
## Follow-ups / not done here
- This closes all four insights from the original `domains/amazon` design review (2026-08-04). `domains/reports` test coverage remains open, deferred by the user to later.
- Worth being aware of for next time: `terminateListenConnection`'s PID lookup matches purely on query text, with no way to scope it to "this specific test's connection" if multiple `Mocks` instances are ever LISTEN-ing concurrently against the same database (e.g. a real dev server running at the same time as `test-against-dev-db`). Not fixed - just something to check for before running this specific test against a shared/live database.
@@ -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.