Compare commits

...

11 Commits

Author SHA1 Message Date
cartsnitch-engineer[bot] 4f42247bf2 docs: add UAT runbook v1
Merges docs/uat-runbook into main. UAT Runbook v1 authored by Savannah Savings (CTO). QA and CTO approved.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 20:20:07 +00:00
cartsnitch-engineer[bot] 4c29d8a241 feat: add utility functions with unit tests (#63)
feat: add utility functions with unit tests
2026-03-30 19:47:33 +00:00
Barcode Betty 6b6b9e7d01 feat: add utility functions with unit tests
Add formatCurrency, formatDate, and storeSlugs utilities in src/utils/
with 21 vitest unit tests covering standard and edge cases.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 18:53:40 +00:00
cartsnitch-engineer[bot] 835aff3522 fix: use same-origin default for auth URL instead of localhost
fix: use same-origin default for auth URL instead of localhost
2026-03-30 16:07:28 +00:00
Barcode Betty 5588c1b5d8 fix: use same-origin default for auth URL instead of localhost
Avoids ERR_CONNECTION_REFUSED in deployed environments where
VITE_AUTH_URL is not set at build time. Empty-string fallback
routes auth requests to same origin, which the HTTPRoute forwards
to the auth service.

cc @cpfarhood

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 15:50:51 +00:00
cartsnitch-ceo[bot] c5ed863ab1 fix: align frontend auth with API token response contract
fix: align frontend auth with API token response contract
2026-03-30 15:20:56 +00:00
Barcode Betty 8d0552f73f Merge branch 'origin/main' into fix/auth-contract-mismatch
# Conflicts:
#	src/pages/Login.tsx
#	src/pages/Register.tsx
2026-03-30 13:12:32 +00:00
Barcode Betty 3a75ee7aee fix: align frontend auth with API token response contract
- Register sends display_name instead of name
- Register/Login handle TokenResponse (access_token, not token)
- Fetch /auth/me after register/login to populate user object

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 11:00:52 +00:00
cartsnitch-engineer[bot] 30d670a257 feat(ci): add auth image tag update to deploy-dev (#57)
Add build-and-push-auth job dependency and tag update to deploy-dev:
- build-and-push-auth: add outputs.calver_tag for downstream jobs
- deploy-dev: needs both build-and-push and build-and-push-auth
- deploy-dev: set auth image tag in dev overlay via kustomize

Refs: CAR-138

Co-authored-by: Barcode Betty <barcode-betty@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: cartsnitch-ceo[bot] <269712056+cartsnitch-ceo[bot]@users.noreply.github.com>
2026-03-30 09:59:41 +00:00
cpfarhood-k8s[bot] cfa4d8fa91 test 2026-03-30 00:50:51 +00:00
cartsnitch-engineer[bot] 39e8d5c9f9 fix(ci): install kustomize in deploy-dev job (#55)
* fix(ci): install kustomize in deploy-dev job

Add imranismail/setup-kustomize@v2 step so the deploy-dev job can
run kustomize edit set image without a "command not found" error.

Also fix the working-directory so cd infra is used consistently rather
than a relative path that resolved outside the checked-out infra repo.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(ci): correct kustomize image name and tag in deploy-dev

- Remove '=' rename syntax which strips the GHCR registry prefix
- Use calver_tag output from build-and-push instead of github.sha
- Update commit message to reflect the correct tag

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(ci): add path: infra to checkout step so cd infra succeeds

CTO review feedback: actions/checkout@v4 must specify path: infra
so that subsequent 'cd infra' commands resolve to the checked-out
infra repository, not the cartsnitch repo root.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(ci): cd into overlay dir before kustomize edit set image

CTO review feedback: kustomize edit set image operates on the
kustomization.yaml in the current working directory. Since the
target file is at infra/apps/overlays/dev/kustomization.yaml, the
step must cd there before running kustomize.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Barcode Betty <noreply@paperclip.ing>
Co-authored-by: Stockboy Steve <stockboy-steve@paperclip.ing>
Co-authored-by: cartsnitch-ceo[bot] <269712056+cartsnitch-ceo[bot]@users.noreply.github.com>
2026-03-30 00:28:20 +00:00
10 changed files with 391 additions and 51 deletions
+12 -5
View File
@@ -111,6 +111,8 @@ jobs:
build-and-push-auth:
runs-on: runners-cartsnitch
needs: [lint, test]
outputs:
calver_tag: ${{ steps.calver.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
@@ -161,7 +163,7 @@ jobs:
deploy-dev:
runs-on: runners-cartsnitch
needs: [build-and-push]
needs: [build-and-push, build-and-push-auth]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Generate GitHub App token
@@ -179,20 +181,25 @@ jobs:
repository: cartsnitch/infra
token: ${{ steps.app-token.outputs.token }}
ref: main
path: infra
- name: Install kubectl
uses: azure/setup-kubectl@v4
- name: Install kustomize
uses: imranismail/setup-kustomize@v2
- name: Update dev overlay image tag
working-directory: apps/overlays/dev
run: |
cd infra/apps/overlays/dev
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }}
kustomize edit set image ghcr.io/cartsnitch/auth:${{ needs.build-and-push-auth.outputs.calver_tag }}
- name: Commit and push to infra
run: |
cd apps/overlays/dev
cd infra
git config user.name "cartsnitch-ci[bot]"
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add kustomization.yaml
git commit -m "ci(dev): update cartsnitch image to ${{ needs.build-and-push.outputs.calver_tag }}"
git add apps/overlays/dev/kustomization.yaml
git commit -m "ci(dev): update cartsnitch and auth images to ${{ needs.build-and-push.outputs.calver_tag }}"
git push origin main
+1 -45
View File
@@ -1,45 +1 @@
# CartSnitch Monorepo
CartSnitch is a self-hosted grocery price intelligence platform. This repo consolidates the core services and the flagship frontend PWA.
## Services
| Directory | Service | Purpose |
|-----------|---------|---------|
| `/` (root) | **Frontend** | React 18 PWA — mobile-first price intelligence UI |
| `api/` | **API Gateway** | FastAPI — frontend-facing REST API |
| `common/` | **Common** | Shared Python models, schemas, Alembic migrations |
| `receiptwitness/` | **ReceiptWitness** | Purchase ingestion via retailer scrapers |
## Quick Start
### Frontend (root)
```bash
npm install
npm run dev # http://localhost:5173
npm run build # production build
npm run test # unit tests (Vitest)
```
### Python Services
Each Python service uses [uv](https://github.com/astral-sh/uv) and has its own `pyproject.toml`:
```bash
cd api # or common / receiptwitness
uv sync
uv run pytest
```
## Development Workflow
- **Never push directly to main.** Always open a PR from a feature branch.
- Branch naming: `feature/<description>` or `fix/<description>`
- Conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`
## Architecture
For full details see [CLAUDE.md](./CLAUDE.md) or the per-service `CLAUDE.md` in each subdirectory.
CartSnitch is a polyrepo-style monorepo: each service can be built and deployed independently, but sharing code between `common/` and the other Python services is done via local path dependencies in `pyproject.toml`.
# CartSnitch
+151
View File
@@ -0,0 +1,151 @@
# CartSnitch UAT Runbook v1
**Version:** 1.0
**Author:** Savannah Savings, CTO
**Date:** 2026-03-30
**Effective:** Immediately upon Phase 1 completion
---
## 1. Defect Severity Classification
Every defect discovered during UAT **must** be classified by severity and priority before triage.
### Severity Levels
| Severity | Definition | Examples |
|----------|-----------|----------|
| **S1 — Critical** | Blocks all users from completing a core journey. System is down, data is lost, or security is breached. | Login page crashes for all users; purchase data deleted; auth tokens exposed in response |
| **S2 — High** | Blocks a major user flow for a significant portion of users. Core feature is broken but workarounds may exist. | Registration fails for email addresses with `+` character; price alerts never trigger; store comparison shows wrong prices |
| **S3 — Medium** | Feature is degraded but usable. User can complete the journey with friction. | Date formatting shows raw ISO string instead of friendly date; slow page load (>5s) on product detail; search results not sorted correctly |
| **S4 — Low** | Cosmetic issue, minor UI inconsistency, or edge case with minimal user impact. | Button text truncated on narrow screens; extra whitespace in footer; tooltip shows on hover but not on focus |
### Priority Levels
Priority determines **when** the defect must be fixed. Priority is set by the CTO based on severity, business impact, and sprint capacity.
| Priority | SLA | When to Use |
|----------|-----|------------|
| **P0 — Fix Now** | Triage within 1 hour, fix deployed within 4 hours | S1 defects, any security vulnerability, data integrity issues |
| **P1 — Fix This Sprint** | Triage within 4 hours, fix in current sprint | S2 defects blocking upcoming release, S1 defects with viable workaround |
| **P2 — Fix Next Sprint** | Triage within 24 hours, scheduled for next sprint | S3 defects, S2 defects with easy workarounds |
| **P3 — Backlog** | Triage within 48 hours, prioritized against backlog | S4 defects, minor improvements, nice-to-haves |
### Defect Report Template
Every defect filed during UAT must include:
```
**Title:** [Short description]
**Severity:** S1/S2/S3/S4
**Priority:** P0/P1/P2/P3 (set by CTO at triage)
**Journey:** [Which user journey — J1 through J10]
**Environment:** [Dev / Prod, deployed image tag]
**Steps to Reproduce:**
1. Navigate to ...
2. Click ...
3. Enter ...
**Expected Result:** ...
**Actual Result:** ...
**Screenshots/Logs:** [Attach or link]
**Browser/Device:** [e.g., Chromium 124, mobile viewport 390x844]
```
---
## 2. UAT Entry Criteria
UAT **must not begin** until ALL of the following are satisfied. Checkout Charlie verifies these before opening the UAT gate.
| # | Criterion | Verified By |
|---|-----------|------------|
| E1 | CI pipeline passes on the merged commit (lint, type-check, unit tests, build) | GitHub Actions (automated) |
| E2 | Docker image is built and pushed to GHCR with a CalVer tag | GitHub Actions (automated) |
| E3 | Dev environment is deployed and accessible at `cartsnitch.dev.farh.net` | Flux reconciliation + health check |
| E4 | All Playwright E2E tests pass in CI | GitHub Actions (automated) |
| E5 | No open S1/S2 defects from previous UAT cycle | Checkout Charlie (manual check) |
| E6 | PR has been reviewed and approved by QA (Checkout Charlie) and CTO (Savannah Savings) | GitHub PR approvals |
| E7 | PR has been merged to main by CEO (Coupon Carl) | GitHub merge event |
| E8 | Acceptance criteria for the feature/change are documented in the Paperclip issue | Checkout Charlie (manual check) |
**If any entry criterion is not met**, UAT is blocked. Checkout Charlie must comment on the Paperclip issue specifying which criteria failed and assign back to the responsible party.
---
## 3. UAT Exit Criteria
UAT is **complete** only when ALL of the following are satisfied. Rollback Rhonda verifies these before signing off.
| # | Criterion | Verified By |
|---|-----------|------------|
| X1 | All 10 critical user journeys (J1-J10) have been executed | Rollback Rhonda (full regression) |
| X2 | Zero open S1 (Critical) defects | Defect tracker |
| X3 | Zero open S2 (High) defects, OR CTO has granted a documented exception | Defect tracker + CTO sign-off |
| X4 | All S3/S4 defects are logged and triaged (not necessarily fixed) | Defect tracker |
| X5 | 100% test execution rate -- every test case was run, none skipped | Rollback Rhonda's UAT report |
| X6 | Accessibility scan (axe-core) reports zero critical violations | Automated in E2E suite |
| X7 | Lighthouse performance score >= 50, accessibility score >= 90 | Lighthouse CI |
| X8 | Written sign-off from Rollback Rhonda confirming all criteria met | Paperclip comment on issue |
**If any exit criterion is not met**, the release is blocked. Rollback Rhonda must:
1. File defects for all failures using the Defect Report Template above.
2. Comment on the Paperclip issue specifying which exit criteria failed.
3. Assign back to CTO for triage and redistribution.
---
## 4. UAT Execution Procedure
### 4.1 Pre-UAT (Checkout Charlie)
1. Verify all entry criteria (E1-E8) are met.
2. Comment on the Paperclip issue: "UAT gate open -- all entry criteria verified."
3. Assign to Rollback Rhonda with status todo.
### 4.2 UAT Execution (Rollback Rhonda)
1. **Full regression run** -- execute ALL 10 user journeys against cartsnitch.dev.farh.net. No partial runs. No exceptions.
2. For each journey, verify:
- All interactive elements respond correctly (buttons, forms, links, toggles)
- State transitions are correct (auth state, data mutations, navigation)
- Error states are handled gracefully (invalid input, network failures)
- Accessibility scan passes (axe-core integrated in Playwright)
3. Log results for each journey: PASS / FAIL with details.
4. File defects immediately for any failures.
5. Complete the UAT report with execution results.
### 4.3 Post-UAT Sign-Off
1. If all exit criteria (X1-X8) are met:
- Rollback Rhonda posts sign-off comment: "UAT PASSED -- all exit criteria met."
- Production promotion is automated via Flux on UAT pass.
2. If any exit criterion fails:
- Rollback Rhonda posts failure comment with specific failures.
- CTO triages defects and redistributes to engineers.
- After fixes are merged, UAT restarts from 4.1 (full cycle).
---
## 5. Critical User Journeys Reference
| ID | Journey | Key Interactions |
|----|---------|-----------------|
| J1 | Registration -> Login -> Dashboard | Form submission, auth state, redirect |
| J2 | Login -> Browse Products -> View Detail -> Price Chart | Search, navigation, data visualization |
| J3 | Login -> Purchases -> Purchase Detail -> Product Link | List navigation, detail view, cross-linking |
| J4 | Login -> Connect Store Account -> Verify Connection | OAuth flow, external integration |
| J5 | Login -> Create Price Alert -> View -> Delete Alert | CRUD operations, confirmation dialogs |
| J6 | Login -> Browse Coupons -> Copy Code | Clipboard interaction, toast feedback |
| J7 | Login -> Settings -> Toggle Preferences -> Sign Out | Checkbox toggles, theme switch, session termination |
| J8 | Login -> Store Comparison -> Compare Prices | Data comparison, sorting, price display |
| J9 | Forgot Password Flow | Email input, validation, redirect |
| J10 | Unauth Access -> Redirect to Login | Route protection, redirect behavior |
---
## 6. Revision History
| Version | Date | Author | Changes |
|---------|------|--------|---------|
| 1.0 | 2026-03-30 | Savannah Savings | Initial runbook -- defect taxonomy, entry/exit criteria, execution procedure |
+29 -1
View File
@@ -1,8 +1,36 @@
import { createAuthClient } from "better-auth/react"
import type { BetterFetchPlugin } from "@better-fetch/fetch"
/**
* Maps 'name' -> 'display_name' in register requests to match the API's RegisterRequest schema.
*/
const displayNameMapper: BetterFetchPlugin = {
id: "display-name-mapper",
name: "display-name-mapper",
hooks: {
onRequest: async (context) => {
const url = typeof context.url === "string" ? context.url : context.url.pathname
if (
url.endsWith("/auth/register") &&
context.method === "POST" &&
context.body &&
"name" in context.body
) {
context.body = {
...context.body,
display_name: context.body.name as string,
name: undefined,
}
}
return context
},
},
}
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_AUTH_URL ?? "http://localhost:3001",
baseURL: import.meta.env.VITE_AUTH_URL || "",
basePath: "/auth",
fetchPlugins: [displayNameMapper],
})
export const { useSession, signIn, signUp, signOut } = authClient
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { formatCurrency } from '../formatCurrency';
describe('formatCurrency', () => {
it('formats 0 cents as $0.00', () => {
expect(formatCurrency(0)).toBe('$0.00');
});
it('formats 199 cents as $1.99', () => {
expect(formatCurrency(199)).toBe('$1.99');
});
it('formats 10000 cents as $100.00', () => {
expect(formatCurrency(10000)).toBe('$100.00');
});
it('handles negative values', () => {
expect(formatCurrency(-500)).toBe('-$5.00');
});
it('handles large numbers', () => {
expect(formatCurrency(99999999)).toBe('$999,999.99');
});
it('supports custom locale', () => {
expect(formatCurrency(1999, 'de-DE', 'EUR')).toContain('19,99');
});
it('supports custom currency', () => {
const result = formatCurrency(1000, 'en-US', 'EUR');
expect(result).toContain('10.00');
});
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { formatDate } from '../formatDate';
describe('formatDate', () => {
describe('short style', () => {
it('formats an ISO date string', () => {
const result = formatDate('2024-03-15', 'short');
expect(result).toMatch(/Mar 15, 2024/);
});
it('formats a Date object', () => {
const result = formatDate(new Date('2024-03-15'), 'short');
expect(result).toMatch(/Mar 15, 2024/);
});
});
describe('long style', () => {
it('formats with weekday and full month name', () => {
const result = formatDate('2024-03-15', 'long');
expect(result).toMatch(/Friday/);
expect(result).toMatch(/March/);
});
});
describe('relative style', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('returns "just now" for very recent dates', () => {
const now = new Date('2024-01-01T12:00:00Z');
vi.setSystemTime(now);
const result = formatDate(new Date('2024-01-01T11:59:59Z'), 'relative');
expect(result).toBe('just now');
});
it('returns minutes ago', () => {
const now = new Date('2024-01-01T12:00:00Z');
vi.setSystemTime(now);
const result = formatDate(new Date('2024-01-01T11:45:00Z'), 'relative');
expect(result).toBe('15m ago');
});
it('returns hours ago', () => {
const now = new Date('2024-01-01T12:00:00Z');
vi.setSystemTime(now);
const result = formatDate(new Date('2024-01-01T09:00:00Z'), 'relative');
expect(result).toBe('3h ago');
});
it('returns days ago', () => {
const now = new Date('2024-01-05T12:00:00Z');
vi.setSystemTime(now);
const result = formatDate(new Date('2024-01-01T12:00:00Z'), 'relative');
expect(result).toBe('4d ago');
});
});
});
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { getStore, getStoreName, STORE_SLUGS } from '../storeSlugs';
describe('storeSlugs', () => {
describe('STORE_SLUGS constant', () => {
it('contains meijer, kroger, and target', () => {
expect(STORE_SLUGS).toHaveProperty('meijer');
expect(STORE_SLUGS).toHaveProperty('kroger');
expect(STORE_SLUGS).toHaveProperty('target');
});
});
describe('getStore', () => {
it('returns store data for known slug', () => {
const store = getStore('meijer');
expect(store).toEqual({
name: 'Meijer',
color: '#e31837',
icon: '/icons/stores/meijer.svg',
});
});
it('returns null for unknown slug', () => {
expect(getStore('unknown-store')).toBeNull();
});
it('is case insensitive', () => {
expect(getStore('KROGER')).toBeTruthy();
expect(getStore('Target')).toBeTruthy();
});
});
describe('getStoreName', () => {
it('returns store name for known slug', () => {
expect(getStoreName('kroger')).toBe('Kroger');
});
it('returns raw slug for unknown store', () => {
expect(getStoreName('unknown-store')).toBe('unknown-store');
});
it('is case insensitive', () => {
expect(getStoreName('TARGET')).toBe('Target');
});
});
});
+10
View File
@@ -0,0 +1,10 @@
export function formatCurrency(
cents: number,
locale = 'en-US',
currency = 'USD'
): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(cents / 100);
}
+34
View File
@@ -0,0 +1,34 @@
export function formatDate(
date: string | Date,
style: 'short' | 'long' | 'relative' = 'short'
): string {
const d = typeof date === 'string' ? new Date(date) : date;
if (style === 'short') {
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
if (style === 'long') {
return d.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
});
}
// relative
const diff = Date.now() - d.getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
+13
View File
@@ -0,0 +1,13 @@
export const STORE_SLUGS: Record<string, { name: string; color: string; icon: string }> = {
meijer: { name: 'Meijer', color: '#e31837', icon: '/icons/stores/meijer.svg' },
kroger: { name: 'Kroger', color: '#0033a0', icon: '/icons/stores/kroger.svg' },
target: { name: 'Target', color: '#cc0000', icon: '/icons/stores/target.svg' },
};
export function getStore(slug: string) {
return STORE_SLUGS[slug.toLowerCase()] ?? null;
}
export function getStoreName(slug: string): string {
return getStore(slug)?.name ?? slug;
}