Compare commits

..

16 Commits

Author SHA1 Message Date
Stockboy Steve a2b0e7cbd3 fix(auth): revert store_id to uuid.UUID in user schemas
CTO review feedback: store_id must remain uuid.UUID since Store
model still uses UUIDPrimaryKeyMixin. Only user_id changes to str.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 17:44:20 +00:00
Stockboy Steve 6d7d54729c fix(auth): add alembic migration to change users.id and user_id FKs from uuid to text 2026-03-31 17:32:00 +00:00
Stockboy Steve 895ad77850 fix(auth): change users.id and user_id FKs from uuid to text
Better-Auth generates nanoid-style text IDs (e.g. pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI),
but the users table was using PostgreSQL uuid type, causing INSERT failures on
registration. This changes User.id, UserStoreAccount.user_id, and Purchase.user_id
from uuid to text, with a corresponding Alembic migration.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 17:15:31 +00:00
cartsnitch-ci[bot] 5f1570e6d2 fix(auth): re-add session table model mapping
Better-Auth defaults to singular "session" table with camelCase
columns, but our DB uses plural "sessions" with snake_case columns
(migration 002). Add modelName and field mappings to match the
existing pattern for user, account, and verification models.

Fixes: 401 on login and 422 on registration.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 15:23:45 +00:00
cartsnitch-ci[bot] 96c0f89a03 chore: trigger CI after rebase
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 04:47:17 +00:00
Barcode Betty 0f8aa2fe47 fix(ci): address CTO review feedback on PR #64
- Fix refs_heads_main typo → refs/heads/main in build-and-push-auth metadata
- Fix ci(ev) typo → ci(dev) in deploy-dev commit message
- Add preview server step before lhci autorun in lighthouse job

Addresses: CAR-199

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 04:43:45 +00:00
Barcode Betty 6903c7dde3 fix(lighthouse): use warn for preset audit assertions + add robots.txt
Per CTO guidance, override preset per-audit assertions to warn:
- errors-in-console: warn (browser dev errors, not prod blockers)
- network-dependency-tree-insight: warn (existing perf debt)
- robots-txt: warn (existing SEO gap)
- unused-javascript: warn (existing perf debt)

Add public/robots.txt so the robots-txt audit passes at warn level.
These are known gaps to address post-merge, not merge blockers.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 04:43:45 +00:00
Barcode Betty 2946ac8dc5 fix(lighthouse): install Chromium system deps via --with-deps
Playwright Chromium binary was missing libnspr4.so and other
system libraries. Use `npx playwright install --with-deps chromium`
to install Chromium along with all required system dependencies.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 04:43:45 +00:00
Barcode Betty 6717e105f4 fix(lighthouse): set LHCI_CHROME_PATH via runtime discovery
- Re-add Playwright Chromium install (LHCI needs a Chrome binary)
- Use `find` at runtime to locate Playwright's chrome binary:
  CHROME_PATH=$(find /home/runner/.cache/ms-playwright -name chrome ...)
- Pass to LHCI via LHCI_CHROME_PATH env var so LHCI does
  not try (and fail) to auto-download Puppeteer's Chromium

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 04:43:45 +00:00
Barcode Betty 01ea36c5aa fix(lighthouse): use staticDistDir, drop Playwright dependency
- lighthouserc.json: replace startServerCommand:npm-run-preview
  with staticDistDir:./dist so LHCI serves files directly
- CI workflow: remove Playwright/Chromium install step and
  LHCI_CHROME_PATH env var (LHCI bundles its own Puppeteer)
- LHCI now uses its built-in static server + bundled Chromium

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 04:43:45 +00:00
Stockboy Steve 0bb4b7d183 fix(lighthouse): set LHCI_CHROME_PATH and lower thresholds per CTO feedback
- Set LHCI_CHROME_PATH to Playwright chromium binary path so LHCI
  healthcheck can find Chrome
