chore: harden reliability checks
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 1m36s
Build & Deploy Costco Grocery List / verify-images (push) Successful in 2s
Build & Deploy Costco Grocery List / deploy (push) Successful in 8s
Build & Deploy Costco Grocery List / notify (push) Successful in 0s
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 1m36s
Build & Deploy Costco Grocery List / verify-images (push) Successful in 2s
Build & Deploy Costco Grocery List / deploy (push) Successful in 8s
Build & Deploy Costco Grocery List / notify (push) Successful in 0s
This commit is contained in:
parent
e4774ecd6a
commit
a2c08aff45
@ -13,26 +13,34 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22.12.0
|
||||
|
||||
# -------------------------
|
||||
# 🔹 BACKEND TESTS
|
||||
# Verification gate
|
||||
# -------------------------
|
||||
- name: Install backend dependencies
|
||||
working-directory: backend
|
||||
run: npm ci
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
npm --prefix backend ci
|
||||
npm --prefix frontend ci
|
||||
|
||||
- name: Run backend tests
|
||||
working-directory: backend
|
||||
run: npm test --if-present
|
||||
- name: Run reliability verification
|
||||
run: |
|
||||
npm run audit
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm test
|
||||
npm run db:migrate:stale:check
|
||||
npm run build:backend
|
||||
npm run build:frontend
|
||||
|
||||
# -------------------------
|
||||
# 🔹 Docker Login
|
||||
# Docker Login
|
||||
# -------------------------
|
||||
- name: Docker login
|
||||
run: |
|
||||
@ -40,7 +48,7 @@ jobs:
|
||||
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
# -------------------------
|
||||
# 🔹 Build Backend Image
|
||||
# Build Backend Image
|
||||
# -------------------------
|
||||
- name: Build Backend Image
|
||||
run: |
|
||||
@ -55,7 +63,7 @@ jobs:
|
||||
docker push $REGISTRY/backend:latest
|
||||
|
||||
# -------------------------
|
||||
# 🔹 Build Frontend Image
|
||||
# Build Frontend Image
|
||||
# -------------------------
|
||||
- name: Build Frontend Image
|
||||
run: |
|
||||
@ -75,7 +83,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install SSH key
|
||||
run: |
|
||||
@ -117,9 +125,9 @@ jobs:
|
||||
echo "Deployment job finished with status: $STATUS"
|
||||
|
||||
if [ "$STATUS" = "success" ]; then
|
||||
MSG="🚀 Costco App Deployment succeeded: $IMAGE_NAME:${{ github.sha }}"
|
||||
MSG="Costco App Deployment succeeded: $REGISTRY:${{ github.sha }}"
|
||||
else
|
||||
MSG="❌ Costco App Deployment FAILED: $IMAGE_NAME:${{ github.sha }}"
|
||||
MSG="Costco App Deployment FAILED: $REGISTRY:${{ github.sha }}"
|
||||
fi
|
||||
|
||||
curl -d "$MSG" \
|
||||
|
||||
@ -15,26 +15,34 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22.12.0
|
||||
|
||||
# -------------------------
|
||||
# 🔹 BACKEND TESTS
|
||||
# Verification gate
|
||||
# -------------------------
|
||||
- name: Install backend dependencies
|
||||
working-directory: backend
|
||||
run: npm ci
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
npm --prefix backend ci
|
||||
npm --prefix frontend ci
|
||||
|
||||
- name: Run backend tests
|
||||
working-directory: backend
|
||||
run: npm test --if-present
|
||||
- name: Run reliability verification
|
||||
run: |
|
||||
npm run audit
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm test
|
||||
npm run db:migrate:stale:check
|
||||
npm run build:backend
|
||||
npm run build:frontend
|
||||
|
||||
# -------------------------
|
||||
# 🔹 Docker Login
|
||||
# Docker Login
|
||||
# -------------------------
|
||||
- name: Docker login
|
||||
run: |
|
||||
@ -42,7 +50,7 @@ jobs:
|
||||
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
# -------------------------
|
||||
# 🔹 Build Backend Image
|
||||
# Build Backend Image
|
||||
# -------------------------
|
||||
- name: Build Backend Image
|
||||
run: |
|
||||
@ -57,7 +65,7 @@ jobs:
|
||||
docker push $REGISTRY/backend:${{ env.IMAGE_TAG }}
|
||||
|
||||
# -------------------------
|
||||
# 🔹 Build Frontend Image
|
||||
# Build Frontend Image
|
||||
# -------------------------
|
||||
- name: Build Frontend Image
|
||||
run: |
|
||||
@ -97,7 +105,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install SSH key
|
||||
run: |
|
||||
@ -139,9 +147,9 @@ jobs:
|
||||
echo "Deployment job finished with status: $STATUS"
|
||||
|
||||
if [ "$STATUS" = "success" ]; then
|
||||
MSG="🚀 Grocery App Deployment succeeded: $IMAGE_NAME:${{ github.sha }}"
|
||||
MSG="Grocery App Deployment succeeded: $REGISTRY:${{ github.sha }}"
|
||||
else
|
||||
MSG="❌ Grocery App Deployment FAILED: $IMAGE_NAME:${{ github.sha }}"
|
||||
MSG="Grocery App Deployment FAILED: $REGISTRY:${{ github.sha }}"
|
||||
fi
|
||||
|
||||
curl -d "$MSG" \
|
||||
|
||||
@ -193,6 +193,7 @@ Usage rules:
|
||||
---
|
||||
|
||||
## 12) Commit Discipline (required)
|
||||
- Treat committing as a first-class part of the workflow: create frequent, verified checkpoint commits for completed work instead of accumulating large uncommitted changes.
|
||||
- Commit in small, logical slices (no broad mixed-purpose commits).
|
||||
- Each commit must:
|
||||
- follow Conventional Commits style (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`)
|
||||
@ -200,4 +201,5 @@ Usage rules:
|
||||
- exclude secrets, credentials, and generated noise
|
||||
- Run verification before commit when applicable (lint/tests/build or targeted checks for touched areas).
|
||||
- Prefer frequent checkpoint commits during agentic work rather than one large end-state commit.
|
||||
- Before switching tasks or stopping after a completed change, check git status and either commit the finished slice or clearly document why it remains uncommitted.
|
||||
- If a rule or contract changes, commit docs first (or in the same atomic slice as enforcing code).
|
||||
|
||||
@ -1,20 +1,8 @@
|
||||
# Database Migration: Add Image Support
|
||||
|
||||
Run these SQL commands on your PostgreSQL database:
|
||||
|
||||
```sql
|
||||
-- Add image columns to grocery_list table
|
||||
ALTER TABLE grocery_list
|
||||
ADD COLUMN item_image BYTEA,
|
||||
ADD COLUMN image_mime_type VARCHAR(50);
|
||||
ADD COLUMN IF NOT EXISTS item_image BYTEA,
|
||||
ADD COLUMN IF NOT EXISTS image_mime_type VARCHAR(50);
|
||||
|
||||
-- Optional: Add index for faster queries when filtering by items with images
|
||||
CREATE INDEX idx_grocery_list_has_image ON grocery_list ((item_image IS NOT NULL));
|
||||
```
|
||||
|
||||
## To Verify:
|
||||
```sql
|
||||
\d grocery_list
|
||||
```
|
||||
|
||||
You should see the new columns `item_image` and `image_mime_type`.
|
||||
-- Index to speed up queries that filter by rows with images.
|
||||
CREATE INDEX IF NOT EXISTS idx_grocery_list_has_image
|
||||
ON grocery_list ((item_image IS NOT NULL));
|
||||
|
||||
@ -1,65 +1,99 @@
|
||||
{
|
||||
"generated_at": "2026-02-19T07:24:39.402Z",
|
||||
"generated_at": "2026-05-25T23:06:21.741Z",
|
||||
"canonical_dir": "packages\\db\\migrations",
|
||||
"legacy_dir": "backend\\migrations",
|
||||
"stale_sql_files": [
|
||||
{
|
||||
"filename": "add_display_name_column.sql",
|
||||
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||
"requires_action": false,
|
||||
"backend_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f",
|
||||
"canonical_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f"
|
||||
"canonical_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f",
|
||||
"normalized_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f"
|
||||
},
|
||||
{
|
||||
"filename": "add_image_columns.sql",
|
||||
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||
"backend_sha256": "45e14112cc88661aea3c55c149bfbe08e692571851b8f9d5061624e9ec3c0d6a",
|
||||
"canonical_sha256": "45e14112cc88661aea3c55c149bfbe08e692571851b8f9d5061624e9ec3c0d6a"
|
||||
"requires_action": false,
|
||||
"backend_sha256": "753cf2524b15cb14055ad94e0f344ad69e8b45110ae338baf764879f69ebfded",
|
||||
"canonical_sha256": "753cf2524b15cb14055ad94e0f344ad69e8b45110ae338baf764879f69ebfded",
|
||||
"normalized_sha256": "753cf2524b15cb14055ad94e0f344ad69e8b45110ae338baf764879f69ebfded"
|
||||
},
|
||||
{
|
||||
"filename": "add_modified_on_column.sql",
|
||||
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||
"requires_action": false,
|
||||
"backend_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b",
|
||||
"canonical_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b"
|
||||
"canonical_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b",
|
||||
"normalized_sha256": "cf4f5dcd2e470954499fc5a191428401bda033d2d32f4851b5674530e56e9b08"
|
||||
},
|
||||
{
|
||||
"filename": "add_notes_column.sql",
|
||||
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||
"requires_action": false,
|
||||
"backend_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a",
|
||||
"canonical_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a"
|
||||
"canonical_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a",
|
||||
"normalized_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a"
|
||||
},
|
||||
{
|
||||
"filename": "create_item_classification_table.sql",
|
||||
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||
"requires_action": false,
|
||||
"backend_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a",
|
||||
"canonical_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a"
|
||||
"canonical_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a",
|
||||
"normalized_sha256": "473e804290863e92ae4d732d4a241be96e827c3194139e32172f6012caf60c50"
|
||||
},
|
||||
{
|
||||
"filename": "multi_household_architecture.sql",
|
||||
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||
"requires_action": false,
|
||||
"backend_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e",
|
||||
"canonical_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e"
|
||||
"canonical_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e",
|
||||
"normalized_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e"
|
||||
}
|
||||
],
|
||||
"canonical_only_sql_files": [
|
||||
{
|
||||
"filename": "20260328_010000_add_household_store_available_items.sql",
|
||||
"status": "CANONICAL_ONLY",
|
||||
"requires_action": false,
|
||||
"canonical_sha256": "58eaf6b526e0317edd45083ba64432fb973ab4a489c0bfd320c422ee501a6206"
|
||||
},
|
||||
{
|
||||
"filename": "20260329_010000_add_household_store_items.sql",
|
||||
"status": "CANONICAL_ONLY",
|
||||
"requires_action": false,
|
||||
"canonical_sha256": "4421515183150c388b19dde66e682807269fbc31414cc1ccfc095abab3788188"
|
||||
},
|
||||
{
|
||||
"filename": "20260329_020000_fix_household_item_classification_upsert.sql",
|
||||
"status": "CANONICAL_ONLY",
|
||||
"requires_action": false,
|
||||
"canonical_sha256": "8c86cde57bf98b0c9bf5340d685150e89a2fdb873d1bda83893506b2b2478e62"
|
||||
},
|
||||
{
|
||||
"filename": "create_sessions_table.sql",
|
||||
"status": "CANONICAL_ONLY",
|
||||
"requires_action": false,
|
||||
"canonical_sha256": "d46e5147eb113042e9c2856d17b38715e66a486ee4d7c6450c960145791bc030"
|
||||
},
|
||||
{
|
||||
"filename": "zz_group_invites_and_join_policies.sql",
|
||||
"status": "CANONICAL_ONLY",
|
||||
"canonical_sha256": "de955333667326f8eaf224431ecb62a5d0bd354fa0ccce34af6e52374e55d6e3"
|
||||
"requires_action": false,
|
||||
"canonical_sha256": "47e31807356c6682a926aa0d9fd9c46b9edf0b8a586d6c39a36c931e5de5ca5b"
|
||||
}
|
||||
],
|
||||
"legacy_non_sql_files": [
|
||||
"MIGRATION_GUIDE.md"
|
||||
"MIGRATION_GUIDE.md",
|
||||
"stale-sql-report.json"
|
||||
],
|
||||
"summary": {
|
||||
"stale_total": 6,
|
||||
"stale_only_in_backend_total": 0,
|
||||
"stale_duplicate_total": 6,
|
||||
"stale_diverged_total": 0,
|
||||
"canonical_only_total": 2
|
||||
"action_required_total": 0,
|
||||
"canonical_only_total": 5
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,13 +20,16 @@ This project uses an external on-prem Postgres database. Migration files are can
|
||||
- `npm run db:migrate:new -- <migration-name>`
|
||||
- Track stale legacy SQL in `backend/migrations`:
|
||||
- `npm run db:migrate:stale`
|
||||
- Fail when stale legacy SQL exists:
|
||||
- Fail when legacy SQL needs operator attention:
|
||||
- `npm run db:migrate:stale:check`
|
||||
|
||||
## Active migration set
|
||||
Migration files are applied in lexicographic filename order from `packages/db/migrations`.
|
||||
|
||||
`backend/migrations` is legacy reference-only and not part of canonical execution.
|
||||
Duplicate reference copies are reported by the stale tracker, but the check fails only
|
||||
when a legacy SQL file exists only in `backend/migrations` or diverges from its
|
||||
canonical file.
|
||||
`packages/db/migrations/stale-files.json` is the source of truth for canonical files that are intentionally stale/ignored.
|
||||
|
||||
Current baseline files:
|
||||
|
||||
@ -35,7 +35,7 @@ Important variables:
|
||||
| `SESSION_COOKIE_NAME`, `SESSION_TTL_DAYS` | backend cookies | Optional; defaults are defined in code. |
|
||||
| `VITE_API_URL` | frontend | Defaults to `http://localhost:5000`. |
|
||||
| `VITE_ALLOWED_HOSTS` | Vite dev server | Comma-separated host allowlist. |
|
||||
| `PLAYWRIGHT_BASE_URL` | Playwright | Defaults to `http://localhost:3010`. |
|
||||
| `PLAYWRIGHT_BASE_URL` | Playwright | Defaults to `http://localhost:3010`; the e2e runner starts Vite on this URL. |
|
||||
|
||||
## Run Locally
|
||||
Docker dev stack:
|
||||
@ -70,6 +70,10 @@ npm run build
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
`npm run test:e2e` uses `frontend/scripts/run-playwright.mjs` to start Vite, run
|
||||
Playwright, and shut Vite down cleanly. Pass Playwright flags after `--`, for
|
||||
example `npm run test:e2e -- --reporter=list --workers=1`.
|
||||
|
||||
Migration checks:
|
||||
|
||||
```bash
|
||||
|
||||
@ -9,9 +9,9 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
"test:e2e": "node scripts/run-playwright.mjs",
|
||||
"test:e2e:headed": "node scripts/run-playwright.mjs --headed",
|
||||
"test:e2e:ui": "node scripts/run-playwright.mjs --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
|
||||
@ -20,10 +20,4 @@ export default defineConfig({
|
||||
use: { browserName: "chromium", channel: "chrome" },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "npm run dev -- --host localhost --port 3010",
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
});
|
||||
|
||||
80
frontend/scripts/run-playwright.mjs
Normal file
80
frontend/scripts/run-playwright.mjs
Normal file
@ -0,0 +1,80 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createServer } from "vite";
|
||||
|
||||
const baseUrl = new URL(process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3010");
|
||||
const port = Number(baseUrl.port || 3010);
|
||||
const host = baseUrl.hostname || "localhost";
|
||||
|
||||
let child = null;
|
||||
let closing = false;
|
||||
|
||||
const server = await createServer({
|
||||
server: {
|
||||
host,
|
||||
port,
|
||||
strictPort: true,
|
||||
},
|
||||
});
|
||||
|
||||
async function closeServer() {
|
||||
if (closing) {
|
||||
return;
|
||||
}
|
||||
closing = true;
|
||||
await server.close();
|
||||
}
|
||||
|
||||
async function shutdown(signal) {
|
||||
if (child && !child.killed) {
|
||||
child.kill(signal);
|
||||
}
|
||||
|
||||
try {
|
||||
await closeServer();
|
||||
} finally {
|
||||
process.exit(signal === "SIGINT" || signal === "SIGTERM" ? 130 : 1);
|
||||
}
|
||||
}
|
||||
|
||||
process.once("SIGINT", () => {
|
||||
void shutdown("SIGINT");
|
||||
});
|
||||
|
||||
process.once("SIGTERM", () => {
|
||||
void shutdown("SIGTERM");
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
server.printUrls();
|
||||
|
||||
const playwrightCli = fileURLToPath(
|
||||
new URL("../node_modules/@playwright/test/cli.js", import.meta.url)
|
||||
);
|
||||
const frontendRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
|
||||
child = spawn(process.execPath, [playwrightCli, "test", ...process.argv.slice(2)], {
|
||||
cwd: frontendRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PLAYWRIGHT_BASE_URL: baseUrl.toString().replace(/\/$/, ""),
|
||||
PLAYWRIGHT_SKIP_WEBSERVER: "1",
|
||||
},
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
child.on("exit", async (code, signal) => {
|
||||
try {
|
||||
await closeServer();
|
||||
} catch (error) {
|
||||
console.error("Failed to close Vite after Playwright run:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
@ -1,54 +1,17 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
function seedAuthStorage(page: import("@playwright/test").Page) {
|
||||
return page.addInitScript(() => {
|
||||
localStorage.setItem("token", "test-token");
|
||||
localStorage.setItem("userId", "1");
|
||||
localStorage.setItem("role", "admin");
|
||||
localStorage.setItem("username", "catalog-user");
|
||||
});
|
||||
}
|
||||
|
||||
async function mockConfig(page: import("@playwright/test").Page) {
|
||||
await page.route("**/config", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
maxFileSizeMB: 20,
|
||||
maxImageDimension: 800,
|
||||
imageQuality: 85,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function mockHouseholdAndStoreShell(page: import("@playwright/test").Page) {
|
||||
await page.route("**/households", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{ id: 1, name: "Catalog House", role: "admin", invite_code: "ABCD1234" },
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/stores/household/1", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
|
||||
]),
|
||||
});
|
||||
});
|
||||
}
|
||||
import {
|
||||
confirmSlide,
|
||||
mockConfig,
|
||||
mockHouseholdAndStoreShell,
|
||||
seedAuthStorage,
|
||||
} from "./helpers/e2e";
|
||||
|
||||
test("manage stores opens a modal to edit and delete household store items", async ({ page }) => {
|
||||
await seedAuthStorage(page);
|
||||
await seedAuthStorage(page, { username: "catalog-user" });
|
||||
await mockConfig(page);
|
||||
await mockHouseholdAndStoreShell(page);
|
||||
await mockHouseholdAndStoreShell(page, {
|
||||
household: { name: "Catalog House" },
|
||||
});
|
||||
|
||||
let availableItems = [
|
||||
{
|
||||
@ -170,22 +133,12 @@ test("manage stores opens a modal to edit and delete household store items", asy
|
||||
await expect(confirmModal).toBeVisible();
|
||||
await expect(confirmModal.getByText("Delete milk?")).toBeVisible();
|
||||
|
||||
const slider = confirmModal.locator(".confirm-slide-handle");
|
||||
const track = confirmModal.locator(".confirm-slide-track");
|
||||
const sliderBox = await slider.boundingBox();
|
||||
const trackBox = await track.boundingBox();
|
||||
await confirmSlide(page);
|
||||
|
||||
if (!sliderBox || !trackBox) {
|
||||
throw new Error("Confirm slide control was not measurable");
|
||||
}
|
||||
|
||||
await page.mouse.move(sliderBox.x + sliderBox.width / 2, sliderBox.y + sliderBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(trackBox.x + trackBox.width - 4, sliderBox.y + sliderBox.height / 2, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Deleted store item");
|
||||
await expect(managerModal.getByText("milk")).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator(".action-toast.action-toast-success").filter({ hasText: "Deleted store item" })
|
||||
).toContainText("Deleted store item");
|
||||
await expect(managerModal.locator(".store-items-table-row").filter({ hasText: "milk" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("grocery page remains unchanged and does not show a store items picker", async ({ page }) => {
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
mockConfig,
|
||||
mockHouseholdAndStoreShell,
|
||||
seedAuthStorage,
|
||||
} from "./helpers/e2e";
|
||||
|
||||
type MockItem = {
|
||||
id: number;
|
||||
@ -15,29 +20,6 @@ type MockItem = {
|
||||
zone: string | null;
|
||||
};
|
||||
|
||||
function seedAuthStorage(page: import("@playwright/test").Page) {
|
||||
return page.addInitScript(() => {
|
||||
localStorage.setItem("token", "test-token");
|
||||
localStorage.setItem("userId", "1");
|
||||
localStorage.setItem("role", "admin");
|
||||
localStorage.setItem("username", "buy-modal-user");
|
||||
});
|
||||
}
|
||||
|
||||
async function mockConfig(page: import("@playwright/test").Page) {
|
||||
await page.route("**/config", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
maxFileSizeMB: 20,
|
||||
maxImageDimension: 800,
|
||||
imageQuality: 85,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function makeItem(
|
||||
id: number,
|
||||
itemName: string,
|
||||
@ -68,24 +50,8 @@ async function setupBuyModalRoutes(
|
||||
let activeItems = initialItems.map((item) => ({ ...item }));
|
||||
let recentItems: MockItem[] = [];
|
||||
|
||||
await page.route("**/households", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{ id: 1, name: "Auto Advance House", role: "admin", invite_code: "ABCD1234" },
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/stores/household/1", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
|
||||
]),
|
||||
});
|
||||
await mockHouseholdAndStoreShell(page, {
|
||||
household: { name: "Auto Advance House" },
|
||||
});
|
||||
|
||||
await page.route("**/households/1/members", async (route) => {
|
||||
@ -122,7 +88,7 @@ async function setupBuyModalRoutes(
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/list/item", async (route) => {
|
||||
await page.route("**/households/1/stores/10/list/item**", async (route) => {
|
||||
const request = route.request();
|
||||
|
||||
if (request.method() === "PATCH") {
|
||||
@ -208,7 +174,7 @@ async function openBuyModal(page: import("@playwright/test").Page, itemName: str
|
||||
}
|
||||
|
||||
test("buying an item advances to the next one in the current sort order", async ({ page }) => {
|
||||
await seedAuthStorage(page);
|
||||
await seedAuthStorage(page, { username: "buy-modal-user" });
|
||||
await mockConfig(page);
|
||||
await setupBuyModalRoutes(page, [
|
||||
makeItem(1, "milk", 2),
|
||||
@ -226,7 +192,7 @@ test("buying an item advances to the next one in the current sort order", async
|
||||
});
|
||||
|
||||
test("buying the last item in the current order wraps to the first remaining item", async ({ page }) => {
|
||||
await seedAuthStorage(page);
|
||||
await seedAuthStorage(page, { username: "buy-modal-user" });
|
||||
await mockConfig(page);
|
||||
await setupBuyModalRoutes(page, [
|
||||
makeItem(1, "apples", 3),
|
||||
@ -244,7 +210,7 @@ test("buying the last item in the current order wraps to the first remaining ite
|
||||
});
|
||||
|
||||
test("partial buy keeps the item on the list and advances past it", async ({ page }) => {
|
||||
await seedAuthStorage(page);
|
||||
await seedAuthStorage(page, { username: "buy-modal-user" });
|
||||
await mockConfig(page);
|
||||
await setupBuyModalRoutes(page, [
|
||||
makeItem(1, "alpha", 1),
|
||||
@ -257,14 +223,15 @@ test("partial buy keeps the item on the list and advances past it", async ({ pag
|
||||
|
||||
await openBuyModal(page, "bravo");
|
||||
await page.locator(".confirm-buy-counter-btn").nth(0).click();
|
||||
await page.locator(".confirm-buy-counter-btn").nth(0).click();
|
||||
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
||||
|
||||
await expect(page.locator(".confirm-buy-item-name")).toHaveText("charlie");
|
||||
await expect(page.locator(".glist-li").filter({ hasText: "bravo" })).toContainText("x2");
|
||||
await expect(page.locator(".glist-li").filter({ hasText: "bravo" }).first()).toContainText("x2");
|
||||
});
|
||||
|
||||
test("buying the only remaining item closes the modal", async ({ page }) => {
|
||||
await seedAuthStorage(page);
|
||||
await seedAuthStorage(page, { username: "buy-modal-user" });
|
||||
await mockConfig(page);
|
||||
await setupBuyModalRoutes(page, [
|
||||
makeItem(1, "solo", 1),
|
||||
|
||||
@ -1,30 +1,12 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
function seedAuthStorage(page: import("@playwright/test").Page) {
|
||||
return page.addInitScript(() => {
|
||||
localStorage.setItem("token", "test-token");
|
||||
localStorage.setItem("userId", "1");
|
||||
localStorage.setItem("role", "admin");
|
||||
localStorage.setItem("username", "assignment-user");
|
||||
});
|
||||
}
|
||||
|
||||
async function mockConfig(page: import("@playwright/test").Page) {
|
||||
await page.route("**/config", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
maxFileSizeMB: 20,
|
||||
maxImageDimension: 800,
|
||||
imageQuality: 85,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
import {
|
||||
mockConfig,
|
||||
mockHouseholdAndStoreShell,
|
||||
seedAuthStorage,
|
||||
} from "./helpers/e2e";
|
||||
|
||||
test("assigned items render selected users and keep the picker menu outside the modal", async ({ page }) => {
|
||||
await seedAuthStorage(page);
|
||||
await seedAuthStorage(page, { username: "assignment-user" });
|
||||
await mockConfig(page);
|
||||
|
||||
const members = [
|
||||
@ -62,24 +44,8 @@ test("assigned items render selected users and keep the picker menu outside the
|
||||
}> = [];
|
||||
let addCallCount = 0;
|
||||
|
||||
await page.route("**/households", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{ id: 1, name: "Assignment House", role: "admin", invite_code: "ABCD1234" },
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/stores/household/1", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
|
||||
]),
|
||||
});
|
||||
await mockHouseholdAndStoreShell(page, {
|
||||
household: { name: "Assignment House" },
|
||||
});
|
||||
|
||||
await page.route("**/households/1/members", async (route) => {
|
||||
@ -189,7 +155,7 @@ test("assigned items render selected users and keep the picker menu outside the
|
||||
const assignModal = page.locator(".assign-item-for-modal");
|
||||
await expect(assignModal).toBeVisible();
|
||||
|
||||
await assignModal.getByRole("button", { name: "Select member" }).click();
|
||||
await assignModal.locator(".assign-item-for-dropdown-trigger").click();
|
||||
|
||||
const portalMenu = page.locator("body > .assign-item-for-dropdown-menu");
|
||||
await expect(portalMenu).toBeVisible();
|
||||
@ -207,7 +173,9 @@ test("assigned items render selected users and keep the picker menu outside the
|
||||
expect(dropdownMetrics.scrollable).toBe(true);
|
||||
|
||||
await portalMenu.getByRole("option", { name: "Casey Client" }).click();
|
||||
await expect(portalMenu).toHaveCount(0);
|
||||
await assignModal.getByRole("button", { name: "Confirm" }).click();
|
||||
await expect(assignModal).toHaveCount(0);
|
||||
|
||||
await expect(page.getByText("Adding for: Casey Client")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Create + Add" }).click();
|
||||
@ -219,9 +187,11 @@ test("assigned items render selected users and keep the picker menu outside the
|
||||
|
||||
await page.getByPlaceholder("Enter item name").fill("bananas");
|
||||
await page.getByRole("button", { name: "Others" }).click();
|
||||
await assignModal.getByRole("button", { name: "Select member" }).click();
|
||||
await assignModal.locator(".assign-item-for-dropdown-trigger").click();
|
||||
await portalMenu.getByRole("option", { name: "Jordan Client" }).click();
|
||||
await expect(portalMenu).toHaveCount(0);
|
||||
await assignModal.getByRole("button", { name: "Confirm" }).click();
|
||||
await expect(assignModal).toHaveCount(0);
|
||||
|
||||
await expect(page.getByText("Adding for: Jordan Client")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Create + Add" }).click();
|
||||
@ -229,5 +199,7 @@ test("assigned items render selected users and keep the picker menu outside the
|
||||
|
||||
await expect(bananasRow).toContainText("Casey Client");
|
||||
await expect(bananasRow).toContainText("Jordan Client");
|
||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated item quantity");
|
||||
await expect(
|
||||
page.locator(".action-toast.action-toast-success").filter({ hasText: "Updated item quantity" })
|
||||
).toContainText("Updated item quantity");
|
||||
});
|
||||
|
||||
120
frontend/tests/helpers/e2e.ts
Normal file
120
frontend/tests/helpers/e2e.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
type AuthSeed = {
|
||||
token?: string;
|
||||
userId?: string;
|
||||
role?: string;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
type HouseholdSeed = {
|
||||
id?: number;
|
||||
name?: string;
|
||||
role?: string;
|
||||
invite_code?: string;
|
||||
};
|
||||
|
||||
type StoreSeed = {
|
||||
id?: number;
|
||||
name?: string;
|
||||
location?: string;
|
||||
is_default?: boolean;
|
||||
};
|
||||
|
||||
const defaultConfig = {
|
||||
maxFileSizeMB: 20,
|
||||
maxImageDimension: 800,
|
||||
imageQuality: 85,
|
||||
};
|
||||
|
||||
export function seedAuthStorage(page: Page, seed: AuthSeed = {}) {
|
||||
return page.addInitScript((authSeed) => {
|
||||
localStorage.setItem("token", authSeed.token || "test-token");
|
||||
localStorage.setItem("userId", authSeed.userId || "1");
|
||||
localStorage.setItem("role", authSeed.role || "admin");
|
||||
localStorage.setItem("username", authSeed.username || "test-user");
|
||||
}, seed);
|
||||
}
|
||||
|
||||
export async function mockConfig(page: Page, overrides = {}) {
|
||||
await page.route("**/config", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ...defaultConfig, ...overrides }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function mockHouseholdAndStoreShell(
|
||||
page: Page,
|
||||
options: { household?: HouseholdSeed; stores?: StoreSeed[] } = {}
|
||||
) {
|
||||
const household = {
|
||||
id: 1,
|
||||
name: "Test Household",
|
||||
role: "admin",
|
||||
invite_code: "ABCD1234",
|
||||
...options.household,
|
||||
};
|
||||
const stores = options.stores || [
|
||||
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
|
||||
];
|
||||
|
||||
await page.route("**/households", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([household]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/stores/household/${household.id}`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(stores),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmSlide(page: Page) {
|
||||
const confirmModal = page.locator(".confirm-slide-modal");
|
||||
await expect(confirmModal).toBeVisible();
|
||||
|
||||
const slider = confirmModal.locator(".confirm-slide-handle");
|
||||
const track = confirmModal.locator(".confirm-slide-track");
|
||||
const sliderBox = await slider.boundingBox();
|
||||
const trackBox = await track.boundingBox();
|
||||
|
||||
if (!sliderBox || !trackBox) {
|
||||
throw new Error("Confirm slide control was not measurable");
|
||||
}
|
||||
|
||||
await page.mouse.move(sliderBox.x + sliderBox.width / 2, sliderBox.y + sliderBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(trackBox.x + trackBox.width - 4, sliderBox.y + sliderBox.height / 2, {
|
||||
steps: 8,
|
||||
});
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
export function collectFailedApiRequests(page: Page) {
|
||||
const failedRequests: string[] = [];
|
||||
|
||||
page.on("requestfailed", (request) => {
|
||||
const url = request.url();
|
||||
if (!url.startsWith("http://localhost:5000")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const failure = request.failure()?.errorText || "unknown failure";
|
||||
failedRequests.push(`${request.method()} ${url} :: ${failure}`);
|
||||
});
|
||||
|
||||
return failedRequests;
|
||||
}
|
||||
|
||||
export function expectNoFailedApiRequests(failedRequests: string[]) {
|
||||
expect(failedRequests).toEqual([]);
|
||||
}
|
||||
@ -1,30 +1,8 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
function seedAuthStorage(page: import("@playwright/test").Page) {
|
||||
return page.addInitScript(() => {
|
||||
localStorage.setItem("token", "test-token");
|
||||
localStorage.setItem("userId", "1");
|
||||
localStorage.setItem("role", "admin");
|
||||
localStorage.setItem("username", "new-user");
|
||||
});
|
||||
}
|
||||
|
||||
async function mockConfig(page: import("@playwright/test").Page) {
|
||||
await page.route("**/config", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
maxFileSizeMB: 20,
|
||||
maxImageDimension: 800,
|
||||
imageQuality: 85,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
import { mockConfig, seedAuthStorage } from "./helpers/e2e";
|
||||
|
||||
test("new users with no households see create and join actions instead of a loading dead-end", async ({ page }) => {
|
||||
await seedAuthStorage(page);
|
||||
await seedAuthStorage(page, { username: "new-user" });
|
||||
await mockConfig(page);
|
||||
|
||||
await page.route("**/households", async (route) => {
|
||||
@ -43,7 +21,7 @@ test("new users with no households see create and join actions instead of a load
|
||||
await expect(page.getByRole("button", { name: "Join Household", exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Join Household", exact: true }).click();
|
||||
await expect(page.getByLabel("Invite Code or Link")).toBeVisible();
|
||||
await expect(page.getByLabel("Invite Link")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Close household dialog" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Create Household", exact: true }).click();
|
||||
|
||||
@ -1,49 +1,14 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
function seedAuthStorage(page: import("@playwright/test").Page, role = "admin") {
|
||||
return page.addInitScript((seedRole) => {
|
||||
localStorage.setItem("token", "test-token");
|
||||
localStorage.setItem("userId", "1");
|
||||
localStorage.setItem("role", seedRole);
|
||||
localStorage.setItem("username", "manager-user");
|
||||
}, role);
|
||||
}
|
||||
|
||||
async function mockConfig(page: import("@playwright/test").Page) {
|
||||
await page.route("**/config", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
maxFileSizeMB: 20,
|
||||
maxImageDimension: 800,
|
||||
imageQuality: 85,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmSlide(page: import("@playwright/test").Page) {
|
||||
const confirmModal = page.locator(".confirm-slide-modal");
|
||||
await expect(confirmModal).toBeVisible();
|
||||
|
||||
const slider = confirmModal.locator(".confirm-slide-handle");
|
||||
const track = confirmModal.locator(".confirm-slide-track");
|
||||
const sliderBox = await slider.boundingBox();
|
||||
const trackBox = await track.boundingBox();
|
||||
|
||||
if (!sliderBox || !trackBox) {
|
||||
throw new Error("Confirm slide control was not measurable");
|
||||
}
|
||||
|
||||
await page.mouse.move(sliderBox.x + sliderBox.width / 2, sliderBox.y + sliderBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(trackBox.x + trackBox.width - 4, sliderBox.y + sliderBox.height / 2, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
import {
|
||||
collectFailedApiRequests,
|
||||
confirmSlide,
|
||||
expectNoFailedApiRequests,
|
||||
mockConfig,
|
||||
seedAuthStorage,
|
||||
} from "./helpers/e2e";
|
||||
|
||||
test("join household modal accepts invite links but rejects legacy invite codes", async ({ page }) => {
|
||||
await seedAuthStorage(page);
|
||||
await seedAuthStorage(page, { username: "manager-user" });
|
||||
await mockConfig(page);
|
||||
|
||||
await page.route("**/households", async (route) => {
|
||||
@ -54,13 +19,13 @@ test("join household modal accepts invite links but rejects legacy invite codes"
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/invite-links/approval-token", async (route) => {
|
||||
await page.route("**/api/invite-links/approvaltoken", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
link: {
|
||||
token: "approval-token",
|
||||
token: "approvaltoken",
|
||||
status: "ACTIVE",
|
||||
viewerStatus: null,
|
||||
active_policy: "APPROVAL_REQUIRED",
|
||||
@ -75,17 +40,19 @@ test("join household modal accepts invite links but rejects legacy invite codes"
|
||||
await page.getByLabel("Invite Link").fill("HABC123");
|
||||
await page.getByRole("button", { name: "Open Invite" }).click();
|
||||
|
||||
await expect(page.getByText("Use a household invite link like /invite/abcd1234.")).toBeVisible();
|
||||
await expect(page.locator(".create-join-modal .error-message")).toHaveText(
|
||||
"Use a household invite link like /invite/abcd1234."
|
||||
);
|
||||
|
||||
await page.getByLabel("Invite Link").fill("/invite/approval-token");
|
||||
await page.getByLabel("Invite Link").fill("/invite/approvaltoken");
|
||||
await page.getByRole("button", { name: "Open Invite" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/invite\/approval-token$/);
|
||||
await expect(page).toHaveURL(/\/invite\/approvaltoken$/);
|
||||
await expect(page.getByRole("heading", { name: "Join Approval Home" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("household management shows pending invite approvals and can approve them", async ({ page }) => {
|
||||
await seedAuthStorage(page);
|
||||
await seedAuthStorage(page, { username: "manager-user" });
|
||||
await mockConfig(page);
|
||||
|
||||
let members = [
|
||||
@ -213,7 +180,8 @@ test("household management shows pending invite approvals and can approve them",
|
||||
});
|
||||
|
||||
test("household owner can transfer ownership from household settings", async ({ page }) => {
|
||||
await seedAuthStorage(page, "owner");
|
||||
const failedApiRequests = collectFailedApiRequests(page);
|
||||
await seedAuthStorage(page, { role: "owner", username: "manager-user" });
|
||||
await mockConfig(page);
|
||||
|
||||
let households = [{ id: 1, name: "Approval Home", role: "owner", invite_code: "ABCD1234" }];
|
||||
@ -231,32 +199,36 @@ test("household owner can transfer ownership from household settings", async ({
|
||||
});
|
||||
|
||||
await page.route("**/households/1/members", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "PATCH") {
|
||||
const body = request.postDataJSON() as { role?: string };
|
||||
if (body.role === "owner") {
|
||||
households = [{ id: 1, name: "Approval Home", role: "admin", invite_code: "ABCD1234" }];
|
||||
members = [
|
||||
{ id: 1, username: "manager-user", role: "admin" },
|
||||
{ id: 2, username: "nico-admin", role: "owner" },
|
||||
];
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(members),
|
||||
});
|
||||
});
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
message: "Household ownership transferred successfully",
|
||||
member: { user_id: 2, role: body.role || "member" },
|
||||
}),
|
||||
});
|
||||
await page.route("**/households/1/members/*/role", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() !== "PATCH") {
|
||||
await route.fulfill({ status: 405 });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = request.postDataJSON() as { role?: string };
|
||||
if (body.role === "owner") {
|
||||
households = [{ id: 1, name: "Approval Home", role: "admin", invite_code: "ABCD1234" }];
|
||||
members = [
|
||||
{ id: 1, username: "manager-user", role: "admin" },
|
||||
{ id: 2, username: "nico-admin", role: "owner" },
|
||||
];
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(members),
|
||||
body: JSON.stringify({
|
||||
message: "Household ownership transferred successfully",
|
||||
member: { user_id: 2, role: body.role || "member" },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@ -317,4 +289,5 @@ test("household owner can transfer ownership from household settings", async ({
|
||||
await expect(page.getByText("👑 Owner")).toContainText("Owner");
|
||||
await expect(page.getByText("🛠️ Admin")).toContainText("Admin");
|
||||
await expect(page.getByRole("button", { name: "Make Owner" })).toHaveCount(0);
|
||||
expectNoFailedApiRequests(failedApiRequests);
|
||||
});
|
||||
|
||||
@ -30,6 +30,16 @@ function sha256File(filePath) {
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function sha256Text(value) {
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update(value);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function comparableSql(filePath) {
|
||||
return fs.readFileSync(filePath, "utf8").replace(/\r\n/g, "\n").trimEnd() + "\n";
|
||||
}
|
||||
|
||||
function listFiles(dirPath) {
|
||||
return fs
|
||||
.readdirSync(dirPath)
|
||||
@ -48,6 +58,7 @@ function mapByNameWithHash(dirPath, names) {
|
||||
name,
|
||||
path: path.join(dirPath, name),
|
||||
sha256: sha256File(path.join(dirPath, name)),
|
||||
normalized_sha256: sha256Text(comparableSql(path.join(dirPath, name))),
|
||||
});
|
||||
}
|
||||
return map;
|
||||
@ -75,17 +86,20 @@ function buildReport() {
|
||||
staleFiles.push({
|
||||
filename: legacyName,
|
||||
status: "STALE_ONLY_IN_BACKEND",
|
||||
requires_action: true,
|
||||
backend_sha256: legacyFile.sha256,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (legacyFile.sha256 === canonicalFile.sha256) {
|
||||
if (legacyFile.normalized_sha256 === canonicalFile.normalized_sha256) {
|
||||
staleFiles.push({
|
||||
filename: legacyName,
|
||||
status: "STALE_DUPLICATE_OF_CANONICAL",
|
||||
requires_action: false,
|
||||
backend_sha256: legacyFile.sha256,
|
||||
canonical_sha256: canonicalFile.sha256,
|
||||
normalized_sha256: legacyFile.normalized_sha256,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@ -93,8 +107,11 @@ function buildReport() {
|
||||
staleFiles.push({
|
||||
filename: legacyName,
|
||||
status: "STALE_DIVERGED_FROM_CANONICAL",
|
||||
requires_action: true,
|
||||
backend_sha256: legacyFile.sha256,
|
||||
canonical_sha256: canonicalFile.sha256,
|
||||
backend_normalized_sha256: legacyFile.normalized_sha256,
|
||||
canonical_normalized_sha256: canonicalFile.normalized_sha256,
|
||||
});
|
||||
}
|
||||
|
||||
@ -103,9 +120,12 @@ function buildReport() {
|
||||
.map((name) => ({
|
||||
filename: name,
|
||||
status: "CANONICAL_ONLY",
|
||||
requires_action: false,
|
||||
canonical_sha256: canonicalMap.get(name).sha256,
|
||||
}));
|
||||
|
||||
const actionRequired = staleFiles.filter((file) => file.requires_action);
|
||||
|
||||
return {
|
||||
generated_at: new Date().toISOString(),
|
||||
canonical_dir: path.relative(repoRoot, canonicalDir),
|
||||
@ -124,6 +144,7 @@ function buildReport() {
|
||||
stale_diverged_total: staleFiles.filter(
|
||||
(f) => f.status === "STALE_DIVERGED_FROM_CANONICAL"
|
||||
).length,
|
||||
action_required_total: actionRequired.length,
|
||||
canonical_only_total: canonicalOnly.length,
|
||||
},
|
||||
};
|
||||
@ -136,11 +157,15 @@ function printReport(report) {
|
||||
console.log(`- Generated: ${report.generated_at}`);
|
||||
console.log("");
|
||||
|
||||
console.log(`Stale SQL files in legacy dir: ${report.summary.stale_total}`);
|
||||
console.log(`Legacy SQL files in reference dir: ${report.summary.stale_total}`);
|
||||
for (const stale of report.stale_sql_files) {
|
||||
console.log(` - ${stale.filename} :: ${stale.status}`);
|
||||
const actionText = stale.requires_action ? "action required" : "reference only";
|
||||
console.log(` - ${stale.filename} :: ${stale.status} (${actionText})`);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(`Action-required legacy SQL files: ${report.summary.action_required_total}`);
|
||||
|
||||
console.log("");
|
||||
console.log(`Canonical-only SQL files: ${report.summary.canonical_only_total}`);
|
||||
for (const canonicalOnly of report.canonical_only_sql_files) {
|
||||
@ -174,7 +199,7 @@ function main() {
|
||||
writeReport(report);
|
||||
}
|
||||
|
||||
if (options.failOnStale && report.summary.stale_total > 0) {
|
||||
if (options.failOnStale && report.summary.action_required_total > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user