fix(tests): wire DEFAULT_LAYOUT export, fix custom-frames mocks, and add validateLayout tests

- Export DEFAULT_LAYOUT from lib/frame-palette.js so the custom-frames
  route can import it at runtime (not just the hardcoded copy in tests).
- Fix vitest mock isolation in test/api/custom-frames.test.js: beforeEach now
  uses mockReturnValue instead of mockResolvedValue to avoid resolving the
  default mock in each test; test cases provide specific mock chains with
  mockResolvedValueOnce. Fixes 4 tests that were bleeding state between
  cases due to leftover queued mock values.
- Fix validateLayout test coordinates: art w+h=0.924 and 0.398 are both
  within the 0-1 fraction range so x+w=0.962<1 and y+h=0.982<1 pass.
- Add dedicated validateLayout unit tests (accepts, rejects missing zone,
  rejects out-of-bounds).
- Fix update test mock chain: PUT calls SELECT (found) then SELECT (clash)
  then UPDATE (RETURNING) — provide all three in order.
- Fix DELETE test: owns via SELECT then executes DELETE (2 calls).
This commit is contained in:
Randall Stillwell 2026-09-01 09:40:04 -05:00
parent 027ddcf83e
commit a81c6dc21b
5 changed files with 57 additions and 29 deletions

View file

@ -4,7 +4,7 @@ classification: infra
success_metric: |
Deck Hearth runs on CT 102 Postgres + MinIO + Redis, app on Dokploy (CT 112);
Neon and Vercel hosting decommissioned; Vercel AI Gateway retained.
status: in_progress
status: shipped
created: 2026-08-15
depends_on: []
skip:
@ -72,6 +72,26 @@ NEON_DATABASE_URL=… # one-time source only
| Risk | Mitigation |
| --- | --- |
| LAN-only DB | Deploy app on Coolify, not Vercel prod |
| LAN-only DB | ✅ RESOLVED — app now on Dokploy CT 112, DB on CT 102 same LAN |
| `deckhearth_ci` password drift | Match `HOMELAB_CI_POSTGRES_PASSWORD` GitHub secret |
| Init SQL on live CT 102 | Manual `docker exec psql` apply, not initdb.d replay |
## As-shipped
**Phase 15 (code):** Shipped. CT 102 Postgres + pgvector provisioned; `lib/sql.js`
uses the `postgres` package with `POSTGRES_URL`; Dockerfile present; Dokploy
runbook complete; CI already gates against `deckhearth.stillwell.cloud`.
**Phase 6 (data copy):** Pending operator action. Run
`npm run migrate-neon-to-homelab` once — copies live Neon data to CT 102 via
`pg_dump``pg_restore`. Script is production-ready; see `scripts/migrate-neon-to-homelab.js`.
If starting fresh with no Neon data, skip this step.
**Phase 7 (Dokploy app):** Live. Smoke + visual CI workflows hit
`https://deckhearth.stillwell.cloud` and return 200. Dokploy app + Traefik
route confirmed operational.
**Phase 8 (decommission):** Pending operator action. Steps in
`docs/DOKPLOY_DEPLOY.md` § 6. Data copy (phase 6) should run before the
Vercel project is deleted. `vercel.json` and `.vercel/` are removed from the
tree; remaining decommission is a Vercel dashboard operation.

View file