- Lower thresholds: performance=0.5, accessibility=0.7 (error), seo=0.7
- SEO threshold was missing, now added
2026-03-31 04:43:45 +00:00
Barcode Betty 7b9194a152 fix(ci): install Chromium via playwright instead of missing action
browser-actions/chromium@v3 does not exist. Switch to using
npm install -g playwright && npx playwright install chromium.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 04:43:45 +00:00
Barcode Betty ca8cf2a80a fix(ci): install Chromium before running Lighthouse CI
lhci autorun requires Chrome to be present on the runner. This was
causing the lighthouse job to fail with "Chrome installation not found".

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 04:43:45 +00:00
cartsnitch-engineer[bot] 5bb0a5817b feat(ci): add Lighthouse CI performance checks 2026-03-31 04:43:45 +00:00
cartsnitch-engineer[bot] 2444219f75 feat(ci): add Lighthouse CI configuration 2026-03-31 04:43:45 +00:00
Barcode Betty c707caea41 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-31 04:43:45 +00:00
19 changed files with 196 additions and 391 deletions
+25
View File
@@ -46,6 +46,31 @@ jobs:
- name: Run tests
run: npx vitest run
lighthouse:
runs-on: runners-cartsnitch
needs: [test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm ci
- run: npm run build
- name: Install Chromium for Lighthouse
run: |
npm install -g playwright
npx playwright install --with-deps chromium
- name: Start preview server
run: |
npm run preview &
npx wait-on http://localhost:4173/ --timeout 30000
- name: Run Lighthouse CI
run: |
CHROME_PATH=$(find /home/runner/.cache/ms-playwright -name chrome -type f 2>/dev/null | head -1)
npm install -g @lhci/cli
LHCI_CHROME_PATH="$CHROME_PATH" lhci autorun
build-and-push:
runs-on: runners-cartsnitch
needs: [lint, test]
@@ -0,0 +1,53 @@
"""Change users.id and FK columns from uuid to text.
Better-Auth generates nanoid-style text IDs (e.g. pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI),
but the users table was using PostgreSQL uuid type, causing INSERT failures.
Revision ID: 003_fix_user_id_text
Revises: 002_better_auth_tables
Create Date: 2026-03-31
"""
import sqlalchemy as sa
from sqlalchemy import text
from alembic import op
revision = "003_fix_user_id_text"
down_revision = "002_better_auth_tables"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Step 1: Drop FK constraints that reference users.id
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
# Step 2: Alter users.id from uuid to text
op.alter_column("users", "id", existing_type=sa.UUID(), type_=sa.Text(), existing_nullable=False, postgresql_using="id::text")
# Step 3: Alter user_store_accounts.user_id from uuid to text
op.alter_column("user_store_accounts", "user_id", existing_type=sa.UUID(), type_=sa.Text(), existing_nullable=False, postgresql_using="user_id::text")
# Step 4: Alter purchases.user_id from uuid to text
op.alter_column("purchases", "user_id", existing_type=sa.UUID(), type_=sa.Text(), existing_nullable=False, postgresql_using="user_id::text")
# Step 5: Re-add FK constraints
op.execute(text("ALTER TABLE user_store_accounts ADD CONSTRAINT user_store_accounts_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id)"))
op.execute(text("ALTER TABLE purchases ADD CONSTRAINT purchases_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id)"))
def downgrade() -> None:
# Drop FK constraints
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
# Alter back to UUID
op.alter_column("purchases", "user_id", existing_type=sa.Text(), type_=sa.UUID(), existing_nullable=False, postgresql_using="user_id::uuid")
op.alter_column("user_store_accounts", "user_id", existing_type=sa.Text(), type_=sa.UUID(), existing_nullable=False, postgresql_using="user_id::uuid")
op.alter_column("users", "id", existing_type=sa.Text(), type_=sa.UUID(), existing_nullable=False, postgresql_using="id::uuid")
# Re-add FK constraints
op.execute(text("ALTER TABLE user_store_accounts ADD CONSTRAINT user_store_accounts_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id)"))
op.execute(text("ALTER TABLE purchases ADD CONSTRAINT purchases_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id)"))
+9
View File
@@ -36,6 +36,15 @@ export const auth = betterAuth({
},
session: {
modelName: "sessions",
fields: {
userId: "user_id",
expiresAt: "expires_at",
ipAddress: "ip_address",
userAgent: "user_agent",
createdAt: "created_at",
updatedAt: "updated_at",
},
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // refresh after 1 day
cookieCache: {
@@ -0,0 +1,61 @@
"""Change users.id and user_id FKs from uuid to text.
Revision ID: 003_fix_user_id_text
Revises:
Create Date: 2026-03-31
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "003_fix_user_id_text"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Drop FK constraints first
op.execute("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey")
op.execute("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey")
# Alter users.id from uuid to text
op.execute("ALTER TABLE users ALTER COLUMN id TYPE text USING id::text")
# Alter user_id columns from uuid to text
op.execute("ALTER TABLE user_store_accounts ALTER COLUMN user_id TYPE text USING user_id::text")
op.execute("ALTER TABLE purchases ALTER COLUMN user_id TYPE text USING user_id::text")
# Re-add FK constraints
op.execute(
"ALTER TABLE user_store_accounts ADD CONSTRAINT user_store_accounts_user_id_fkey "
"FOREIGN KEY (user_id) REFERENCES users(id)"
)
op.execute(
"ALTER TABLE purchases ADD CONSTRAINT purchases_user_id_fkey "
"FOREIGN KEY (user_id) REFERENCES users(id)"
)
def downgrade() -> None:
# Drop FK constraints
op.execute("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey")
op.execute("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey")
# Alter back to uuid
op.execute("ALTER TABLE users ALTER COLUMN id TYPE uuid USING id::uuid")
op.execute("ALTER TABLE user_store_accounts ALTER COLUMN user_id TYPE uuid USING user_id::uuid")
op.execute("ALTER TABLE purchases ALTER COLUMN user_id TYPE uuid USING user_id::uuid")
# Re-add FK constraints
op.execute(
"ALTER TABLE user_store_accounts ADD CONSTRAINT user_store_accounts_user_id_fkey "
"FOREIGN KEY (user_id) REFERENCES users(id)"
)
op.execute(
"ALTER TABLE purchases ADD CONSTRAINT purchases_user_id_fkey "
"FOREIGN KEY (user_id) REFERENCES users(id)"
)
@@ -32,7 +32,7 @@ class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "purchases"
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
store_location_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("store_locations.id"))
receipt_id: Mapped[str] = mapped_column(String(200), nullable=False)
+3 -2
View File
@@ -15,11 +15,12 @@ if TYPE_CHECKING:
from cartsnitch_common.models.store import Store
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
class User(TimestampMixin, Base):
"""Application user."""
__tablename__ = "users"
id: Mapped[str] = mapped_column(Text, primary_key=True)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[str | None] = mapped_column(String(100))
@@ -37,7 +38,7 @@ class UserStoreAccount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "user_store_accounts"
__table_args__ = (UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
# WARNING: Contains retailer session cookies/tokens. Encryption-at-rest
# required before production deployment (e.g., pgcrypto or app-level encryption).
@@ -40,7 +40,7 @@ class PurchaseItemRead(BaseModel):
class PurchaseCreate(BaseModel):
user_id: uuid.UUID
user_id: str
store_id: uuid.UUID
store_location_id: uuid.UUID | None = None
receipt_id: str
@@ -58,7 +58,7 @@ class PurchaseRead(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
user_id: uuid.UUID
user_id: str
store_id: uuid.UUID
store_location_id: uuid.UUID | None
receipt_id: str
+3 -3
View File
@@ -17,7 +17,7 @@ class UserCreate(BaseModel):
class UserRead(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
id: str
email: str
display_name: str | None
created_at: datetime
@@ -25,7 +25,7 @@ class UserRead(BaseModel):
class UserStoreAccountCreate(BaseModel):
user_id: uuid.UUID
user_id: str
store_id: uuid.UUID
session_data: dict | None = None
status: AccountStatus = AccountStatus.ACTIVE
@@ -35,7 +35,7 @@ class UserStoreAccountRead(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
user_id: uuid.UUID
user_id: str
store_id: uuid.UUID
status: AccountStatus
session_expires_at: datetime | None
-151
View File
@@ -1,151 +0,0 @@
# 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 |
+25
View File
@@ -0,0 +1,25 @@
{
"ci": {
"collect": {
"staticDistDir": "./dist",
"url": ["http://localhost:4173/"],
"numberOfRuns": 1
},
"assert": {
"preset": "lighthouse:no-pwa",
"assertions": {
"categories:performance": ["warn", { "minScore": 0.5 }],
"categories:accessibility": ["error", { "minScore": 0.7 }],
"categories:best-practices": ["warn", { "minScore": 0.8 }],
"categories:seo": ["warn", { "minScore": 0.7 }],
"errors-in-console": ["warn"],
"network-dependency-tree-insight": ["warn"],
"robots-txt": ["warn"],
"unused-javascript": ["warn"]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://cartsnitch.com/sitemap.xml
+10 -6
View File
@@ -3,9 +3,10 @@ FROM python:3.12-slim AS build
WORKDIR /app
# build-essential and libpq-dev are needed to compile any C-extension wheels
# (e.g. psycopg2 fallback). git is no longer needed here.
# git is required to install cartsnitch-common from GitHub; build-essential and
# libpq-dev are needed to compile any C-extension wheels (e.g. psycopg2 fallback)
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
libpq-dev \
build-essential \
&& rm -rf /var/lib/apt/lists/*
@@ -13,10 +14,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY pyproject.toml ./
COPY src/ ./src/
# cartsnitch-common is not on PyPI — install it from the local monorepo common/
# directory. The common/ dir is the cartsnitch-common Python package.
COPY common/ /tmp/common/
RUN pip install --no-cache-dir --prefix=/install /tmp/common/ .
# cartsnitch-common is not on PyPI — install it directly from GitHub, then
# install the rest of the package dependencies in a single resolver pass so
# pip can satisfy the cartsnitch-common>=0.1.0 constraint declared in
# pyproject.toml without hitting PyPI for it.
RUN pip install --no-cache-dir --prefix=/install \
"cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b" \
.
# Stage 2: Production image with Playwright + Chromium
FROM python:3.12-slim AS prod
-28
View File
@@ -1,36 +1,8 @@
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 || "",
basePath: "/auth",
fetchPlugins: [displayNameMapper],
})
export const { useSession, signIn, signUp, signOut } = authClient
@@ -1,33 +0,0 @@
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
@@ -1,62 +0,0 @@
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
@@ -1,46 +0,0 @@
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
@@ -1,10 +0,0 @@
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
@@ -1,34 +0,0 @@
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
@@ -1,13 +0,0 @@
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;
}