chore: harden reliability checks #2

Merged
nalalangan merged 67 commits from main-new into main 2026-05-25 14:28:32 -09:00
17 changed files with 433 additions and 324 deletions
Showing only changes of commit a2c08aff45 - Show all commits

View File

@ -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" \

View File

@ -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" \

View File

@ -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).

View File

@ -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));

View File

@ -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
}
}

View File

@ -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:

View File

@ -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

View File

@ -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",

View File

@ -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,
},
});

View 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);
});

View File

@ -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 }) => {

View File

@ -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),

View File

@ -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");
});

View 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([]);
}

View File

@ -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();

View File

@ -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,8 +199,20 @@ test("household owner can transfer ownership from household settings", async ({
});
await page.route("**/households/1/members", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(members),
});
});
await page.route("**/households/1/members/*/role", async (route) => {
const request = route.request();
if (request.method() === "PATCH") {
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" }];
@ -250,14 +230,6 @@ test("household owner can transfer ownership from household settings", async ({
member: { user_id: 2, role: body.role || "member" },
}),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(members),
});
});
await page.route("**/api/groups/join-policy", async (route) => {
@ -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);
});

View File

@ -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);
}
}