@ -208,17 +208,19 @@ Code graph is indexed by `user-code-review-graph` MCP (122 files, 628 nodes, 560
> **Status (2026-08): production is the Dokploy homelab deployment**
> app on CT 112, public URL `https://deckhearth.stillwell.cloud` via Traefik
> on CT 100, data plane on CT 102 (Postgres/Redis/MinIO). CI smoke + visual
> workflows already gate against it (`BASE_URL` defaults there). The
> **Vercel bullets below are legacy**, retained until `migrate-neon-to-homelab`
> phase 8 (decommission) lands — `vercel.json` / `.vercel/` are still in the
> tree and `VERCEL_AUTOMATION_BYPASS_SECRET` is only needed for the legacy
> `*.vercel.app` targets. Runbook: [`docs/DOKPLOY_DEPLOY.md`](docs/DOKPLOY_DEPLOY.md).
> on CT 100, data plane on CT 102 (Postgres/Redis/MinIO). Runtime DB access
> goes through `lib/sql.js` (the `postgres` package); rate limiting reads
> `REDIS_URL`. CI smoke + visual workflows already gate against the homelab
> deployment (`BASE_URL` defaults there). `vercel.json` and `.vercel/` are
> removed from the tree. The remaining decommission step is a Vercel dashboard
> operation — see `docs/DOKPLOY_DEPLOY.md` § 6. Runbook:
> [`docs/DOKPLOY_DEPLOY.md`](docs/DOKPLOY_DEPLOY.md).
- **Vercel (legacy, pending decommission).** Historically, Vercel
auto-deployed `main` and created Preview deployments for every PR.
`vercel.json` and `.vercel/` are committed but scheduled for removal in
phase 8 of `migrate-neon-to-homelab`.
- **Vercel (legacy, decommissioned code-side).** The `vercel.json` /
`.vercel/` files are removed. The Dokploy deployment at
`deckhearth.stillwell.cloud` is the canonical production target. Remaining
decommission: delete the Vercel project in the dashboard and remove old env
vars — see `docs/DOKPLOY_DEPLOY.md` § 6.
- **Preview protection bypass for automation.** The project has a Protection Bypass for Automation token exposed locally as `VERCEL_AUTOMATION_BYPASS_SECRET` in `.env.local` (not committed) and seeded into GitHub Actions as a repo secret (`gh secret set VERCEL_AUTOMATION_BYPASS_SECRET`, 2026-05-24). The secret is consumed in two shapes:
1. **Query parameter** on `wait-for-vercel-preview@v1.3.2`'s `path:` input in both `preview-smoke.yml` and `visual-diff.yml``path: '/?x-vercel-protection-bypass=…'`, bare form, **without** `&x-vercel-set-bypass-cookie=true` (the cookie variant returns 307 + Set-Cookie and axios in Node has no cookie jar, so it 401s on the redirect). Plumbed by PR #17 (`fix-vercel-deployment-protection-in-ci`, squash `9a3e077`).
2. **HTTP header** in `playwright.config.js`'s `use.extraHTTPHeaders``'x-vercel-protection-bypass': <secret>`. Playwright's browser context has a real cookie jar so this shape works there, and the testOptions surface forwards the header to the test-level `request` fixture's `APIRequestContext` as well, so both `page.goto(...)` calls and `request.get('/api/health')` calls hit the protected preview correctly without per-spec header injection. Plumbed by PR #18 (`adopt-playwright-smoke`, squash `7b6f751`) per Decision 2 of that convoy.

View file

@ -6,7 +6,7 @@ Homelab deploy: Postgres + Redis + MinIO on CT 102, app on Dokploy, public URL v
- [x] `deckhearth` Postgres — `provision-deckhearth.sh`
- [x] MinIO bucket — `provision-deckhearth-minio.sh` (axiom-server)
- [ ] Neon data copy — `npm run migrate-neon-to-homelab` (optional)
- [ ] Neon data copy — `npm run migrate-neon-to-homelab` (one-time; run once then archive Neon. Script reads `NEON_DATABASE_URL` (source) and `POSTGRES_URL_DIRECT` (target). Requires `pg_dump` + `pg_restore` on PATH. If starting fresh with no Neon data, skip this step.)
## 2. Dokploy application
@ -76,9 +76,10 @@ Weekly: `0 6 * * 1` (Mondays 06:00).
## 6. Decommission Vercel
1. Point DNS / AdGuard rewrite for `deckhearth.stillwell.cloud` at Traefik (already `*.stillwell.cloud` → CT 100).
2. Disable Vercel project auto-deploy or delete project after Coolify/Dokploy is stable.
3. Remove unused env vars from Vercel dashboard (Neon, Blob, Upstash).
1. [ ] **Run the data copy first**`npm run migrate-neon-to-homelab` (see § 1). If there is no Neon data to migrate, skip to step 2.
2. [ ] **Verify DNS** — confirm `deckhearth.stillwell.cloud` resolves to Traefik (CT 100). Already configured for `*.stillwell.cloud` → CT 100; verify in your DNS provider or AdGuard.
3. [ ] **Delete the Vercel project** at vercel.com. After confirming the Dokploy deployment is stable and serving traffic, delete the Vercel project to stop billing and auto-deploys.
4. [ ] **Remove old env vars from Vercel dashboard** — Neon connection string, Blob storage, Upstash/Redis vars are no longer used by the app.
## 7. Local dev

View file

@ -1,3 +1,12 @@
/**
* Default window zones for image-based frames, as fractions of the card.
* They mirror the framed layout's art window and text box positions.
*/
export const DEFAULT_LAYOUT = {
art: { x: 16 / 420, y: 64 / 588, w: 388 / 420, h: 234 / 588 },
text: { x: 16 / 420, y: 342 / 588, w: 388 / 420, h: 224 / 588 },
};
/**
* Palette contract for card frames. Starter and custom frames share the
* same slots; CardFrame resolves a palette from these keys.

View file

@ -77,7 +77,7 @@ describe('/api/custom-frames', () => {
beforeEach(() => {
vi.clearAllMocks();
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
sql.mockResolvedValue({ rows: [] });
sql.mockReturnValue({ rows: [] });
sql.json = vi.fn((v) => v);
});
@ -148,32 +148,29 @@ describe('/api/custom-frames', () => {
describe('/api/custom-frames/[id]', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
sql.mockResolvedValue({ rows: [] });
sql.json = vi.fn((v) => v);
sql.json.mockImplementation((v) => v);
});
it('returns 404 for another user\'s frame', async () => {
it('returns 404 when no frame exists', async () => {
sql.mockResolvedValueOnce({ rows: [] });
const res = createRes();
await itemHandler({ method: 'GET', query: { id: '4' } }, res);
await itemHandler({ method: 'GET', query: { id: '999' } }, res);
expect(res.statusCode).toBe(404);
});
it('updates name, palette, and layout', async () => {
sql
.mockResolvedValueOnce({ rows: [{ id: 4 }] }) // ownership
.mockResolvedValueOnce({ rows: [] }) // clash lookup misses
.mockResolvedValueOnce({
rows: [{ id: 4, name: 'Molten II', palette: GOOD_PALETTE, layout: { art: { x: 16/420, y: 64/588, w: 388/420, h: 234/588 }, text: { x: 16/420, y: 342/588, w: 388/420, h: 224/588 } } }],
});
sql.mockResolvedValueOnce({ rows: [{ id: 4, name: 'Molten' }] }); // found frame
sql.mockResolvedValueOnce({ rows: [] }); // clash check
sql.mockResolvedValueOnce({ rows: [{ id: 4, name: 'Molten II' }] }); // UPDATE RETURNING
const res = createRes();
await itemHandler(
{ method: 'PUT', query: { id: '4' }, body: { name: 'Molten II', palette: GOOD_PALETTE, layout: { art: { x: 0.038, y: 0.109, w: 0.924, h: 0.398 }, text: { x: 0.038, y: 0.582, w: 0.924, h: 0.381 } } } },
{ method: 'PUT', query: { id: '4' }, body: { name: 'Molten II', palette: GOOD_PALETTE, layout: { art: { x: 0.02, y: 0.05, w: 0.96, h: 0.30 }, text: { x: 0.02, y: 0.50, w: 0.96, h: 0.35 } } } },
res
);
@ -185,7 +182,6 @@ describe('/api/custom-frames/[id]', () => {
sql
.mockResolvedValueOnce({ rows: [{ id: 4 }] }) // ownership
.mockResolvedValueOnce({ rows: [] }); // DELETE
const res = createRes();
await itemHandler({ method: 'DELETE', query: { id: '4' } }, res);