Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 440f92e96e | |||
| 97bbdf68a5 | |||
| 02e5bee390 | |||
| d475b3876a | |||
| 76bcc53992 | |||
| 470b615528 | |||
| f26f8f7e56 | |||
| 78b7831d43 | |||
| e45b510519 | |||
| f25044ea7e | |||
| b637fd9c11 | |||
| 983ee2c398 | |||
| 8af7b37b38 | |||
| b21a30b2e7 | |||
| 361ad3acc2 | |||
| 5e165d277e | |||
| 6828e4d0a9 | |||
| 0b9dd74f7d | |||
| 7a06f0618b | |||
| 9385463171 | |||
| b658f77f9c | |||
| 8706112be3 | |||
| 00b2b2469b | |||
| 1a464fd77d | |||
| 962e64b72a | |||
| ff91003e90 | |||
| cd733fbc7d | |||
| 1f9086f2f2 | |||
| 59407ae54a | |||
| 8659b99059 | |||
| e82ed5ac12 | |||
| 0d8ee5f386 | |||
| 09864c1a96 | |||
| 3621504c22 | |||
| a28e9d9dd4 | |||
| d405caceca | |||
| f0d1694a1c | |||
| 6b32197ad2 | |||
| 6e76222b81 | |||
| 1966b94a97 | |||
| a33b6a0c30 | |||
| c2b5ccb830 | |||
| 69e1be1560 | |||
| 43673583c1 | |||
| aba26b9d2f | |||
| dfe7b42db3 | |||
| b6df3dc0cb | |||
| 6c09db5478 | |||
| 3f13cb1bf6 | |||
| d4f7194d3f |
@@ -48,6 +48,18 @@ jobs:
|
|||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npx vitest run
|
run: npx vitest run
|
||||||
|
|
||||||
|
audit:
|
||||||
|
runs-on: runners-cartsnitch
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- name: Check for vulnerabilities
|
||||||
|
run: npm audit --audit-level=high
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
runs-on: runners-cartsnitch
|
runs-on: runners-cartsnitch
|
||||||
steps:
|
steps:
|
||||||
@@ -60,6 +72,31 @@ jobs:
|
|||||||
- run: npx playwright install --with-deps chromium
|
- run: npx playwright install --with-deps chromium
|
||||||
- run: npx playwright test
|
- run: npx playwright test
|
||||||
|
|
||||||
|
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
|
||||||
|
CHROME_PATH="$CHROME_PATH" lhci autorun --chrome-flags="--headless=new --no-sandbox --disable-gpu --disable-dev-shm-usage"
|
||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: runners-cartsnitch
|
runs-on: runners-cartsnitch
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
|||||||
+1
-1
@@ -30,4 +30,4 @@ EXPOSE 8000
|
|||||||
HEALTHCHECK --interval=30s --timeout=3s \
|
HEALTHCHECK --interval=30s --timeout=3s \
|
||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
||||||
|
|
||||||
CMD ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["sh", "-c", "python -m alembic upgrade head && uvicorn cartsnitch_api.main:app --host 0.0.0.0 --port 8000"]
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Fix users.id UUID->text type mismatch for Better-Auth compatibility.
|
||||||
|
|
||||||
|
Better-Auth generates nanoid-style text IDs (e.g. pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI),
|
||||||
|
but the users table was using PostgreSQL uuid type. When Better-Auth tries to INSERT
|
||||||
|
a new user, Postgres throws:
|
||||||
|
ERROR: invalid input syntax for type uuid: "pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI"
|
||||||
|
|
||||||
|
The sessions, accounts, and verifications tables already use text IDs — only users,
|
||||||
|
user_store_accounts.user_id, and purchases.user_id needed fixing.
|
||||||
|
|
||||||
|
Revision ID: 004_fix_user_id_text
|
||||||
|
Revises: 003_make_users_hashed_password_nullable
|
||||||
|
Create Date: 2026-03-31
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "004_fix_user_id_text"
|
||||||
|
down_revision = "003_make_users_hashed_password_nullable"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Step 1: Drop existing FK constraints
|
||||||
|
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",
|
||||||
|
type_=sa.Text(),
|
||||||
|
existing_type=sa.UUID(),
|
||||||
|
postgresql_using="id::text",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Alter user_store_accounts.user_id from uuid to text
|
||||||
|
op.alter_column(
|
||||||
|
"user_store_accounts",
|
||||||
|
"user_id",
|
||||||
|
type_=sa.Text(),
|
||||||
|
existing_type=sa.UUID(),
|
||||||
|
postgresql_using="user_id::text",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 4: Alter purchases.user_id from uuid to text
|
||||||
|
op.alter_column(
|
||||||
|
"purchases",
|
||||||
|
"user_id",
|
||||||
|
type_=sa.Text(),
|
||||||
|
existing_type=sa.UUID(),
|
||||||
|
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) ON DELETE CASCADE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE purchases "
|
||||||
|
"ADD CONSTRAINT purchases_user_id_fkey "
|
||||||
|
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop FK constraints
|
||||||
|
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"))
|
||||||
|
|
||||||
|
# Revert users.id from text to uuid
|
||||||
|
op.alter_column(
|
||||||
|
"users",
|
||||||
|
"id",
|
||||||
|
type_=sa.UUID(),
|
||||||
|
existing_type=sa.Text(),
|
||||||
|
postgresql_using="id::uuid",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Revert user_store_accounts.user_id from text to uuid
|
||||||
|
op.alter_column(
|
||||||
|
"user_store_accounts",
|
||||||
|
"user_id",
|
||||||
|
type_=sa.UUID(),
|
||||||
|
existing_type=sa.Text(),
|
||||||
|
postgresql_using="user_id::uuid",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Revert purchases.user_id from text to uuid
|
||||||
|
op.alter_column(
|
||||||
|
"purchases",
|
||||||
|
"user_id",
|
||||||
|
type_=sa.UUID(),
|
||||||
|
existing_type=sa.Text(),
|
||||||
|
postgresql_using="user_id::uuid",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-add FK constraints (PostgreSQL will auto-name them)
|
||||||
|
op.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE user_store_accounts "
|
||||||
|
"ADD CONSTRAINT user_store_accounts_user_id_fkey "
|
||||||
|
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE purchases "
|
||||||
|
"ADD CONSTRAINT purchases_user_id_fkey "
|
||||||
|
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -5,6 +5,7 @@ Sessions are verified by querying the shared sessions table directly.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from hashlib import sha256
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import Cookie, Depends, Header, HTTPException, Request, status
|
from fastapi import Cookie, Depends, Header, HTTPException, Request, status
|
||||||
@@ -27,10 +28,13 @@ async def _validate_session_token(token: str, db: AsyncSession) -> UUID:
|
|||||||
"""Validate a Better-Auth session token against the sessions table.
|
"""Validate a Better-Auth session token against the sessions table.
|
||||||
|
|
||||||
Returns the user_id (as UUID) if the session is valid and not expired.
|
Returns the user_id (as UUID) if the session is valid and not expired.
|
||||||
|
Better-Auth v1.5.6+ stores tokens as SHA-256 hashes, so we hash the
|
||||||
|
incoming raw token before querying.
|
||||||
"""
|
"""
|
||||||
|
hashed_token = sha256(token.encode("utf-8")).hexdigest()
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
|
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
|
||||||
{"token": token},
|
{"token": hashed_token},
|
||||||
)
|
)
|
||||||
row = result.first()
|
row = result.first()
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import APIRouter, FastAPI
|
||||||
|
|
||||||
from cartsnitch_api.auth.routes import router as auth_router
|
from cartsnitch_api.auth.routes import router as auth_router
|
||||||
from cartsnitch_api.middleware.cors import add_cors_middleware
|
from cartsnitch_api.middleware.cors import add_cors_middleware
|
||||||
@@ -46,15 +46,19 @@ def create_app() -> FastAPI:
|
|||||||
# Routers
|
# Routers
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(stores_router)
|
|
||||||
app.include_router(purchases_router)
|
# Data endpoints mounted under /api/v1
|
||||||
app.include_router(products_router)
|
v1_router = APIRouter(prefix="/api/v1")
|
||||||
app.include_router(prices_router)
|
v1_router.include_router(stores_router)
|
||||||
app.include_router(coupons_router)
|
v1_router.include_router(purchases_router)
|
||||||
app.include_router(shopping_router)
|
v1_router.include_router(products_router)
|
||||||
app.include_router(alerts_router)
|
v1_router.include_router(prices_router)
|
||||||
app.include_router(scraping_router)
|
v1_router.include_router(coupons_router)
|
||||||
app.include_router(public_router)
|
v1_router.include_router(shopping_router)
|
||||||
|
v1_router.include_router(alerts_router)
|
||||||
|
v1_router.include_router(scraping_router)
|
||||||
|
v1_router.include_router(public_router)
|
||||||
|
app.include_router(v1_router)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
|
|
||||||
__tablename__ = "purchases"
|
__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_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
||||||
store_location_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("store_locations.id"))
|
store_location_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("store_locations.id"))
|
||||||
receipt_id: Mapped[str] = mapped_column(String(200), nullable=False)
|
receipt_id: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
|
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from cartsnitch_api.constants import AccountStatus
|
from cartsnitch_api.constants import AccountStatus
|
||||||
@@ -16,11 +16,12 @@ if TYPE_CHECKING:
|
|||||||
from cartsnitch_api.models.store import Store
|
from cartsnitch_api.models.store import Store
|
||||||
|
|
||||||
|
|
||||||
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
class User(TimestampMixin, Base):
|
||||||
"""Application user."""
|
"""Application user."""
|
||||||
|
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||||
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
display_name: Mapped[str | None] = mapped_column(String(100))
|
display_name: Mapped[str | None] = mapped_column(String(100))
|
||||||
@@ -36,7 +37,7 @@ class UserStoreAccount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
__tablename__ = "user_store_accounts"
|
__tablename__ = "user_store_accounts"
|
||||||
__table_args__ = (UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),)
|
__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)
|
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
||||||
session_data: Mapped[dict | None] = mapped_column(EncryptedJSON)
|
session_data: Mapped[dict | None] = mapped_column(EncryptedJSON)
|
||||||
session_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
session_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class UpdateUserRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserResponse(BaseModel):
|
||||||
id: UUID
|
id: str
|
||||||
email: str
|
email: str
|
||||||
display_name: str
|
display_name: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { test as base, expect } from "@playwright/test";
|
||||||
|
import AxeBuilder from "@axe-core/playwright";
|
||||||
|
|
||||||
|
export const test = base.extend<{ axeCheck: void }>({
|
||||||
|
axeCheck: [async ({ page }, use) => {
|
||||||
|
await use();
|
||||||
|
const results = await new AxeBuilder({ page }).analyze();
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
}, { auto: true }],
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect } from "@playwright/test";
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const uniqueEmail = () => `betty+e2e-${Date.now()}@cartsnitch.test`;
|
||||||
|
|
||||||
|
test.describe('J1: Registration and Login', () => {
|
||||||
|
test('can register a new account and lands on dashboard', async ({ page }) => {
|
||||||
|
await page.goto('/register');
|
||||||
|
await page.fill('[placeholder="Full Name"]', 'Betty Tester');
|
||||||
|
await page.fill('[placeholder="Email"]', uniqueEmail());
|
||||||
|
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
// With VITE_MOCK_AUTH=true the app navigates to "/" on success
|
||||||
|
await expect(page).toHaveURL('http://localhost:5173/');
|
||||||
|
await expect(page.getByRole('heading', { name: /cart/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows validation error when registration fields are empty', async ({ page }) => {
|
||||||
|
await page.goto('/register');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
await expect(page.locator('.bg-red-50')).toContainText('Please fill in all fields');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can navigate from register to login', async ({ page }) => {
|
||||||
|
await page.goto('/register');
|
||||||
|
await page.getByRole('link', { name: /sign in/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can sign in with credentials and land on dashboard', async ({ page }) => {
|
||||||
|
// Register first so we have a real account
|
||||||
|
const email = uniqueEmail();
|
||||||
|
await page.goto('/register');
|
||||||
|
await page.fill('[placeholder="Full Name"]', 'Login Betty');
|
||||||
|
await page.fill('[placeholder="Email"]', email);
|
||||||
|
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await expect(page).toHaveURL('http://localhost:5173/');
|
||||||
|
|
||||||
|
// Sign out by clearing the mock session (reload with no session)
|
||||||
|
await page.goto('/');
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Now sign in
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.fill('[placeholder="Email"]', email);
|
||||||
|
await page.fill('[placeholder="Password"]', 'TestPass123!');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL('http://localhost:5173/');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('J8: Unauthenticated Access', () => {
|
||||||
|
test('redirects /dashboard (/) to /login when not authenticated', async ({ page }) => {
|
||||||
|
// No session cookie — start fresh
|
||||||
|
await page.context().clearCookies();
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redirects /purchases to /login when not authenticated', async ({ page }) => {
|
||||||
|
await page.context().clearCookies();
|
||||||
|
await page.goto('/purchases');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redirects /products to /login when not authenticated', async ({ page }) => {
|
||||||
|
await page.context().clearCookies();
|
||||||
|
await page.goto('/products');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redirects /coupons to /login when not authenticated', async ({ page }) => {
|
||||||
|
await page.context().clearCookies();
|
||||||
|
await page.goto('/coupons');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows loading spinner while auth session is pending', async ({ page }) => {
|
||||||
|
// Intercept but don't respond — session stays pending
|
||||||
|
await page.context().clearCookies();
|
||||||
|
await page.request.fetch('/api/auth/session', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Just navigate to a protected route — ProtectedRoute will show spinner while session is pending
|
||||||
|
await page.goto('/purchases');
|
||||||
|
// Spinner is visible briefly; once resolved, should redirect to login
|
||||||
|
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
+4
-2
@@ -1,6 +1,8 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from './fixtures';
|
||||||
|
|
||||||
test('app loads', async ({ page }) => {
|
test('app loads', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page).toHaveTitle(/CartSnitch/);
|
// Unauthenticated users are redirected to /login
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.getByRole('heading', { name: /CartSnitch/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"ci": {
|
||||||
|
"collect": {
|
||||||
|
"staticDistDir": "./dist",
|
||||||
|
"url": ["http://localhost:4173/"],
|
||||||
|
"numberOfRuns": 1,
|
||||||
|
"settings": {
|
||||||
|
"chromeFlags": ["--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
|
||||||
|
"skipAudits": ["bf-cache"],
|
||||||
|
"disableFullPageScreenshot": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assert": {
|
||||||
|
"assertions": {
|
||||||
|
"categories:performance": ["warn", { "minScore": 0.7 }],
|
||||||
|
"categories:accessibility": ["error", { "minScore": 0.9 }],
|
||||||
|
"categories:best-practices": ["warn", { "minScore": 0.8 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"target": "temporary-public-storage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+39
-90
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"better-auth": "^1.2.0",
|
"better-auth": "^1.2.0",
|
||||||
|
"picomatch": "4.0.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^7.0.0",
|
"react-router-dom": "^7.0.0",
|
||||||
@@ -17,21 +18,23 @@
|
|||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "^4.10.0",
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.58.2",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react": "^18.3.28",
|
"@types/react": "^18.3.28",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"@vitejs/plugin-react": "^4.5.2",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"msw": "^2.12.14",
|
"msw": "^2.12.14",
|
||||||
|
"playwright": "^1.58.2",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.56.1",
|
"typescript-eslint": "^8.56.1",
|
||||||
@@ -68,6 +71,19 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/@axe-core/playwright": {
|
||||||
|
"version": "4.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz",
|
||||||
|
"integrity": "sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"axe-core": "~4.11.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"playwright-core": ">= 1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -4492,6 +4508,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axe-core": {
|
||||||
|
"version": "4.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz",
|
||||||
|
"integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/babel-plugin-polyfill-corejs2": {
|
"node_modules/babel-plugin-polyfill-corejs2": {
|
||||||
"version": "0.4.16",
|
"version": "0.4.16",
|
||||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.16.tgz",
|
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.16.tgz",
|
||||||
@@ -6052,9 +6078,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.4.1",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||||
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
|
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -8177,10 +8203,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -8338,16 +8363,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/randombytes": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "^5.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
@@ -8754,27 +8769,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/safe-buffer": {
|
|
||||||
"version": "5.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
|
||||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/safe-push-apply": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
@@ -8850,13 +8844,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/serialize-javascript": {
|
"node_modules/serialize-javascript": {
|
||||||
"version": "6.0.2",
|
"version": "7.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz",
|
||||||
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
|
"integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"engines": {
|
||||||
"randombytes": "^2.1.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/set-cookie-parser": {
|
"node_modules/set-cookie-parser": {
|
||||||
@@ -10446,31 +10440,6 @@
|
|||||||
"rollup": "^1.20.0 || ^2.0.0"
|
"rollup": "^1.20.0 || ^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/workbox-build/node_modules/@rollup/pluginutils": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/estree": "0.0.39",
|
|
||||||
"estree-walker": "^1.0.1",
|
|
||||||
"picomatch": "^2.2.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 8.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"rollup": "^1.20.0||^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/workbox-build/node_modules/@types/estree": {
|
|
||||||
"version": "0.0.39",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz",
|
|
||||||
"integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/workbox-build/node_modules/ajv": {
|
"node_modules/workbox-build/node_modules/ajv": {
|
||||||
"version": "8.18.0",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||||
@@ -10488,13 +10457,6 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/workbox-build/node_modules/estree-walker": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/workbox-build/node_modules/json-schema-traverse": {
|
"node_modules/workbox-build/node_modules/json-schema-traverse": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
@@ -10512,19 +10474,6 @@
|
|||||||
"sourcemap-codec": "^1.4.8"
|
"sourcemap-codec": "^1.4.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/workbox-build/node_modules/picomatch": {
|
|
||||||
"version": "2.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/workbox-build/node_modules/pretty-bytes": {
|
"node_modules/workbox-build/node_modules/pretty-bytes": {
|
||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
||||||
|
|||||||
+10
-2
@@ -15,6 +15,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"better-auth": "^1.2.0",
|
"better-auth": "^1.2.0",
|
||||||
|
"picomatch": "4.0.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^7.0.0",
|
"react-router-dom": "^7.0.0",
|
||||||
@@ -22,26 +23,33 @@
|
|||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "^4.10.0",
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react": "^18.3.28",
|
"@types/react": "^18.3.28",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"@vitejs/plugin-react": "^4.5.2",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"@playwright/test": "^1.49.0",
|
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"msw": "^2.12.14",
|
"msw": "^2.12.14",
|
||||||
|
"playwright": "^1.58.2",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.56.1",
|
"typescript-eslint": "^8.56.1",
|
||||||
"vite": "^6.3.5",
|
"vite": "^6.3.5",
|
||||||
"vite-plugin-pwa": "^0.21.2",
|
"vite-plugin-pwa": "^0.21.2",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@rollup/pluginutils": "5.3.0",
|
||||||
|
"flatted": "^3.4.2",
|
||||||
|
"serialize-javascript": "7.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npm run dev',
|
command: 'VITE_MOCK_AUTH=true npm run dev',
|
||||||
url: 'http://localhost:5173',
|
url: 'http://localhost:5173',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://cartsnitch.com/sitemap.xml
|
||||||
+2
-8
@@ -9,15 +9,9 @@ vi.mock('./lib/auth-client.ts', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
it('renders the dashboard on the root route', () => {
|
it('redirects unauthenticated users to login', () => {
|
||||||
render(<App />)
|
render(<App />)
|
||||||
expect(screen.getByText('CartSnitch')).toBeInTheDocument()
|
expect(screen.getByText('CartSnitch')).toBeInTheDocument()
|
||||||
})
|
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
|
||||||
|
|
||||||
it('renders the bottom navigation', () => {
|
|
||||||
render(<App />)
|
|
||||||
expect(screen.getByText('Home')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Purchases')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Products')).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+1
-1
@@ -31,8 +31,8 @@ export default function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route index element={<Dashboard />} />
|
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="purchases" element={<Purchases />} />
|
<Route path="purchases" element={<Purchases />} />
|
||||||
<Route path="purchases/:id" element={<PurchaseDetail />} />
|
<Route path="purchases/:id" element={<PurchaseDetail />} />
|
||||||
<Route path="products" element={<Products />} />
|
<Route path="products" element={<Products />} />
|
||||||
|
|||||||
@@ -4,12 +4,22 @@ import { authClient } from '../lib/auth-client.ts'
|
|||||||
import { useAuthStore } from '../stores/auth.ts'
|
import { useAuthStore } from '../stores/auth.ts'
|
||||||
|
|
||||||
export function ProtectedRoute() {
|
export function ProtectedRoute() {
|
||||||
|
const isMockAuth = import.meta.env.VITE_MOCK_AUTH === 'true'
|
||||||
const { data: session, isPending } = authClient.useSession()
|
const { data: session, isPending } = authClient.useSession()
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||||
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
|
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAuthenticated(!!session)
|
if (!isMockAuth) {
|
||||||
}, [session, setAuthenticated])
|
setAuthenticated(!!session)
|
||||||
|
}
|
||||||
|
}, [session, setAuthenticated, isMockAuth])
|
||||||
|
|
||||||
|
// In mock auth mode, rely on Zustand store (set by Login/Register pages)
|
||||||
|
if (isMockAuth) {
|
||||||
|
if (!isAuthenticated) return <Navigate to="/login" replace />
|
||||||
|
return <Outlet />
|
||||||
|
}
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+2
-2
@@ -35,7 +35,7 @@ export function useProduct(id: string) {
|
|||||||
export function usePriceHistory(productId: string) {
|
export function usePriceHistory(productId: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['priceHistory', productId],
|
queryKey: ['priceHistory', productId],
|
||||||
queryFn: () => api.get<PriceHistory[]>(`/products/${productId}/price-history`),
|
queryFn: () => api.get<PriceHistory[]>(`/products/${productId}/prices`),
|
||||||
enabled: !!productId,
|
enabled: !!productId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,6 @@ export function useCoupons() {
|
|||||||
export function usePriceAlerts() {
|
export function usePriceAlerts() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['priceAlerts'],
|
queryKey: ['priceAlerts'],
|
||||||
queryFn: () => api.get<PriceAlert[]>('/price-alerts'),
|
queryFn: () => api.get<PriceAlert[]>('/alerts'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -15,7 +15,7 @@ const mockRoutes: Record<string, (path: string) => unknown> = {
|
|||||||
'/purchases': () => mockPurchases,
|
'/purchases': () => mockPurchases,
|
||||||
'/products': () => mockProducts,
|
'/products': () => mockProducts,
|
||||||
'/coupons': () => mockCoupons,
|
'/coupons': () => mockCoupons,
|
||||||
'/price-alerts': () => mockAlerts,
|
'/alerts': () => mockAlerts,
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchMockRoute<T>(path: string): T | null {
|
function matchMockRoute<T>(path: string): T | null {
|
||||||
@@ -30,7 +30,7 @@ function matchMockRoute<T>(path: string): T | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// /products/:id/price-history
|
// /products/:id/price-history
|
||||||
const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/)
|
const priceHistoryMatch = path.match(/^\/products\/(.+)\/prices$/)
|
||||||
if (priceHistoryMatch) {
|
if (priceHistoryMatch) {
|
||||||
return getMockPriceHistory(priceHistoryMatch[1]) as T
|
return getMockPriceHistory(priceHistoryMatch[1]) as T
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ function AuthenticatedDashboard({ userName }: { userName: string }) {
|
|||||||
function DashboardSkeleton() {
|
function DashboardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="animate-pulse">
|
<div className="animate-pulse">
|
||||||
|
<h1 className="sr-only">Loading CartSnitch…</h1>
|
||||||
<div className="h-8 w-40 rounded bg-gray-200" />
|
<div className="h-8 w-40 rounded bg-gray-200" />
|
||||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||||
<div className="h-24 rounded-xl bg-gray-200" />
|
<div className="h-24 rounded-xl bg-gray-200" />
|
||||||
|
|||||||
+11
-5
@@ -31,8 +31,14 @@ export function Login() {
|
|||||||
throw new Error(authError.message ?? 'Sign in failed')
|
throw new Error(authError.message ?? 'Sign in failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
setAuthenticated(true)
|
// After successful signIn, force a session fetch to confirm the cookie is set
|
||||||
navigate('/')
|
// before navigating to the protected route
|
||||||
|
const sessionResult = await authClient.getSession()
|
||||||
|
if (sessionResult.data) {
|
||||||
|
navigate('/')
|
||||||
|
} else {
|
||||||
|
setError('Sign in failed. Please try again.')
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
||||||
setAuthenticated(true)
|
setAuthenticated(true)
|
||||||
@@ -46,7 +52,7 @@ export function Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center px-4">
|
<main className="flex min-h-screen flex-col items-center justify-center px-4">
|
||||||
<h1 className="mb-2 text-3xl font-bold text-gray-900">CartSnitch</h1>
|
<h1 className="mb-2 text-3xl font-bold text-gray-900">CartSnitch</h1>
|
||||||
<p className="mb-8 text-sm text-gray-500">Track prices. Save money.</p>
|
<p className="mb-8 text-sm text-gray-500">Track prices. Save money.</p>
|
||||||
|
|
||||||
@@ -88,10 +94,10 @@ export function Login() {
|
|||||||
|
|
||||||
<p className="mt-6 text-sm text-gray-500">
|
<p className="mt-6 text-sm text-gray-500">
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<Link to="/register" className="text-brand-blue">
|
<Link to="/register" className="text-brand-blue underline">
|
||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,8 +38,15 @@ export function Register() {
|
|||||||
throw new Error(authError.message ?? 'Registration failed')
|
throw new Error(authError.message ?? 'Registration failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
setAuthenticated(true)
|
// After successful signUp, force a session fetch to confirm the cookie is set
|
||||||
navigate('/')
|
// before navigating to the protected route
|
||||||
|
const sessionResult = await authClient.getSession()
|
||||||
|
if (sessionResult.data) {
|
||||||
|
navigate('/')
|
||||||
|
} else {
|
||||||
|
// Session not established — show success message and link to login
|
||||||
|
setError('Account created! Please sign in.')
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
||||||
setAuthenticated(true)
|
setAuthenticated(true)
|
||||||
|
|||||||
@@ -61,5 +61,5 @@ export const handlers = [
|
|||||||
http.get('/api/v1/products', () => HttpResponse.json(mockProducts)),
|
http.get('/api/v1/products', () => HttpResponse.json(mockProducts)),
|
||||||
http.get('/api/v1/products/prod_1', () => HttpResponse.json(mockProducts[0])),
|
http.get('/api/v1/products/prod_1', () => HttpResponse.json(mockProducts[0])),
|
||||||
http.get('/api/v1/coupons', () => HttpResponse.json(mockCoupons)),
|
http.get('/api/v1/coupons', () => HttpResponse.json(mockCoupons)),
|
||||||
http.get('/api/v1/price-alerts', () => HttpResponse.json(mockAlerts)),
|
http.get('/api/v1/alerts', () => HttpResponse.json(mockAlerts)),
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user