Compare commits

..

14 Commits

Author SHA1 Message Date
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
cartsnitch-ceo[bot] 44c475265e Merge pull request #56 from cartsnitch/feat/uat-seed-user
feat: add dedicated UAT seed user with known credentials
2026-03-29 21:57:26 +00:00
cartsnitch-ceo[bot] 8e1f61214c Merge branch 'main' into feat/uat-seed-user 2026-03-29 21:54:43 +00:00
cartsnitch-ceo[bot] fb1c5fb929 fix: align auth client basePath with server config
fix: align auth client basePath with server config
2026-03-29 21:48:27 +00:00
Barcode Betty 75be08ccf3 feat: add dedicated UAT seed user with known credentials
Add guaranteed UAT test user (uat@cartsnitch.com / CartSnitch-UAT-2026!)
seeded via Better-Auth bcrypt path. Idempotent — re-running the seed
skips the user if it already exists.

- Add 002_better_auth_tables Alembic migration (sessions, accounts,
  verifications tables + email_verified/image on users)
- Add bcrypt>=4.0,<6.0 to [seed] extra (CTO feedback: was bcrypt>=0.15,<1.0
  which matches zero installable versions)
- Fix account_id to use str(UAT_USER_ID) to match migration convention
  (CTO feedback: was using UAT_EMAIL which was inconsistent)
- Document credentials in common/README.md under Test Users

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 21:20:31 +00:00
12 changed files with 330 additions and 51 deletions
+12 -5
View File
@@ -111,6 +111,8 @@ jobs:
build-and-push-auth: build-and-push-auth:
runs-on: runners-cartsnitch runs-on: runners-cartsnitch
needs: [lint, test] needs: [lint, test]
outputs:
calver_tag: ${{ steps.calver.outputs.version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -161,7 +163,7 @@ jobs:
deploy-dev: deploy-dev:
runs-on: runners-cartsnitch 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' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps: steps:
- name: Generate GitHub App token - name: Generate GitHub App token
@@ -179,20 +181,25 @@ jobs:
repository: cartsnitch/infra repository: cartsnitch/infra
token: ${{ steps.app-token.outputs.token }} token: ${{ steps.app-token.outputs.token }}
ref: main ref: main
path: infra
- name: Install kubectl - name: Install kubectl
uses: azure/setup-kubectl@v4 uses: azure/setup-kubectl@v4
- name: Install kustomize
uses: imranismail/setup-kustomize@v2
- name: Update dev overlay image tag - name: Update dev overlay image tag
working-directory: apps/overlays/dev
run: | 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/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 - name: Commit and push to infra
run: | run: |
cd apps/overlays/dev cd infra
git config user.name "cartsnitch-ci[bot]" git config user.name "cartsnitch-ci[bot]"
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com" git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add kustomization.yaml git add apps/overlays/dev/kustomization.yaml
git commit -m "ci(dev): update cartsnitch image to ${{ needs.build-and-push.outputs.calver_tag }}" git commit -m "ci(dev): update cartsnitch and auth images to ${{ needs.build-and-push.outputs.calver_tag }}"
git push origin main git push origin main
+1 -45
View File
@@ -1,45 +1 @@
# CartSnitch Monorepo # CartSnitch
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`.
+28
View File
@@ -0,0 +1,28 @@
# CartSnitch Common
Shared models, schemas, and utilities for CartSnitch services.
## Test Users
The following users are seeded by `cartsnitch-seed` and can be used for local development and UAT.
| Email | Password | Display Name | Notes |
|---|---|---|---|
| `uat@cartsnitch.com` | `CartSnitch-UAT-2026!` | UAT Tester | Primary UAT account. Use for regression testing in the CartSnitch frontend. Created by the seed runner via Better-Auth's bcrypt path — credentials work against the live auth service. Idempotent; re-running the seed skips this user if it already exists. |
### Running the Seed
```bash
# Install with seed dependencies
pip install -e "cartsnitch-common[seed]"
# Run (requires CARTSNITCH_DATABASE_URL_SYNC)
CARTSNITCH_DATABASE_URL_SYNC=postgresql://user:pass@localhost:5432/cartsnitch \
cartsnitch-seed
```
### Architecture
- **Models** live in `src/cartsnitch_common/models/`
- **Alembic migrations** run via the `api` service (`api/alembic/`)
- **Seed runner** runs via `cartsnitch-seed` (installed as a package entry point)
+1
View File
@@ -27,6 +27,7 @@ dev = [
] ]
seed = [ seed = [
"faker>=33.0,<34.0", "faker>=33.0,<34.0",
"bcrypt>=4.0,<6.0",
] ]
[project.scripts] [project.scripts]
@@ -2,8 +2,10 @@
import random import random
import time import time
import uuid
from typing import Any from typing import Any
import bcrypt
from faker import Faker from faker import Faker
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -184,6 +186,65 @@ def run_seed(
session.commit() session.commit()
_seed_uat_user(session)
elapsed = time.monotonic() - t0 elapsed = time.monotonic() - t0
_log("") _log("")
_log(f"Seed complete in {elapsed:.1f}s") _log(f"Seed complete in {elapsed:.1f}s")
# ---------------------------------------------------------------------------
# UAT seed user
# ---------------------------------------------------------------------------
UAT_EMAIL = "uat@cartsnitch.com"
UAT_PASSWORD = "CartSnitch-UAT-2026!"
UAT_DISPLAY_NAME = "UAT Tester"
UAT_USER_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
def _seed_uat_user(session: Session) -> None:
"""Insert or verify the dedicated UAT test user.
The user is created via Better-Auth's bcrypt hashing path so credentials
work against the live auth service. Idempotent — skips if the user already
exists.
"""
existing = session.execute(
text("SELECT id FROM users WHERE email = :email"),
{"email": UAT_EMAIL},
).fetchone()
if existing is not None:
_log(f"UAT user {UAT_EMAIL} already exists — skipping")
return
password_hash = bcrypt.hashpw(UAT_PASSWORD.encode(), bcrypt.gensalt()).decode()
session.execute(
text(
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) "
"VALUES (:id, :email, :hashed_password, :display_name, true, now(), now())"
),
{
"id": str(UAT_USER_ID),
"email": UAT_EMAIL,
"hashed_password": password_hash,
"display_name": UAT_DISPLAY_NAME,
},
)
session.execute(
text(
"INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) "
"VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())"
),
{
"user_id": str(UAT_USER_ID),
"account_id": str(UAT_USER_ID),
"password": password_hash,
},
)
session.commit()
_log(f"UAT user {UAT_EMAIL} created")
+29 -1
View File
@@ -1,8 +1,36 @@
import { createAuthClient } from "better-auth/react" 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({ export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_AUTH_URL ?? "http://localhost:3001", baseURL: import.meta.env.VITE_AUTH_URL || "",
basePath: "/auth", basePath: "/auth",
fetchPlugins: [displayNameMapper],
}) })
export const { useSession, signIn, signUp, signOut } = authClient 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;
}