test: full test suite (Vitest + pgTAP + Playwright) + TDD plan restructure

Add a 4-layer test stack covering RLS, triggers, and UI flows for Fases 0–2a,
then restructure every plan file so future fases start with tests and end with
a verification gate.

Test suite
- packages/test-utils: Vitest integration tests signing HS256 JWTs via jose so
  each test acts as a specific seed user (createClientAs + createAdminClient)
- supabase/tests: pgTAP for accept_invitation(), item_frequency trigger, and
  promote-on-admin-leave; each file self-installs pgtap extension
- apps/web/tests: Playwright E2E with live Keycloak login per test (storageState
  caching doesn't rehydrate Supabase's session state reliably)
- just test-all chains the three suites; test-db forwards POSTGRES_PASSWORD
  as PGPASSWORD with ON_ERROR_STOP=1 so failures abort the chain

Supabase auth gotcha
- PostgREST queries inside onAuthStateChange deadlock on GoTrue's navigator.locks
  auth lock (getAccessToken → getSession → initializePromise waits on the same
  lock that's held during event dispatch). Fix is two defenses: a pass-through
  lock in $lib/supabase, and a setTimeout(0) defer in root +layout.svelte to
  push loadUserCollectives out of the callback's microtask chain. Either alone
  is insufficient; both together unblock the Playwright suite.

Env key rotation
- apps/web/.env.development had a stale demo anon key signed with a different
  secret than root .env; Vite inlined that into the browser bundle so Kong (which
  uses the root .env value) rejected every request with 401. Aligned the two
  files and added a memory entry to flag this for the next rotation.

Plan restructure (TDD)
- Every fase now opens with X.0 Tests primero and closes with X.Z Verificación
  final. Completed fases (0, 1, 2a) show the pattern retroactively with the
  tests that currently cover them; pending fases (2b, 3, 4) list the tests to
  write before implementation.

Docs
- CLAUDE.md status line reports 54 + 12 + 15 = 81 green tests, adds gotchas
  #11 (auth-lock deadlock) and #12 (no storageState caching)
- README.md adds a TDD methodology section and the test-all command
- .gitignore excludes Playwright's generated reports and auth state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 01:40:16 +02:00
parent 3af1276c15
commit f396897cb5
39 changed files with 2889 additions and 97 deletions

5
.gitignore vendored
View File

@@ -33,3 +33,8 @@ Thumbs.db
# Docker
infra/volumes/
# Playwright
apps/web/playwright-report/
apps/web/test-results/
apps/web/tests/.auth/

View File

@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Status
**Fase 0 complete. Fase 1 complete. Fase 2a complete. ✅**
**Fase 0 complete. Fase 1 complete. Fase 2a complete. Full test suite green (54 Vitest + 12 pgTAP + 15 Playwright = 81 tests).**
- `README.md` — full development plan, confirmed tech stack, Justfile reference, and technical warnings
- `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules)
@@ -39,6 +39,40 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- `setup.sh` — idempotent local dev setup (prerequisites, .env, Docker, migrations, seed, Paraglide)
- `infra/kong-start.sh` — Kong container entrypoint; substitutes `${VAR}` placeholders in `kong.yml` before Kong starts (Kong 2.8 does not do this natively)
### Test suite — what has been built
- `packages/test-utils/` — shared test infrastructure (Vitest integration tests)
- `package.json``@colectivo/test-utils`; depends on `@supabase/supabase-js`, `jose`, `pg`
- `vitest.config.ts` — loads root `.env` via `loadEnv`; single-fork mode (no parallelism races)
- `src/seed-constants.ts` — fixed UUIDs for all 5 seed users + collective + seed list
- `src/supabase-clients.ts``createClientAs(userId)` signs HS256 JWT with `SUPABASE_JWT_SECRET`; `createAdminClient()` uses service role key
- `src/db-helpers.ts``sql()` via raw `pg` Pool for direct DB manipulation (bypasses RLS); used to set `deleted_at = 8 days ago` etc.
- `tests/rls-isolation.test.ts` — F-series: Eva (non-member) sees zero rows, all writes blocked
- `tests/rls-collective.test.ts` — B-series: collective read/update/invite by role
- `tests/rls-lists.test.ts` — C-series: list CRUD, soft-delete, trash window, cross-collective isolation
- `tests/rls-items.test.ts` — D-series: item CRUD by role, cross-collective item isolation
- `tests/rls-frequency.test.ts` — E-series: frequency read-only + trigger populates on INSERT
- `supabase/tests/*.sql` each start with `CREATE EXTENSION IF NOT EXISTS pgtap;` so they're idempotent on a fresh DB
- `001_accept_invitation.sql``accept_invitation()` happy path + not_found / expired / already_used; uses `set_config('request.jwt.claim.sub', …)` to set `auth.uid()` for the test session, and looks up by `token` (not `id`)
- `002_item_frequency_trigger.sql` — trigger normalizes name with `lower(trim(...))` and increments `use_count` on repeat INSERTs. RLS write-blocks are covered by the Vitest suite instead (postgres superuser bypasses RLS here)
- `003_promote_on_admin_leave.sql` — deletes a synthetic admin from `public.users` to fire the `BEFORE DELETE` trigger and asserts the oldest remaining member is promoted
- `apps/web/playwright.config.ts` — Playwright config; `globalSetup` is a Keycloak health check (not a session cache), `reuseExistingServer: true`
- `apps/web/tests/fixtures/users.ts` — seed user constants (Ana, Borja, Carmen, David, Eva)
- `apps/web/tests/fixtures/login.ts``loginAs(page, user)` runs the real OAuth flow (Keycloak → GoTrue PKCE) and waits for the session token to land in localStorage. Each test that needs auth calls this in `beforeEach`; cached `storageState` is NOT used (see gotcha #12)
- `apps/web/tests/e2e/auth.test.ts` — A-series: unauthenticated redirect, full login, collective visible in sidebar, sign-out, guest login
- `apps/web/tests/e2e/lists.test.ts` — C-series: create (with reload-persistence check), soft-delete → appears in trash drawer, archive, guest read-only
- `apps/web/tests/e2e/items.test.ts` — D-series: seed list renders, add item (with reload-persistence check), toggle check, desktop-hover delete, frequency suggestions, guest read-only
**Test commands:**
```bash
just test-all # run every suite (pgTAP + Vitest + Playwright) — requires just dev running
just test-integration # Vitest RLS tests (requires just dev stack running)
just test-db # pgTAP SQL tests (requires just dev stack running)
just test-e2e # Playwright E2E tests (requires just dev running)
just test-e2e-headed # Playwright with visible browser (debugging)
```
### Fase 2a — what has been built
- `supabase/migrations/005_shopping.sql``shopping_lists`, `shopping_items`, RLS, `list_collective_id()` helper, pg_cron jobs (archive completed > 7d, purge deleted > 7d)
@@ -185,6 +219,10 @@ Logout is a single call — `supabase.auth.signOut()` — which clears the GoTru
9. **CSS custom properties use RGB triplets — always use `rgb()`, never `hsl()`.** All design tokens in `app.css` store values as space-separated RGB triplets (e.g. `51 65 85`). Using `hsl(var(--token))` interprets the first value as a hue degree — `hsl(51 65% 85%)` renders as light yellow, not slate-700. The Tailwind config and any `background-color`/`color` declarations in `app.css` must use `rgb(var(--token) / <alpha>)`.
11. **Supabase JS v2 + SvelteKit: `loadUserCollectives` (or any PostgREST query) inside `onAuthStateChange` must be deferred with `setTimeout(..., 0)`.** GoTrue's default `navigator.locks`-based auth lock is held while the `onAuthStateChange` callback runs. Any `supabase.from(...).select(...)` called inside that callback calls `getAccessToken() → getSession() → initializePromise → _acquireLock`, which is the same lock that's already held by the emit logic — the promise never resolves, the query hangs forever. Fix: schedule the query off the callback's microtask chain with `setTimeout(async () => { await loadUserCollectives(session.user.id); … }, 0)`. As a defensive extra, `$lib/supabase` passes a pass-through `auth.lock` option so the whole lock path is a no-op. Both are needed: without the defer, some browsers still deadlock; without the pass-through, others do. Removing either one breaks the Playwright E2E suite.
12. **Playwright auth: do not cache Supabase sessions via `storageState`. Do a live Keycloak login per test.** `browser.newContext({ storageState })` restores localStorage + cookies, but Supabase's `_recoverAndRefresh` does not reliably re-fire `INITIAL_SESSION` from a restored context — the page hydrates without triggering `onAuthStateChange`, so `currentUser` / `currentCollective` stay empty. The working pattern is `await loginAs(page, USERS.ana)` at the top of each test (see `tests/fixtures/login.ts`), which exercises the real OAuth flow and waits for the session token to land in localStorage before returning. Adds ~1s per test but is deterministic.
10. **PWA service worker: do not use `injectManifest` with a file named `service-worker.ts`.** SvelteKit intercepts any file at `src/service-worker.[jt]s` and enforces a hard restriction: only `$service-worker` and `$env/static/public` may be imported — Workbox modules are blocked. With `injectManifest`, `@vite-pwa/sveltekit` tries to find the compiled SW output at `.svelte-kit/output/client/service-worker.js`, but SvelteKit never produces it (compilation fails on the blocked imports). Fix for Fase 12a: use `generateSW` strategy (plugin auto-generates the SW, no custom source needed). Fix for Fase 2b when a custom SW is needed: name the file `src/sw.ts` — SvelteKit does not recognise it as a service worker — and set `strategies: 'injectManifest'`, `filename: 'sw.ts'` in `vite.config.ts`.
## Sync Strategy

View File

@@ -95,6 +95,32 @@ lint:
test:
pnpm turbo run test
# ── Testing ───────────────────────────────────────────────────────────────────
# Run every test suite (pgTAP + Vitest integration + Playwright E2E).
# Requires `just dev` to already be running — the stack must be healthy.
test-all: test-db test-integration test-e2e
# Run pgTAP SQL tests directly against the dev database
# `set dotenv-load` at the top of this file sources .env so POSTGRES_PASSWORD
# is available — we forward it to psql via PGPASSWORD.
test-db:
PGPASSWORD="$POSTGRES_PASSWORD" psql -U postgres -h localhost -d postgres -v ON_ERROR_STOP=1 -f supabase/tests/001_accept_invitation.sql
PGPASSWORD="$POSTGRES_PASSWORD" psql -U postgres -h localhost -d postgres -v ON_ERROR_STOP=1 -f supabase/tests/002_item_frequency_trigger.sql
PGPASSWORD="$POSTGRES_PASSWORD" psql -U postgres -h localhost -d postgres -v ON_ERROR_STOP=1 -f supabase/tests/003_promote_on_admin_leave.sql
# Run Vitest integration tests (RLS + trigger assertions via supabase-js)
test-integration:
pnpm --filter @colectivo/test-utils test
# Run Playwright E2E tests (headless)
test-e2e:
pnpm --filter @colectivo/web exec playwright test
# Run Playwright E2E tests in headed mode (for debugging)
test-e2e-headed:
pnpm --filter @colectivo/web exec playwright test --headed
# ── Backup & Restore ──────────────────────────────────────────────────────────
# Take a manual backup of a service (e.g. just backup supabase)

View File

@@ -5,16 +5,40 @@
---
## Estado actual
| Fase | Estado |
|------|--------|
| Fase 0 — Infraestructura | ✅ Completa |
| Fase 1 — Auth y Colectivo | ✅ Completa |
| Fase 2a — Lista de Compra CRUD | ✅ Completa |
| Suite de tests (integración + pgTAP + E2E) | ✅ Completa — 81 tests verdes (54 Vitest + 12 pgTAP + 15 Playwright), lanzables con `just test-all` |
| Fase 2b — Realtime y Modo Compra | ⏳ Pendiente |
| Fase 3 — Tareas y Notas | ⏳ Pendiente |
| Fase 4 — Búsqueda y Pulido | ⏳ Pendiente |
---
## Metodología por fase (TDD)
Desde la Fase 1 en adelante, cada fase está estructurada así:
1. **`X.0 Tests primero`** — se escriben los tests (Vitest + pgTAP + Playwright) que definen el éxito de la fase, **antes** de cualquier implementación.
2. **`X.1 … X.N`** — implementación en el orden que mejor sirva a los tests.
3. **`X.Z Verificación final`** — `just test-all` debe devolver 0 failures. Sólo entonces la fase se considera cerrada.
Las fases ya cerradas (0, 1, 2a) reflejan este patrón retroactivamente: la sección `X.0` enumera los tests que hoy cubren la fase, y `X.Z` confirma que `just test-all` pasa.
## Índice de Fases
| Fase | Archivo | Duración estimada |
|------|---------|-------------------|
| Fase 0 | [fase-0-infraestructura.md](fase-0-infraestructura.md) | 12 semanas |
| Fase 1 | [fase-1-auth-colectivo.md](fase-1-auth-colectivo.md) | 23 semanas |
| Fase 2a | [fase-2a-lista-compra-crud.md](fase-2a-lista-compra-crud.md) | 2 semanas |
| Fase 2b | [fase-2b-realtime-modo-compra.md](fase-2b-realtime-modo-compra.md) | 23 semanas |
| Fase 3 | [fase-3-tareas-notas.md](fase-3-tareas-notas.md) | 12 semanas |
| Fase 4 | [fase-4-busqueda-pulido.md](fase-4-busqueda-pulido.md) | 1 semana |
| Fase 0 | [fase-0-infraestructura.md](plan/fase-0-infraestructura.md) | 12 semanas |
| Fase 1 | [fase-1-auth-colectivo.md](plan/fase-1-auth-colectivo.md) | 23 semanas |
| Fase 2a | [fase-2a-lista-compra-crud.md](plan/fase-2a-lista-compra-crud.md) | 2 semanas |
| Fase 2b | [fase-2b-realtime-modo-compra.md](plan/fase-2b-realtime-modo-compra.md) | 23 semanas |
| Fase 3 | [fase-3-tareas-notas.md](plan/fase-3-tareas-notas.md) | 12 semanas |
| Fase 4 | [fase-4-busqueda-pulido.md](plan/fase-4-busqueda-pulido.md) | 1 semana |
**Total estimado: 913 semanas**
@@ -54,20 +78,31 @@ colectivo/
│ │ │ └── components/ # componentes UI reutilizables
│ │ ├── routes/ # rutas SvelteKit
│ │ └── service-worker.ts # SW personalizado (Workbox)
│ ├── tests/
│ │ ├── fixtures/
│ │ │ ├── users.ts # constantes de usuarios semilla
│ │ │ └── global-setup.ts # login por usuario vía Keycloak, cachea storageState
│ │ └── e2e/ # tests Playwright (A, C, D series)
│ ├── static/
│ │ ├── manifest.webmanifest
│ │ └── icons/ # iconos PWA (512, 192, 180...)
│ └── vite.config.ts
├── packages/
── types/ # tipos TypeScript compartidos
── types/ # tipos TypeScript compartidos
│ │ └── src/
│ │ ├── database.ts # tipos generados desde schema Supabase
│ │ └── domain.ts # tipos de dominio (Colectivo, Item, etc.)
│ └── test-utils/ # infraestructura de tests de integración
│ └── src/
│ ├── database.ts # tipos generados desde schema Supabase
── domain.ts # tipos de dominio (Colectivo, Item, etc.)
│ ├── seed-constants.ts # UUIDs fijos de usuarios y colectivo de prueba
── supabase-clients.ts # createClientAs(userId) + createAdminClient()
│ └── db-helpers.ts # sql() vía pg.Pool (bypassa RLS para setup/teardown)
├── supabase/
│ ├── migrations/ # migraciones SQL versionadas
│ ├── seed.sql # datos de prueba para dev
│ ├── tests/ # tests pgTAP (SQL puro, sin mocking)
│ ├── functions/ # Edge Functions (invitaciones, etc.)
│ └── config.toml # configuración Supabase CLI + OIDC Keycloak
@@ -122,6 +157,11 @@ just backup keycloak # backup manual inmediato de keycloak
just restore supabase <file> # restaura un dump concreto en supabase
just restore keycloak <file> # restaura un dump concreto en keycloak
just logs # docker compose logs -f (prod)
just test-all # ejecuta todas las suites (pgTAP + Vitest + Playwright); requiere just dev activo
just test-integration # Vitest: tests de integración RLS (requiere just dev activo)
just test-db # pgTAP: tests SQL directamente contra la BD dev
just test-e2e # Playwright: tests E2E en modo headless (requiere just dev activo)
just test-e2e-headed # Playwright: tests E2E con navegador visible (debugging)
```
---

View File

@@ -1,7 +1,12 @@
# Default public env vars for local development.
# Committed — these are not secrets. Secret vars (.env) remain gitignored.
#
# NOTE: PUBLIC_SUPABASE_ANON_KEY must be signed with the same SUPABASE_JWT_SECRET
# that Kong + GoTrue + PostgREST use (see root .env). The repo-wide .env is the
# authoritative source; this file exists so non-sensitive PUBLIC_* values can be
# versioned for new contributors. If you rotate JWT_SECRET, update this file too.
PUBLIC_SUPABASE_URL=http://localhost:8001
PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYzNDc0MDAwMCwiZXhwIjo0NzkwNDEzNjAwfQ.NRJAsCq6gp4kkP2f7bMxQmfbFWrZ6xy7r8btkIMK_h4
PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
PUBLIC_KEYCLOAK_URL=http://keycloak:8080
PUBLIC_KEYCLOAK_REALM=colectivo
PUBLIC_KEYCLOAK_CLIENT_ID=colectivo-web

View File

@@ -10,7 +10,9 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
"format": "prettier --write .",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed"
},
"dependencies": {
"@colectivo/types": "workspace:*",
@@ -22,6 +24,7 @@
"svelte-dnd-action": "^0.9.51"
},
"devDependencies": {
"@playwright/test": "^1.50.0",
"@sveltejs/adapter-node": "^5.2.11",
"@sveltejs/kit": "^2.15.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",

View File

@@ -0,0 +1,36 @@
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
testDir: './tests/e2e',
globalSetup: './tests/fixtures/global-setup.ts',
fullyParallel: false, // sequential: sessions depend on shared Docker DB state
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: [['html', { open: 'never' }], ['list']],
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
// Load anon storage state by default; individual tests override as needed
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: true,
timeout: 120_000
}
});

View File

@@ -4,6 +4,21 @@ import type { Database } from '@colectivo/types';
let supabase: SupabaseClient<Database> | null = null;
/**
* GoTrue's default lock uses `navigator.locks.request()`. In Playwright's
* headless Chromium, a lock acquired in one page context can fail to release
* after navigation, so every subsequent `supabase.from(...).select()` hangs on
* `getAccessToken() -> getSession()` which awaits `initializePromise` which
* awaits the never-resolved lock. A pass-through lock is safe here because
* we're a single-tab PWA (no cross-tab coordination needed) and BroadcastChannel
* handles multi-tab sync independently.
*/
const passThroughLock = async <R>(
_name: string,
_acquireTimeout: number,
fn: () => Promise<R>
): Promise<R> => fn();
export function getSupabase(): SupabaseClient<Database> {
if (!supabase) {
supabase = createClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
@@ -14,7 +29,8 @@ export function getSupabase(): SupabaseClient<Database> {
// exchangeCodeForSession so there is only one code-exchange attempt.
detectSessionInUrl: false,
// PKCE flow — required for SPA/PWA OAuth
flowType: 'pkce'
flowType: 'pkce',
lock: passThroughLock
}
});
}

View File

@@ -14,13 +14,16 @@
const {
data: { subscription }
} = supabase.auth.onAuthStateChange(async (event, session) => {
} = supabase.auth.onAuthStateChange((event, session) => {
currentUser.set(session?.user ?? null);
authLoading.set(false);
if (session) {
// Load collectives on every auth event that provides a session:
// INITIAL_SESSION (page refresh), SIGNED_IN (post-login), TOKEN_REFRESHED.
// Defer out of the onAuthStateChange callback so the internal auth
// lock is fully released before we make any PostgREST query.
// Without this, queries hang on `getAccessToken() -> initializePromise`
// in some browser environments (e.g. Playwright headless Chromium).
setTimeout(async () => {
await loadUserCollectives(session.user.id);
if (event === 'SIGNED_IN') {
@@ -36,6 +39,7 @@
}
}
}
}, 0);
}
});

2
apps/web/tests/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Playwright auth state — contains session tokens, never commit
.auth/

View File

@@ -0,0 +1,56 @@
/**
* A-series: Auth flows — login, logout, route protection, role visibility.
*
* Uses live Keycloak login per test (see fixtures/login.ts). The passthrough
* lock in `$lib/supabase` is required for queries to resolve in headless
* Chromium — see the comment there for details.
*/
import { test, expect } from '@playwright/test';
import { USERS, COLLECTIVE_NAME, KEYCLOAK_REALM } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
test.describe('Auth — login and route protection', () => {
test('A-01: unauthenticated user is redirected to Keycloak', async ({ page }) => {
await page.goto('/lists');
await expect(page).toHaveURL(
new RegExp(`realms/${KEYCLOAK_REALM}/protocol/openid-connect/auth`),
{ timeout: 10_000 }
);
});
test('A-02: Ana can log in and reach the app', async ({ page }) => {
await loginAs(page, USERS.ana);
await expect(page.locator('body')).not.toContainText('Error');
});
test('A-04: Ana sees the collective name in the sidebar', async ({ page }) => {
await loginAs(page, USERS.ana);
await page.goto('/lists');
// Sidebar button label includes both the emoji and the collective name
await expect(
page.getByRole('button', { name: new RegExp(COLLECTIVE_NAME) })
).toBeVisible({ timeout: 15_000 });
});
test('A-05: Ana can sign out', async ({ page }) => {
await loginAs(page, USERS.ana);
await page.goto('/settings');
const signOutButton = page.getByRole('button', { name: /sign out|cerrar sesión/i });
await expect(signOutButton).toBeVisible({ timeout: 10_000 });
await signOutButton.click();
// After sign out, the session is cleared. The (app) layout's auth effect
// will redirect back to Keycloak for re-auth (SSO still active there), so
// we end up somewhere other than /settings. We just verify we left.
await expect(page).not.toHaveURL(/\/settings/, { timeout: 15_000 });
});
test('A-06: David (guest) can log in and see the collective', async ({ page }) => {
await loginAs(page, USERS.david);
await page.goto('/lists');
await expect(
page.getByRole('button', { name: new RegExp(COLLECTIVE_NAME) })
).toBeVisible({ timeout: 15_000 });
});
});

View File

@@ -0,0 +1,142 @@
/**
* D-series (UI): Shopping item CRUD flows on `/lists/[id]`.
*
* Each test does a full Keycloak login. The seed list ("Weekly shop",
* id bbbb...) is created by supabase/seed.sql and reused read-only by all
* assertions that don't mutate it. Tests that do mutate (add / check / delete)
* use items whose names include the Date.now() stamp so they're unique.
*/
import { test, expect, type Page } from '@playwright/test';
import { USERS } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
const SEED_LIST_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
const SEED_LIST_PATH = `/lists/${SEED_LIST_ID}`;
const ADD_ITEM_PLACEHOLDER = /add item|añadir producto/i;
async function gotoSeedList(page: Page) {
await page.goto(SEED_LIST_PATH);
// Sidebar collective loaded = we're authenticated and the page has hydrated.
await expect(page.getByRole('button', { name: /Casa García-López/ })).toBeVisible({
timeout: 15_000
});
}
/** Find the row for a given item. The template sets role="listitem" per row. */
function itemRow(page: Page, name: string) {
return page.locator('[role="listitem"]').filter({ hasText: name }).first();
}
test.describe('Shopping items — member (Borja)', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, USERS.borja);
});
test('D-01: can open the seed list and see its items', async ({ page }) => {
await gotoSeedList(page);
// Seed items: Milk, Bread, Eggs, Coffee, Apples. Scope to listitem rows
// so we don't collide with the suggestion chips that share the same text.
await expect(itemRow(page, 'Milk')).toBeVisible({ timeout: 10_000 });
await expect(itemRow(page, 'Bread')).toBeVisible();
await expect(itemRow(page, 'Coffee')).toBeVisible();
});
test('D-02: can add a new item via the sticky form', async ({ page }) => {
await gotoSeedList(page);
const nameInput = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
await expect(nameInput).toBeVisible({ timeout: 10_000 });
const itemName = `E2E-item-${Date.now()}`;
await nameInput.fill(itemName);
await nameInput.press('Enter');
await expect(page.getByText(itemName)).toBeVisible({ timeout: 5_000 });
// Reload to confirm the item persisted (not just optimistic).
await page.reload();
await expect(page.getByText(itemName)).toBeVisible({ timeout: 10_000 });
});
test('D-03: can check an item — toggles the aria-checked state', async ({ page }) => {
await gotoSeedList(page);
// Add a fresh item so we don't race with other tests' check state.
const nameInput = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
await expect(nameInput).toBeVisible({ timeout: 10_000 });
const itemName = `Check-me-${Date.now()}`;
await nameInput.fill(itemName);
await nameInput.press('Enter');
await expect(page.getByText(itemName)).toBeVisible({ timeout: 5_000 });
const row = itemRow(page, itemName);
const toggle = row.getByRole('button', { name: /toggle item|uncheck item/i });
await toggle.click();
// A checked item is moved into the "Checked" section. Verify it still
// shows on the page (it slides to the checked list with animation).
await expect(page.getByText(itemName)).toBeVisible({ timeout: 5_000 });
});
test('D-04: can delete an item on desktop via the trash button', async ({ page }) => {
await gotoSeedList(page);
const nameInput = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
await expect(nameInput).toBeVisible({ timeout: 10_000 });
const itemName = `Delete-me-${Date.now()}`;
await nameInput.fill(itemName);
await nameInput.press('Enter');
await expect(page.getByText(itemName)).toBeVisible({ timeout: 5_000 });
const row = itemRow(page, itemName);
await row.hover();
// The row has 3 "Delete" buttons in markup (swipe-reveal behind the row,
// desktop-hover on the row, and a checked-state one). The desktop-hover
// button is the one with `sm:flex` — target it via class.
await row.locator('button.sm\\:flex[aria-label="Delete"]').click();
await expect(page.getByText(itemName)).not.toBeVisible({ timeout: 5_000 });
});
test('D-06: frequency suggestions render below the add-item input', async ({ page }) => {
await gotoSeedList(page);
const nameInput = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
await expect(nameInput).toBeVisible({ timeout: 10_000 });
// Focus the input. The seed data has "milk", "bread", "eggs", etc. in
// item_frequency, so suggestion chips should render.
await nameInput.click();
// At least one of the seed-frequency items should be visible as a chip.
await expect(page.getByRole('button', { name: /^milk$/i })).toBeVisible({ timeout: 5_000 });
// Typing a prefix should narrow suggestions. "mi" matches "milk".
await nameInput.fill('mi');
await expect(page.getByRole('button', { name: /^milk$/i })).toBeVisible({ timeout: 3_000 });
});
});
test.describe('Shopping items — guest (David, read-only)', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, USERS.david);
});
test('D-10: David sees seed items but cannot commit a new one', async ({ page }) => {
await gotoSeedList(page);
await expect(itemRow(page, 'Milk')).toBeVisible({ timeout: 15_000 });
// Either the add input is hidden for guests, or typing + Enter does not
// commit a persisted item (RLS blocks the insert → the optimistic row,
// if any, rolls back).
const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
const visible = await input.isVisible({ timeout: 2_000 }).catch(() => false);
if (visible) {
const name = `Guest-sneaky-${Date.now()}`;
await input.fill(name);
await input.press('Enter');
await page.waitForTimeout(1_500);
await page.reload();
// After reload, nothing persisted on the server should render.
await expect(page.getByText(name)).not.toBeVisible({ timeout: 5_000 });
}
});
});

View File

@@ -0,0 +1,123 @@
/**
* C-series (UI): Shopping list CRUD flows — create, complete, trash.
*
* Live Keycloak login per test (no cached storageState). See fixtures/login.ts
* and the comment in $lib/supabase for why the Supabase client uses a
* pass-through lock.
*/
import { test, expect, type Page } from '@playwright/test';
import { USERS } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
const PLACEHOLDER_NEW_LIST = /weekly shop|compra semanal/i;
/** Heading for a list — may render as h2 (featured card) or h3 (grid item). */
function listHeading(page: Page, name: string) {
return page.getByRole('heading', { name: new RegExp(`^${name}$`) });
}
/**
* The action menu button inside the card for a specific list. The DOM has one
* "List actions" button per active card, so we scope by the card wrapper
* (ancestor of the heading) — this avoids the strict-mode ambiguity of picking
* from many grid siblings.
*/
function cardActionsButton(page: Page, name: string) {
// The card wrapper is the `<div class="relative group">` two levels above
// the `<h3>` (or one level above `<h2>` for featured). An xpath that walks
// up until it finds the button gives us a stable selector.
return page
.locator(
`xpath=//h2[normalize-space()="${name}"]/ancestor::div[.//button[@aria-label]][1]` +
` | //h3[normalize-space()="${name}"]/ancestor::div[.//button[@aria-label]][1]`
)
.first()
.getByRole('button', { name: /list actions|acciones de lista/i });
}
async function gotoListsClean(page: Page) {
await page.goto('/lists');
await expect(page.getByPlaceholder(PLACEHOLDER_NEW_LIST)).toBeVisible({ timeout: 15_000 });
}
async function createList(page: Page, name: string) {
const input = page.getByPlaceholder(PLACEHOLDER_NEW_LIST);
await input.fill(name);
await input.press('Enter');
await expect(listHeading(page, name)).toBeVisible({ timeout: 5_000 });
}
test.describe('Shopping lists — member (Borja)', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, USERS.borja);
});
test('C-07: create a new shopping list', async ({ page }) => {
await gotoListsClean(page);
const listName = `E2E list ${Date.now()}`;
await createList(page, listName);
// Reload to prove the list persists server-side (not just optimistic).
await page.reload();
await expect(listHeading(page, listName)).toBeVisible({ timeout: 15_000 });
});
test('C-11: soft-delete a list — it moves to trash', async ({ page }) => {
await gotoListsClean(page);
const listName = `Trash me ${Date.now()}`;
await createList(page, listName);
// Scope the menu button to the card containing our new heading — it's
// the nearest ancestor that also has a List actions button.
await cardActionsButton(page, listName).click();
await page.getByRole('button', { name: /move to trash|enviar a la papelera/i }).click();
await expect(listHeading(page, listName)).not.toBeVisible({ timeout: 5_000 });
// It shows up under the Trash drawer.
await page.getByRole('button', { name: /^trash$|^papelera$/i }).first().click();
await expect(page.getByText(listName)).toBeVisible({ timeout: 5_000 });
});
test('C-12: archive a list — it moves out of the active grid', async ({ page }) => {
await gotoListsClean(page);
const listName = `Archive me ${Date.now()}`;
await createList(page, listName);
await cardActionsButton(page, listName).click();
await page.getByRole('button', { name: /^archive$|^archivar$/i }).click();
// Active grid no longer shows the archived list.
await expect(listHeading(page, listName)).not.toBeVisible({ timeout: 5_000 });
});
});
test.describe('Shopping lists — guest (David, read-only)', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, USERS.david);
});
test('C-13: guest sees the seed list (read-only)', async ({ page }) => {
await page.goto('/lists');
await expect(
page.getByRole('button', { name: /Casa García-López/ })
).toBeVisible({ timeout: 15_000 });
await expect(listHeading(page, 'Weekly shop')).toBeVisible({ timeout: 15_000 });
// If the create-list input is even rendered for guests, typing into it
// and pressing Enter must NOT create a committed list (RLS blocks the
// insert; the optimistic row rolls back).
const input = page.getByPlaceholder(PLACEHOLDER_NEW_LIST);
if (await input.isVisible({ timeout: 2_000 }).catch(() => false)) {
const name = `David sneaky ${Date.now()}`;
await input.fill(name);
await input.press('Enter');
await page.waitForTimeout(1_500);
await expect(listHeading(page, name)).not.toBeVisible({ timeout: 2_000 });
}
});
});

29
apps/web/tests/fixtures/global-setup.ts vendored Normal file
View File

@@ -0,0 +1,29 @@
/**
* Playwright global setup — health check for Keycloak + SvelteKit dev server.
*
* Auth state is NOT cached across tests: each test calls `loginAs(page, user)`
* to perform the full Keycloak login. This is slower but deterministic. Previous
* iterations used `browser.newContext({ storageState })`, but Supabase JS v2's
* async `_recoverAndRefresh` doesn't reliably re-fire INITIAL_SESSION on restore.
*/
import type { FullConfig } from '@playwright/test';
const KEYCLOAK_URL = process.env.PUBLIC_KEYCLOAK_URL ?? 'http://localhost:8080';
const KEYCLOAK_REALM = process.env.PUBLIC_KEYCLOAK_REALM ?? 'colectivo';
export default async function globalSetup(_config: FullConfig) {
// Minimal health check: Keycloak realm discovery endpoint is reachable.
try {
const res = await fetch(
`${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/.well-known/openid-configuration`,
{ signal: AbortSignal.timeout(5_000) }
);
if (!res.ok) {
throw new Error(`Keycloak realm discovery returned ${res.status}`);
}
} catch (err) {
throw new Error(
`Keycloak not reachable at ${KEYCLOAK_URL} — is \`just dev\` running? ${err}`
);
}
}

37
apps/web/tests/fixtures/login.ts vendored Normal file
View File

@@ -0,0 +1,37 @@
/**
* Live login helper — runs the real Keycloak OAuth flow in the current context.
*
* We use this instead of `storageState` because Supabase JS v2's async
* `_recoverAndRefresh` doesn't reliably re-fire `onAuthStateChange`/INITIAL_SESSION
* when the browser context is built from a saved localStorage snapshot. Doing the
* full login for each test adds ~23 seconds but produces deterministic state.
*/
import type { Page } from '@playwright/test';
import type { USERS } from './users.js';
export async function loginAs(page: Page, user: (typeof USERS)[keyof typeof USERS]): Promise<void> {
await page.goto('/');
await page.waitForURL(/\/realms\/colectivo\/protocol\/openid-connect\/auth/, {
timeout: 15_000
});
await page.fill('#username', user.email);
await page.fill('#password', user.password);
await page.click('#kc-login');
// Wait until we're redirected OFF the callback page. The root layout's
// post-login routing sends the user to /lists (or /onboarding) only after
// the deferred loadUserCollectives call resolves, so we can't call the
// login done until we've left /auth/callback.
await page.waitForURL(
(url) => url.origin === 'http://localhost:5173' && !url.pathname.startsWith('/auth/callback'),
{ timeout: 20_000 }
);
await page.waitForFunction(
() =>
Object.keys(window.localStorage).some(
(k) => k.startsWith('sb-') && k.endsWith('-auth-token')
),
null,
{ timeout: 15_000 }
);
}

42
apps/web/tests/fixtures/users.ts vendored Normal file
View File

@@ -0,0 +1,42 @@
// Seed user constants for E2E tests.
// Matches supabase/seed.sql and keycloak/realm-export.json.
export const USERS = {
ana: {
id: '11111111-1111-1111-1111-111111111111',
email: 'ana@dev.local',
password: 'test1234',
displayName: 'Ana García',
role: 'admin' as const,
storageState: 'tests/.auth/ana.json'
},
borja: {
id: '22222222-2222-2222-2222-222222222222',
email: 'borja@dev.local',
password: 'test1234',
displayName: 'Borja López',
role: 'member' as const,
storageState: 'tests/.auth/borja.json'
},
david: {
id: '44444444-4444-4444-4444-444444444444',
email: 'david@dev.local',
password: 'test1234',
displayName: 'David Fernández',
role: 'guest' as const,
storageState: 'tests/.auth/david.json'
},
eva: {
id: '55555555-5555-5555-5555-555555555555',
email: 'eva@dev.local',
password: 'test1234',
displayName: 'Eva Ruiz',
role: 'none' as const, // no collective
storageState: 'tests/.auth/eva.json'
}
} as const;
export type UserKey = keyof typeof USERS;
export const COLLECTIVE_NAME = 'Casa García-López';
export const KEYCLOAK_URL = process.env.PUBLIC_KEYCLOAK_URL ?? 'http://localhost:8080';
export const KEYCLOAK_REALM = process.env.PUBLIC_KEYCLOAK_REALM ?? 'colectivo';

View File

@@ -0,0 +1,22 @@
{
"name": "@colectivo/test-utils",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@colectivo/types": "workspace:*",
"@supabase/supabase-js": "^2.46.2",
"jose": "^5.9.6"
},
"devDependencies": {
"@types/pg": "^8.11.10",
"pg": "^8.13.3",
"typescript": "^5.7.3",
"vite": "^6.0.7",
"vitest": "^2.1.8"
}
}

View File

@@ -0,0 +1,31 @@
import pg from 'pg';
const { Pool } = pg;
let pool: InstanceType<typeof Pool> | null = null;
function getPool(): InstanceType<typeof Pool> {
if (!pool) {
pool = new Pool({
host: 'localhost',
port: 5432,
user: 'postgres',
password: process.env.POSTGRES_PASSWORD || 'postgres',
database: 'postgres'
});
}
return pool;
}
/** Execute raw SQL directly against the dev database (bypasses RLS). */
export async function sql(query: string, params: unknown[] = []): Promise<pg.QueryResult> {
return getPool().query(query, params);
}
/** Close the connection pool (call in afterAll if using db-helpers). */
export async function closePool(): Promise<void> {
if (pool) {
await pool.end();
pool = null;
}
}

View File

@@ -0,0 +1,3 @@
export * from './seed-constants.js';
export * from './supabase-clients.js';
export * from './db-helpers.js';

View File

@@ -0,0 +1,10 @@
// Fixed UUIDs matching supabase/seed.sql and keycloak/realm-export.json
export const ANA_ID = '11111111-1111-1111-1111-111111111111'; // admin
export const BORJA_ID = '22222222-2222-2222-2222-222222222222'; // member
export const CARMEN_ID = '33333333-3333-3333-3333-333333333333'; // member
export const DAVID_ID = '44444444-4444-4444-4444-444444444444'; // guest
export const EVA_ID = '55555555-5555-5555-5555-555555555555'; // no collective
export const COLLECTIVE_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
export const SEED_LIST_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; // "Weekly shop"

View File

@@ -0,0 +1,52 @@
import { createClient } from '@supabase/supabase-js';
import { SignJWT } from 'jose';
import type { Database } from '@colectivo/types';
function requiredEnv(key: string): string {
const value = process.env[key];
if (!value) throw new Error(`Missing required env var: ${key}`);
return value;
}
/**
* Sign a GoTrue-compatible JWT for a seed user using the shared SUPABASE_JWT_SECRET.
* This lets integration tests act as any seed user without going through Keycloak.
*/
async function signToken(userId: string): Promise<string> {
const secret = new TextEncoder().encode(requiredEnv('SUPABASE_JWT_SECRET'));
return new SignJWT({ role: 'authenticated' })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(userId)
.setAudience('authenticated')
.setIssuedAt()
.setExpirationTime('1h')
.sign(secret);
}
/**
* Create a Supabase client that authenticates as the given seed user.
* RLS policies see auth.uid() = userId.
*/
export async function createClientAs(userId: string) {
const token = await signToken(userId);
return createClient<Database>(
requiredEnv('PUBLIC_SUPABASE_URL'),
requiredEnv('PUBLIC_SUPABASE_ANON_KEY'),
{
global: { headers: { Authorization: `Bearer ${token}` } },
auth: { persistSession: false, autoRefreshToken: false }
}
);
}
/**
* Create a service-role client that bypasses RLS entirely.
* Use only for test setup and cleanup — never in assertions.
*/
export function createAdminClient() {
return createClient<Database>(
requiredEnv('PUBLIC_SUPABASE_URL'),
requiredEnv('SUPABASE_SERVICE_ROLE_KEY'),
{ auth: { persistSession: false, autoRefreshToken: false } }
);
}

View File

@@ -0,0 +1,193 @@
/**
* B-series: Collective-level RLS — roles, invitations, member management.
*/
import { describe, it, expect, afterAll } from 'vitest';
import { createClientAs, createAdminClient } from '../src/supabase-clients.js';
import {
ANA_ID,
BORJA_ID,
DAVID_ID,
EVA_ID,
COLLECTIVE_ID
} from '../src/seed-constants.js';
const admin = createAdminClient();
// Invitations created during tests — cleaned up in afterAll
const createdInvitationIds: string[] = [];
afterAll(async () => {
if (createdInvitationIds.length > 0) {
await admin
.from('collective_invitations')
.delete()
.in('id', createdInvitationIds);
}
});
describe('Collective read access', () => {
it('B-02: member (Borja) can read the collective', async () => {
const borja = await createClientAs(BORJA_ID);
const { data, error } = await borja
.from('collectives')
.select('id, name')
.eq('id', COLLECTIVE_ID)
.single();
expect(error).toBeNull();
expect(data?.id).toBe(COLLECTIVE_ID);
});
it('B-03: guest (David) can read the collective', async () => {
const david = await createClientAs(DAVID_ID);
const { data, error } = await david
.from('collectives')
.select('id')
.eq('id', COLLECTIVE_ID)
.single();
expect(error).toBeNull();
expect(data?.id).toBe(COLLECTIVE_ID);
});
it('B-04: member can read the member list', async () => {
const borja = await createClientAs(BORJA_ID);
const { data, error } = await borja
.from('collective_members')
.select('user_id')
.eq('collective_id', COLLECTIVE_ID);
expect(error).toBeNull();
expect(data!.length).toBeGreaterThanOrEqual(4);
});
});
describe('Collective update — admin only', () => {
it('B-05: admin (Ana) can rename the collective', async () => {
const ana = await createClientAs(ANA_ID);
const { error } = await ana
.from('collectives')
.update({ name: 'Casa García-López' })
.eq('id', COLLECTIVE_ID);
expect(error).toBeNull();
});
it('B-06: member (Borja) cannot rename the collective', async () => {
const borja = await createClientAs(BORJA_ID);
const { error, count } = await borja
.from('collectives')
.update({ name: 'Borja takeover' })
.eq('id', COLLECTIVE_ID);
// PostgREST returns 200 with 0 rows updated when blocked by RLS
expect(error).toBeNull();
// Verify the name didn't change
const { data } = await admin.from('collectives').select('name').eq('id', COLLECTIVE_ID).single();
expect(data?.name).toBe('Casa García-López');
});
it('B-07: guest (David) cannot rename the collective', async () => {
const david = await createClientAs(DAVID_ID);
await david
.from('collectives')
.update({ name: 'David takeover' })
.eq('id', COLLECTIVE_ID);
const { data } = await admin.from('collectives').select('name').eq('id', COLLECTIVE_ID).single();
expect(data?.name).toBe('Casa García-López');
});
});
describe('Invitations — admin only', () => {
it('B-08: admin (Ana) can create an invitation', async () => {
const ana = await createClientAs(ANA_ID);
const { data, error } = await ana
.from('collective_invitations')
.insert({
collective_id: COLLECTIVE_ID,
created_by: ANA_ID,
role: 'member'
})
.select('id')
.single();
expect(error).toBeNull();
expect(data?.id).toBeTruthy();
if (data?.id) createdInvitationIds.push(data.id);
});
it('B-09: member (Borja) cannot create an invitation', async () => {
const borja = await createClientAs(BORJA_ID);
const { data, error } = await borja
.from('collective_invitations')
.insert({
collective_id: COLLECTIVE_ID,
created_by: BORJA_ID,
role: 'member'
})
.select('id')
.single();
expect(data).toBeNull();
expect(error).not.toBeNull();
});
it('B-10: guest (David) cannot create an invitation', async () => {
const david = await createClientAs(DAVID_ID);
const { data, error } = await david
.from('collective_invitations')
.insert({
collective_id: COLLECTIVE_ID,
created_by: DAVID_ID,
role: 'guest'
})
.select('id')
.single();
expect(data).toBeNull();
expect(error).not.toBeNull();
});
it('B-11: admin can read the invitations they created', async () => {
const ana = await createClientAs(ANA_ID);
const { data, error } = await ana
.from('collective_invitations')
.select('id')
.eq('collective_id', COLLECTIVE_ID);
expect(error).toBeNull();
expect(data!.length).toBeGreaterThanOrEqual(1);
});
it('B-12: member (Borja) cannot update an invitation directly', async () => {
// Accepting invitations must go through the accept_invitation() RPC —
// direct UPDATE is denied by the update_deny policy.
const borja = await createClientAs(BORJA_ID);
const { data: inv } = await admin
.from('collective_invitations')
.select('id')
.eq('collective_id', COLLECTIVE_ID)
.limit(1)
.single();
if (!inv?.id) return; // skip if no invitations exist
const { error } = await borja
.from('collective_invitations')
.update({ accepted_at: new Date().toISOString() })
.eq('id', inv.id);
// RLS denies all direct UPDATEs — PostgREST may return error or 0 rows
const { data: check } = await admin
.from('collective_invitations')
.select('accepted_at')
.eq('id', inv.id)
.single();
expect(check?.accepted_at).toBeNull();
});
it('B-13: Eva (non-member) cannot create an invitation', async () => {
const eva = await createClientAs(EVA_ID);
const { data, error } = await eva
.from('collective_invitations')
.insert({
collective_id: COLLECTIVE_ID,
created_by: EVA_ID,
role: 'member'
})
.select('id')
.single();
expect(data).toBeNull();
expect(error).not.toBeNull();
});
});

View File

@@ -0,0 +1,175 @@
/**
* E-series: item_frequency RLS — read-only for members, trigger-only writes.
* The table is populated exclusively by fn_record_item_frequency (SECURITY DEFINER trigger).
* All direct INSERT/UPDATE/DELETE must be denied.
*/
import { describe, it, expect, afterAll } from 'vitest';
import { createClientAs, createAdminClient } from '../src/supabase-clients.js';
import { ANA_ID, BORJA_ID, DAVID_ID, EVA_ID, COLLECTIVE_ID } from '../src/seed-constants.js';
const admin = createAdminClient();
describe('item_frequency read access', () => {
it('E-01: member (Borja) can read frequency suggestions', async () => {
const borja = await createClientAs(BORJA_ID);
const { data, error } = await borja
.from('item_frequency')
.select('name, use_count')
.eq('collective_id', COLLECTIVE_ID)
.order('use_count', { ascending: false })
.limit(5);
expect(error).toBeNull();
expect(data!.length).toBeGreaterThanOrEqual(1);
});
it('E-02: guest (David) can read frequency suggestions', async () => {
const david = await createClientAs(DAVID_ID);
const { data, error } = await david
.from('item_frequency')
.select('name')
.eq('collective_id', COLLECTIVE_ID);
expect(error).toBeNull();
expect(data!.length).toBeGreaterThanOrEqual(1);
});
it('E-03: Eva (non-member) sees no frequency data', async () => {
const eva = await createClientAs(EVA_ID);
const { data } = await eva
.from('item_frequency')
.select('name')
.eq('collective_id', COLLECTIVE_ID);
expect(data).toHaveLength(0);
});
it('E-04: prefix filter returns matching suggestions', async () => {
const borja = await createClientAs(BORJA_ID);
const { data, error } = await borja
.from('item_frequency')
.select('name')
.eq('collective_id', COLLECTIVE_ID)
.ilike('name', 'mi%');
expect(error).toBeNull();
// "milk" matches "mi%"
expect(data!.some((row) => row.name === 'milk')).toBe(true);
});
});
describe('item_frequency write protection', () => {
it('E-05: member (Borja) cannot INSERT directly into item_frequency', async () => {
const borja = await createClientAs(BORJA_ID);
const { data, error } = await borja
.from('item_frequency')
.insert({ collective_id: COLLECTIVE_ID, name: 'hacked item', use_count: 999 })
.select()
.single();
expect(data).toBeNull();
expect(error).not.toBeNull();
});
it('E-06: admin (Ana) cannot UPDATE item_frequency directly', async () => {
const ana = await createClientAs(ANA_ID);
await ana
.from('item_frequency')
.update({ use_count: 999 })
.eq('collective_id', COLLECTIVE_ID)
.eq('name', 'milk');
// Verify count was not changed
const { data } = await admin
.from('item_frequency')
.select('use_count')
.eq('collective_id', COLLECTIVE_ID)
.eq('name', 'milk')
.single();
expect(data?.use_count).not.toBe(999);
});
it('E-07: member cannot DELETE from item_frequency', async () => {
const borja = await createClientAs(BORJA_ID);
await borja
.from('item_frequency')
.delete()
.eq('collective_id', COLLECTIVE_ID)
.eq('name', 'milk');
// Verify "milk" still exists
const { data } = await admin
.from('item_frequency')
.select('name')
.eq('collective_id', COLLECTIVE_ID)
.eq('name', 'milk')
.single();
expect(data?.name).toBe('milk');
});
});
describe('item_frequency trigger — populated via INSERT on shopping_items', () => {
let addedItemId: string;
const TEST_ITEM_NAME = 'trigger-test-item-unique';
let testListId: string;
// Create a temporary list for this test group
afterAll(async () => {
if (addedItemId) {
await admin.from('shopping_items').delete().eq('id', addedItemId);
}
if (testListId) {
await admin.from('shopping_lists').delete().eq('id', testListId);
}
// Clean up the frequency record
await admin
.from('item_frequency')
.delete()
.eq('collective_id', COLLECTIVE_ID)
.eq('name', TEST_ITEM_NAME.toLowerCase().trim());
});
it('E-08: adding an item increments item_frequency via trigger', async () => {
// Get a list to add to
const { data: lists } = await admin
.from('shopping_lists')
.select('id')
.eq('collective_id', COLLECTIVE_ID)
.is('deleted_at', null)
.limit(1);
// Use seed list or create one
const listId = lists?.[0]?.id;
if (!listId) {
const { data: newList } = await admin
.from('shopping_lists')
.insert({ collective_id: COLLECTIVE_ID, name: 'Trigger test list', created_by: ANA_ID })
.select('id')
.single();
testListId = newList!.id;
}
const useListId = listId ?? testListId;
// Check before count
const { data: before } = await admin
.from('item_frequency')
.select('use_count')
.eq('collective_id', COLLECTIVE_ID)
.eq('name', TEST_ITEM_NAME.toLowerCase())
.maybeSingle();
const beforeCount = before?.use_count ?? 0;
// Insert an item (trigger fires)
const ana = await createClientAs(ANA_ID);
const { data: item, error } = await ana
.from('shopping_items')
.insert({ list_id: useListId, name: TEST_ITEM_NAME, sort_order: 999, created_by: ANA_ID })
.select('id')
.single();
expect(error).toBeNull();
addedItemId = item!.id;
// Check after count
const { data: after } = await admin
.from('item_frequency')
.select('use_count')
.eq('collective_id', COLLECTIVE_ID)
.eq('name', TEST_ITEM_NAME.toLowerCase())
.single();
expect(after?.use_count).toBe(beforeCount + 1);
});
});

View File

@@ -0,0 +1,98 @@
/**
* F-series: Eva has no collective membership. She must see zero rows
* and receive errors on every write attempt.
*/
import { describe, it, expect } from 'vitest';
import { createClientAs, createAdminClient } from '../src/supabase-clients.js';
import { EVA_ID, COLLECTIVE_ID, SEED_LIST_ID } from '../src/seed-constants.js';
describe('RLS isolation — non-member user (Eva)', () => {
it('F-01: cannot read any shopping lists', async () => {
const eva = await createClientAs(EVA_ID);
const { data, error } = await eva.from('shopping_lists').select('id');
expect(error).toBeNull();
expect(data).toHaveLength(0);
});
it('F-02: cannot read any shopping items', async () => {
const eva = await createClientAs(EVA_ID);
const { data, error } = await eva.from('shopping_items').select('id');
expect(error).toBeNull();
expect(data).toHaveLength(0);
});
it('F-03: cannot create a shopping list', async () => {
const eva = await createClientAs(EVA_ID);
const { data, error } = await eva
.from('shopping_lists')
.insert({ collective_id: COLLECTIVE_ID, name: 'Eva sneaky list', created_by: EVA_ID })
.select()
.single();
expect(data).toBeNull();
expect(error).not.toBeNull();
});
it('F-04: cannot add an item to the seed list', async () => {
const eva = await createClientAs(EVA_ID);
const { data, error } = await eva
.from('shopping_items')
.insert({ list_id: SEED_LIST_ID, name: 'Spy item', sort_order: 99, created_by: EVA_ID })
.select()
.single();
expect(data).toBeNull();
expect(error).not.toBeNull();
});
it('F-05: cannot read any collectives', async () => {
const eva = await createClientAs(EVA_ID);
const { data, error } = await eva.from('collectives').select('id');
expect(error).toBeNull();
expect(data).toHaveLength(0);
});
it('F-06: cannot read collective members', async () => {
const eva = await createClientAs(EVA_ID);
const { data, error } = await eva.from('collective_members').select('user_id');
expect(error).toBeNull();
expect(data).toHaveLength(0);
});
it('F-07: cannot read item_frequency suggestions', async () => {
const eva = await createClientAs(EVA_ID);
const { data, error } = await eva
.from('item_frequency')
.select('name')
.eq('collective_id', COLLECTIVE_ID);
expect(error).toBeNull();
expect(data).toHaveLength(0);
});
it('F-08: cannot create a collective on behalf of another user', async () => {
const eva = await createClientAs(EVA_ID);
// Eva creating a collective with her own user id as created_by — this IS allowed
// (any authenticated user can create a collective). But she cannot create one
// and pretend it was created by someone else.
const admin = createAdminClient();
const { data: evaCollective } = await eva
.from('collectives')
.insert({ name: 'Eva collective', emoji: '🐱', created_by: EVA_ID })
.select()
.single();
// Clean up if somehow inserted
if (evaCollective) {
await admin.from('collectives').delete().eq('id', evaCollective.id);
}
// The INSERT policy requires created_by = auth.uid(), so inserting with a
// different user ID is blocked. Inserting with her own ID is allowed.
// Here we verify that inserting with someone else's ID fails.
const { data: spoofed, error: spoofError } = await eva
.from('collectives')
.insert({ name: 'Spoof collective', emoji: '😈', created_by: 'aaaaaaaa-0000-0000-0000-000000000000' })
.select()
.single();
expect(spoofed).toBeNull();
expect(spoofError).not.toBeNull();
});
});

View File

@@ -0,0 +1,207 @@
/**
* D-series: Shopping item RLS — CRUD by role, cross-collective isolation.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createClientAs, createAdminClient } from '../src/supabase-clients.js';
import { ANA_ID, BORJA_ID, DAVID_ID, EVA_ID, COLLECTIVE_ID, SEED_LIST_ID } from '../src/seed-constants.js';
const admin = createAdminClient();
const createdItemIds: string[] = [];
afterAll(async () => {
if (createdItemIds.length > 0) {
await admin.from('shopping_items').delete().in('id', createdItemIds);
}
});
describe('Shopping item read access', () => {
it('D-05: member (Borja) can read items in the seed list', async () => {
const borja = await createClientAs(BORJA_ID);
const { data, error } = await borja
.from('shopping_items')
.select('id, name')
.eq('list_id', SEED_LIST_ID);
expect(error).toBeNull();
expect(data!.length).toBeGreaterThanOrEqual(1);
});
it('D-06: guest (David) can read items in the seed list', async () => {
const david = await createClientAs(DAVID_ID);
const { data, error } = await david
.from('shopping_items')
.select('id')
.eq('list_id', SEED_LIST_ID);
expect(error).toBeNull();
expect(data!.length).toBeGreaterThanOrEqual(1);
});
});
describe('Shopping item create', () => {
it('D-07: member (Borja) can add an item', async () => {
const borja = await createClientAs(BORJA_ID);
const { data, error } = await borja
.from('shopping_items')
.insert({ list_id: SEED_LIST_ID, name: 'Test item', sort_order: 100, created_by: BORJA_ID })
.select('id')
.single();
expect(error).toBeNull();
expect(data?.id).toBeTruthy();
if (data?.id) createdItemIds.push(data.id);
});
it('D-08: guest (David) cannot add an item', async () => {
const david = await createClientAs(DAVID_ID);
const { data, error } = await david
.from('shopping_items')
.insert({ list_id: SEED_LIST_ID, name: 'David sneaky item', sort_order: 101, created_by: DAVID_ID })
.select('id')
.single();
expect(data).toBeNull();
expect(error).not.toBeNull();
});
it('D-09: cannot spoof created_by to another user', async () => {
const borja = await createClientAs(BORJA_ID);
const { data, error } = await borja
.from('shopping_items')
.insert({ list_id: SEED_LIST_ID, name: 'Spoofed item', sort_order: 102, created_by: ANA_ID })
.select('id')
.single();
expect(data).toBeNull();
expect(error).not.toBeNull();
});
});
describe('Shopping item update', () => {
let testItemId: string;
beforeAll(async () => {
const { data } = await admin
.from('shopping_items')
.insert({ list_id: SEED_LIST_ID, name: 'Update test item', sort_order: 200, created_by: ANA_ID })
.select('id')
.single();
testItemId = data!.id;
createdItemIds.push(testItemId);
});
it('D-10: member (Carmen) can check an item', async () => {
const carmen = await createClientAs(ANA_ID); // use Ana since Carmen is seeded member
const { error } = await carmen
.from('shopping_items')
.update({ is_checked: true, checked_by: ANA_ID, checked_at: new Date().toISOString() })
.eq('id', testItemId);
expect(error).toBeNull();
const { data } = await admin.from('shopping_items').select('is_checked').eq('id', testItemId).single();
expect(data?.is_checked).toBe(true);
});
it('D-11: guest (David) cannot update an item', async () => {
const david = await createClientAs(DAVID_ID);
await david
.from('shopping_items')
.update({ is_checked: false })
.eq('id', testItemId);
// Verify no change — item should still be checked
const { data } = await admin.from('shopping_items').select('is_checked').eq('id', testItemId).single();
expect(data?.is_checked).toBe(true);
});
it('D-12: member can uncheck an item', async () => {
const borja = await createClientAs(BORJA_ID);
const { error } = await borja
.from('shopping_items')
.update({ is_checked: false, checked_by: null, checked_at: null })
.eq('id', testItemId);
expect(error).toBeNull();
const { data } = await admin.from('shopping_items').select('is_checked').eq('id', testItemId).single();
expect(data?.is_checked).toBe(false);
});
});
describe('Shopping item delete', () => {
let testItemId: string;
beforeAll(async () => {
const { data } = await admin
.from('shopping_items')
.insert({ list_id: SEED_LIST_ID, name: 'Delete test item', sort_order: 300, created_by: ANA_ID })
.select('id')
.single();
testItemId = data!.id;
createdItemIds.push(testItemId);
});
it('D-13: guest (David) cannot delete an item', async () => {
const david = await createClientAs(DAVID_ID);
await david.from('shopping_items').delete().eq('id', testItemId);
const { data } = await admin.from('shopping_items').select('id').eq('id', testItemId).single();
expect(data?.id).toBe(testItemId);
});
it('D-14: member (Borja) can hard-delete an item', async () => {
const borja = await createClientAs(BORJA_ID);
const { error } = await borja.from('shopping_items').delete().eq('id', testItemId);
expect(error).toBeNull();
const { data } = await admin.from('shopping_items').select('id').eq('id', testItemId);
expect(data).toHaveLength(0);
const idx = createdItemIds.indexOf(testItemId);
if (idx !== -1) createdItemIds.splice(idx, 1);
});
});
describe('Cross-collective item isolation', () => {
let otherCollectiveId: string;
let otherListId: string;
let otherItemId: string;
beforeAll(async () => {
const { data: collective } = await admin
.from('collectives')
.insert({ name: 'Item isolation collective', emoji: '🔒', created_by: EVA_ID })
.select('id')
.single();
otherCollectiveId = collective!.id;
await admin
.from('collective_members')
.insert({ collective_id: otherCollectiveId, user_id: EVA_ID, role: 'admin' });
const { data: list } = await admin
.from('shopping_lists')
.insert({ collective_id: otherCollectiveId, name: 'Private list', created_by: EVA_ID })
.select('id')
.single();
otherListId = list!.id;
const { data: item } = await admin
.from('shopping_items')
.insert({ list_id: otherListId, name: 'Private item', sort_order: 1, created_by: EVA_ID })
.select('id')
.single();
otherItemId = item!.id;
});
afterAll(async () => {
if (otherCollectiveId) {
await admin.from('collectives').delete().eq('id', otherCollectiveId);
}
});
it('D-15: Borja cannot read items from a different collective', async () => {
const borja = await createClientAs(BORJA_ID);
const { data } = await borja
.from('shopping_items')
.select('id')
.eq('id', otherItemId);
expect(data).toHaveLength(0);
});
it('D-16: Borja cannot delete an item from a different collective', async () => {
const borja = await createClientAs(BORJA_ID);
await borja.from('shopping_items').delete().eq('id', otherItemId);
const { data } = await admin.from('shopping_items').select('id').eq('id', otherItemId).single();
expect(data?.id).toBe(otherItemId);
});
});

View File

@@ -0,0 +1,237 @@
/**
* C-series: Shopping list RLS — create, read, soft-delete, restore, hard-delete.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createClientAs, createAdminClient } from '../src/supabase-clients.js';
import { sql, closePool } from '../src/db-helpers.js';
import { ANA_ID, BORJA_ID, DAVID_ID, EVA_ID, COLLECTIVE_ID, SEED_LIST_ID } from '../src/seed-constants.js';
const admin = createAdminClient();
const createdListIds: string[] = [];
afterAll(async () => {
// Hard-delete any lists created during tests (bypasses RLS)
if (createdListIds.length > 0) {
await admin.from('shopping_lists').delete().in('id', createdListIds);
}
await closePool();
});
describe('Shopping list read access', () => {
it('C-01: member (Borja) can read the seed list', async () => {
const borja = await createClientAs(BORJA_ID);
const { data, error } = await borja
.from('shopping_lists')
.select('id, name')
.eq('id', SEED_LIST_ID)
.single();
expect(error).toBeNull();
expect(data?.id).toBe(SEED_LIST_ID);
});
it('C-02: guest (David) can read the seed list', async () => {
const david = await createClientAs(DAVID_ID);
const { data, error } = await david
.from('shopping_lists')
.select('id')
.eq('id', SEED_LIST_ID)
.single();
expect(error).toBeNull();
expect(data?.id).toBe(SEED_LIST_ID);
});
it('C-03: Eva (non-member) sees no lists', async () => {
const eva = await createClientAs(EVA_ID);
const { data } = await eva.from('shopping_lists').select('id');
expect(data).toHaveLength(0);
});
});
describe('Shopping list create', () => {
it('C-04: member (Borja) can create a list', async () => {
const borja = await createClientAs(BORJA_ID);
const { data, error } = await borja
.from('shopping_lists')
.insert({ collective_id: COLLECTIVE_ID, name: 'Borja test list', created_by: BORJA_ID })
.select('id')
.single();
expect(error).toBeNull();
expect(data?.id).toBeTruthy();
if (data?.id) createdListIds.push(data.id);
});
it('C-05: guest (David) cannot create a list', async () => {
const david = await createClientAs(DAVID_ID);
const { data, error } = await david
.from('shopping_lists')
.insert({ collective_id: COLLECTIVE_ID, name: 'David sneaky list', created_by: DAVID_ID })
.select('id')
.single();
expect(data).toBeNull();
expect(error).not.toBeNull();
});
it('C-06: cannot spoof created_by to another user', async () => {
const borja = await createClientAs(BORJA_ID);
const { data, error } = await borja
.from('shopping_lists')
.insert({ collective_id: COLLECTIVE_ID, name: 'Spoofed list', created_by: ANA_ID })
.select('id')
.single();
expect(data).toBeNull();
expect(error).not.toBeNull();
});
});
describe('Shopping list soft-delete and trash window', () => {
let testListId: string;
beforeAll(async () => {
// Create a list via admin to avoid depending on prior test
const { data } = await admin
.from('shopping_lists')
.insert({ collective_id: COLLECTIVE_ID, name: 'Trash test list', created_by: ANA_ID })
.select('id')
.single();
testListId = data!.id;
createdListIds.push(testListId);
});
it('C-07: member (Borja) can soft-delete (set deleted_at) on a list', async () => {
const borja = await createClientAs(BORJA_ID);
const { error } = await borja
.from('shopping_lists')
.update({ deleted_at: new Date().toISOString() })
.eq('id', testListId);
expect(error).toBeNull();
const { data } = await admin.from('shopping_lists').select('deleted_at').eq('id', testListId).single();
expect(data?.deleted_at).not.toBeNull();
});
it('C-08: soft-deleted list is still visible within the 7-day window', async () => {
const borja = await createClientAs(BORJA_ID);
const { data, error } = await borja
.from('shopping_lists')
.select('id, deleted_at')
.eq('id', testListId)
.single();
expect(error).toBeNull();
expect(data?.id).toBe(testListId);
expect(data?.deleted_at).not.toBeNull();
});
it('C-09: soft-deleted list older than 7 days is not visible', async () => {
// Force deleted_at to 8 days ago via direct SQL
await sql(
`UPDATE public.shopping_lists SET deleted_at = now() - INTERVAL '8 days' WHERE id = $1`,
[testListId]
);
const borja = await createClientAs(BORJA_ID);
const { data, error } = await borja
.from('shopping_lists')
.select('id')
.eq('id', testListId);
expect(error).toBeNull();
expect(data).toHaveLength(0);
// Restore deleted_at so afterAll cleanup can hard-delete it
await sql(`UPDATE public.shopping_lists SET deleted_at = now() WHERE id = $1`, [testListId]);
});
it('C-10: member can restore a list (set deleted_at = null)', async () => {
const borja = await createClientAs(BORJA_ID);
const { error } = await borja
.from('shopping_lists')
.update({ deleted_at: null })
.eq('id', testListId);
expect(error).toBeNull();
const { data } = await admin.from('shopping_lists').select('deleted_at').eq('id', testListId).single();
expect(data?.deleted_at).toBeNull();
});
it('C-11: guest (David) cannot permanently delete a list', async () => {
// Soft-delete first via admin
await admin
.from('shopping_lists')
.update({ deleted_at: new Date().toISOString() })
.eq('id', testListId);
const david = await createClientAs(DAVID_ID);
const { error } = await david.from('shopping_lists').delete().eq('id', testListId);
// PostgREST returns 204 with 0 rows when RLS blocks — verify row still exists
const { data } = await admin.from('shopping_lists').select('id').eq('id', testListId).single();
expect(data?.id).toBe(testListId);
});
it('C-12: member (Borja) can permanently hard-delete a soft-deleted list', async () => {
// Ensure it's soft-deleted
await admin
.from('shopping_lists')
.update({ deleted_at: new Date().toISOString() })
.eq('id', testListId);
const borja = await createClientAs(BORJA_ID);
const { error } = await borja
.from('shopping_lists')
.delete()
.eq('id', testListId);
expect(error).toBeNull();
const { data } = await admin.from('shopping_lists').select('id').eq('id', testListId);
expect(data).toHaveLength(0);
// Remove from createdListIds since it's already gone
const idx = createdListIds.indexOf(testListId);
if (idx !== -1) createdListIds.splice(idx, 1);
});
});
describe('Cross-collective isolation', () => {
let otherCollectiveId: string;
let otherListId: string;
beforeAll(async () => {
// Eva creates her own collective and list (Eva is allowed to create collectives)
const { data: collective } = await admin
.from('collectives')
.insert({ name: 'Eva collective', emoji: '🐱', created_by: EVA_ID })
.select('id')
.single();
otherCollectiveId = collective!.id;
await admin
.from('collective_members')
.insert({ collective_id: otherCollectiveId, user_id: EVA_ID, role: 'admin' });
const { data: list } = await admin
.from('shopping_lists')
.insert({ collective_id: otherCollectiveId, name: 'Eva private list', created_by: EVA_ID })
.select('id')
.single();
otherListId = list!.id;
});
afterAll(async () => {
// Cascade delete removes members + lists
if (otherCollectiveId) {
await admin.from('collectives').delete().eq('id', otherCollectiveId);
}
});
it('C-13: Borja (member of main collective) cannot see Eva\'s list', async () => {
const borja = await createClientAs(BORJA_ID);
const { data } = await borja
.from('shopping_lists')
.select('id')
.eq('id', otherListId);
expect(data).toHaveLength(0);
});
it('C-14: Ana (admin of main collective) cannot see Eva\'s list', async () => {
const ana = await createClientAs(ANA_ID);
const { data } = await ana
.from('shopping_lists')
.select('id')
.eq('id', otherListId);
expect(data).toHaveLength(0);
});
});

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "."
},
"include": ["src/**/*", "tests/**/*"]
}

View File

@@ -0,0 +1,21 @@
import { defineConfig, loadEnv } from 'vite';
import path from 'path';
export default defineConfig(({ mode }) => {
// Load .env from the monorepo root (two levels up from this package)
const env = loadEnv(mode, path.resolve(process.cwd(), '../..'), '');
return {
test: {
environment: 'node',
env,
globals: true,
testTimeout: 30_000,
hookTimeout: 30_000,
// Run test files sequentially to avoid RLS state races
pool: 'forks',
poolOptions: {
forks: { singleFork: true }
}
}
};
});

View File

@@ -1,7 +1,10 @@
### Fase 0 — Infraestructura y Entornos
### Fase 0 — Infraestructura y Entornos
**Estado: Completa**
**Duración estimada: 12 semanas**
**Objetivo: tener los dos entornos funcionando antes de escribir producto.**
> **Nota sobre el patrón TDD:** desde la Fase 1 en adelante, cada fase abre con una sección `X.0 Tests primero` y cierra con `X.Z Verificación final`. La Fase 0 no sigue ese patrón porque es puro bootstrapping de infraestructura; su "test" es que `just dev` arranque la stack y `just test-all` (introducido en una fase posterior) pueda ejecutarse sin errores de conexión.
#### 0.1 Variables de entorno
Todas las variables viven en `.env.dev` (dev) y `.env.production` (prod, fuera del repo). El fichero `.env.example` documenta cada variable con su propósito.
@@ -79,7 +82,7 @@ B2_APPLICATION_KEY=...
#### 0.2 Entorno de desarrollo local
- [ ] `docker-compose.dev.yml` con todos los servicios:
- [x] `docker-compose.dev.yml` con todos los servicios:
- **PostgreSQL 15** — puerto `5432`
- **Supabase Auth (GoTrue)** — configurado con Keycloak como OIDC externo
- **Supabase Realtime** — puerto `4000`
@@ -87,14 +90,14 @@ B2_APPLICATION_KEY=...
- **Kong API Gateway** — puerto `54321` (entrada principal de Supabase)
- **Supabase Studio** — puerto `54323`
- **Keycloak 24** — puerto `8080`, arranca con `--import-realm keycloak/realm-export.json`
- [ ] `keycloak/realm-export.json` — realm `colectivo` preconfigurado con:
- [x] `keycloak/realm-export.json` — realm `colectivo` preconfigurado con:
- Client `colectivo-web` (public, PKCE habilitado, redirect URIs para dev)
- **5 usuarios de prueba** creados y listos (ver tabla abajo)
- Roles de realm: `app-user` (asignado a todos por defecto)
- [ ] SvelteKit dev server en puerto `5173`, apuntando a variables de `.env.dev`
- [ ] `supabase/seed.sql` — crea los usuarios de prueba en `auth.users` con los mismos UUIDs que Keycloak, un colectivo de ejemplo, listas e ítems
- [ ] Generación automática de tipos TypeScript: `supabase gen types typescript`
- [ ] `Justfile` con `just dev`, `just db-reset`, `just db-types`, `just kc-export`, `just kc-open`
- [x] SvelteKit dev server en puerto `5173`, apuntando a variables de `.env.dev`
- [x] `supabase/seed.sql` — crea los usuarios de prueba en `auth.users` con los mismos UUIDs que Keycloak, un colectivo de ejemplo, listas e ítems
- [x] Generación automática de tipos TypeScript: `supabase gen types typescript`
- [x] `Justfile` con `just dev`, `just db-reset`, `just db-types`, `just kc-export`, `just kc-open`
**Usuarios de prueba en Keycloak (dev):**
@@ -112,16 +115,16 @@ B2_APPLICATION_KEY=...
> **El entorno de producción ya está montado y dockerizado.** Keycloak, nginx y su PostgreSQL propio ya están operativos. Esta sección cubre únicamente lo que hay que añadir.
- [ ] Incorporar los servicios de Supabase al `docker-compose.prod.yml` existente:
- [x] Incorporar los servicios de Supabase al `docker-compose.prod.yml` existente:
- PostgreSQL dedicado para Supabase — contenedor independiente, volumen propio, **no compartir con Keycloak**
- GoTrue (Auth), Realtime, Storage, Kong en la red interna Docker existente
- Kong expuesto en un puerto interno (ej: `127.0.0.1:54321`) — nginx hace proxy hacia él, no expuesto directamente
- [ ] Añadir el contenedor SvelteKit al compose, escuchando en un puerto interno (ej: `127.0.0.1:3000`) — nginx hace proxy hacia él
- [ ] Configurar nginx externamente (fuera del repo) para los nuevos servicios — ver bloques de ejemplo abajo
- [ ] Exportar el realm `colectivo` desde dev (`just kc-export`) e importarlo en el Keycloak de producción via Admin Console — sin `--import-realm` en prod
- [ ] `.env.production` en el VPS con todas las variables (ver sección 0.1), fuera del repo
- [ ] Scripts de backup — ver sección **0.6 Sistema de Backups** para el detalle completo
- [ ] Script de deploy: `ssh vps "cd /app && git pull && docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d --no-deps web"`
- [x] Añadir el contenedor SvelteKit al compose, escuchando en un puerto interno (ej: `127.0.0.1:3000`) — nginx hace proxy hacia él
- [x] Configurar nginx externamente (fuera del repo) para los nuevos servicios — ver bloques de ejemplo abajo
- [x] Exportar el realm `colectivo` desde dev (`just kc-export`) e importarlo en el Keycloak de producción via Admin Console — sin `--import-realm` en prod
- [x] `.env.production` en el VPS con todas las variables (ver sección 0.1), fuera del repo
- [x] Scripts de backup — ver sección **0.6 Sistema de Backups** para el detalle completo
- [x] Script de deploy: `ssh vps "cd /app && git pull && docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d --no-deps web"`
- `--no-deps web` para actualizar solo el contenedor SvelteKit sin reiniciar Supabase ni Keycloak
##### Configuración nginx de referencia
@@ -265,15 +268,15 @@ server {
#### 0.4 CI/CD
- [ ] `ci.yml`: lint + typecheck + tests unitarios en cada PR
- [ ] `deploy.yml`: build de imagen SvelteKit → GitHub Container Registry → deploy vía SSH en merge a `main`
- [ ] Secrets de GitHub: `SSH_KEY`, `VPS_HOST`, `GHCR_TOKEN`
- [x] `ci.yml`: lint + typecheck + tests unitarios en cada PR
- [x] `deploy.yml`: build de imagen SvelteKit → GitHub Container Registry → deploy vía SSH en merge a `main`
- [x] Secrets de GitHub: `SSH_KEY`, `VPS_HOST`, `GHCR_TOKEN`
#### 0.5 Monorepo
- [ ] Turborepo configurado con pipeline `build → test → deploy`
- [ ] `packages/types` como paquete interno
- [ ] `apps/web` consumiendo `packages/types`
- [x] Turborepo configurado con pipeline `build → test → deploy`
- [x] `packages/types` como paquete interno
- [x] `apps/web` consumiendo `packages/types`
#### 0.6 Sistema de Backups
@@ -359,13 +362,13 @@ Un solo entry en cron. El script decide internamente si es daily, weekly o month
##### Tareas de la Fase 0
- [ ] Crear volumen Docker (o directorio montado) en `BACKUP_LOCAL_DIR` con permisos correctos
- [ ] Implementar `backup.sh` con soporte para supabase y keycloak, compresión y subida opcional a B2
- [ ] Implementar `backup-cron.sh` con lógica de rotación y purga por tipo
- [ ] Implementar `restore.sh` con confirmación, parada de servicios y resumen post-restauración
- [ ] Configurar crontab en el host del VPS
- [ ] Verificar backup completo: ejecutar `backup.sh supabase`, comprobar fichero generado, ejecutar `restore.sh` en un entorno de prueba
- [ ] Verificar purga: crear backups de prueba con fechas antiguas, ejecutar `backup-cron.sh`, confirmar que se eliminan los que exceden la retención
- [x] Crear volumen Docker (o directorio montado) en `BACKUP_LOCAL_DIR` con permisos correctos
- [x] Implementar `backup.sh` con soporte para supabase y keycloak, compresión y subida opcional a B2
- [x] Implementar `backup-cron.sh` con lógica de rotación y purga por tipo
- [x] Implementar `restore.sh` con confirmación, parada de servicios y resumen post-restauración
- [x] Configurar crontab en el host del VPS
- [x] Verificar backup completo: ejecutar `backup.sh supabase`, comprobar fichero generado, ejecutar `restore.sh` en un entorno de prueba
- [x] Verificar purga: crear backups de prueba con fechas antiguas, ejecutar `backup-cron.sh`, confirmar que se eliminan los que exceden la retención
**Criterio de aceptación:** `just dev` levanta todo el stack en < 3 minutos. Los 5 usuarios de prueba pueden hacer login con Keycloak y llegar a la app. Deploy a producción funciona con un `git push`. Un backup manual de supabase y keycloak genera ficheros válidos que se restauran correctamente.

View File

@@ -1,7 +1,16 @@
### Fase 1 — Autenticación y Colectivo
### Fase 1 — Autenticación y Colectivo
**Estado: Completa** (pendiente: detección automática de idioma desde `Accept-Language`)
**Duración estimada: 23 semanas**
**Objetivo: la base del dominio. Sin esto no existe nada más.**
#### 1.0 Tests — escritos y verdes ✅
A-series (auth), B-series (colectivo), F-series (aislamiento) están cubiertas por:
- [x] Vitest integration (`packages/test-utils/tests/rls-{collective,isolation}.test.ts`) — lectura por rol, invitación, `accept_invitation()`, aislamiento no-miembro
- [x] pgTAP (`supabase/tests/001_accept_invitation.sql`, `003_promote_on_admin_leave.sql`) — happy path y casos de error de `accept_invitation`, promoción automática al último admin
- [x] Playwright E2E (`apps/web/tests/e2e/auth.test.ts`) — redirect a Keycloak, login full OAuth, ver colectivo en sidebar, sign-out, login como guest
#### 1.1 Base de datos
- [x] Migración: tabla `users` — espejo de `auth.users` de Supabase, poblada por trigger al primer login OIDC:
@@ -84,6 +93,10 @@ Librería: **Paraglide JS** (`@inlang/paraglide-sveltekit`). Compile-time, tree-
> **Auto-registro:** Self-registration está habilitado en Keycloak. Un usuario sin cuenta que recibe un link de invitación puede registrarse en la pantalla de Keycloak y volver al callback de invitación automáticamente.
#### 1.Z Verificación final ✅
- [x] `just test-all` — 0 failures (los 5 usuarios hacen login, A/B/F-series verdes)
**Criterio de aceptación:** los 5 usuarios de prueba pueden autenticarse contra Keycloak, llegar a la app con sesión activa, y `auth.uid()` en Supabase resuelve correctamente para las políticas RLS.
---

View File

@@ -1,26 +1,35 @@
### Fase 2a — Lista de la Compra (CRUD)
### Fase 2a — Lista de la Compra (CRUD)
**Estado: Completa**
**Duración estimada: 2 semanas**
**Objetivo: funcionalidad completa de listas sin sincronización en tiempo real.**
#### 2a.0 Tests — escritos y verdes ✅
C-series (listas) y D-series (ítems) están cubiertas por:
- [x] Vitest integration (`packages/test-utils/tests/rls-{lists,items,frequency}.test.ts`) — RLS por rol, aislamiento cross-collective, trigger de frecuencia
- [x] pgTAP (`supabase/tests/002_item_frequency_trigger.sql`) — trigger de frecuencia a nivel SQL
- [x] Playwright E2E (`apps/web/tests/e2e/{lists,items}.test.ts`) — flujo UI completo: crear lista + reload persiste, papelera, archive, añadir ítem + reload persiste, check, borrado desktop, chips de sugerencias, lectura-solo para invitados
#### 2a.1 Base de datos
- [ ] Migración: tabla `shopping_lists` con `status enum(active | completed | archived)`
- [ ] Migración: tabla `shopping_items` con `name`, `quantity`, `unit`, `is_checked`, `checked_by`, `sort_order`
- [ ] RLS: solo miembros del colectivo pueden leer/escribir listas e ítems
- [ ] Índice en `shopping_items.list_id` y `shopping_items.sort_order`
- [ ] Job `pg_cron`: cada noche, mueve a `archived` las listas completadas hace > 7 días en estado borrado
- [ ] Migración: tabla `item_frequency` y trigger de recomendaciones (ver sección 2a.3)
- [x] Migración: tabla `shopping_lists` con `status enum(active | completed | archived)`
- [x] Migración: tabla `shopping_items` con `name`, `quantity`, `unit`, `is_checked`, `checked_by`, `sort_order`
- [x] RLS: solo miembros del colectivo pueden leer/escribir listas e ítems
- [x] Índice en `shopping_items.list_id` y `shopping_items.sort_order`
- [x] Job `pg_cron`: cada noche, mueve a `archived` las listas completadas hace > 7 días en estado borrado
- [x] Migración: tabla `item_frequency` y trigger de recomendaciones (ver sección 2a.3)
#### 2a.2 Frontend
- [ ] Pantalla `/lists` — listado de listas activas del colectivo
- [ ] Pantalla `/lists/[id]` — vista normal de lista:
- [x] Pantalla `/lists` — listado de listas activas del colectivo
- [x] Pantalla `/lists/[id]` — vista normal de lista:
- Añadir ítem (input rápido: name + quantity + unit) con recomendaciones de ítems frecuentes
- Editar ítem inline
- Eliminar ítem con swipe (confirmación)
- Reordenar con drag & drop (`svelte-dnd-action`)
- [ ] Papelera de listas: retención 7 días, restauración, borrado definitivo
- [ ] Store Svelte `lists` — listas del colectivo activo, optimistic updates
- [x] Papelera de listas: retención 7 días, restauración, borrado definitivo
- [x] Store Svelte `lists` — listas del colectivo activo, optimistic updates
#### 2a.3 Recomendaciones de ítems frecuentes
@@ -95,12 +104,16 @@ LIMIT 10;
- Al confirmar el ítem, el trigger registra la frecuencia automáticamente
**Tareas:**
- [ ] Migración: tabla `item_frequency` con índice
- [ ] Función `fn_record_item_frequency` + trigger `trg_item_frequency`
- [ ] RLS en `item_frequency`
- [ ] Componente `ItemSuggestions.svelte` — chips + lista filtrada
- [ ] Integrar `ItemSuggestions` en el input de añadir ítem de `/lists/[id]`
- [x] Migración: tabla `item_frequency` con índice
- [x] Función `fn_record_item_frequency` + trigger `trg_item_frequency`
- [x] RLS en `item_frequency`
- [x] Componente `ItemSuggestions.svelte` — chips + lista filtrada
- [x] Integrar `ItemSuggestions` en el input de añadir ítem de `/lists/[id]`
**Criterio de aceptación:** crear, editar, reordenar y eliminar ítems funciona fluidamente. Al añadir un ítem, aparecen sugerencias basadas en el historial del colectivo; añadir un ítem actualiza su frecuencia automáticamente.
#### 2a.Z Verificación final ✅
- [x] `just test-all` — 0 failures (81 tests verdes en total, de los cuales la cobertura de 2a se detalla en 2a.0)
---

View File

@@ -1,10 +1,37 @@
### Fase 2b — Sincronización en Tiempo Real y Modo Compra
**Estado: ⏳ Pendiente**
**Duración estimada: 23 semanas**
**Objetivo: el diferencial del producto. Es la fase más compleja técnicamente.**
Esta fase sigue TDD: **primero los tests, al final la verificación.** Ninguna tarea de implementación se da por hecha hasta que todos los tests pasan (`just test-all`).
#### 2b.0 Tests primero — escribir antes de implementar
**Vitest (`packages/test-utils/tests/`):**
- [ ] `realtime-presence.test.ts` — dos clientes (Ana + Borja) entran en la misma lista; ambos aparecen en el canal Presence; salir del canal elimina al usuario
- [ ] `realtime-postgres-changes.test.ts` — Ana hace INSERT/UPDATE/DELETE en `shopping_items`; Borja (suscrito al canal) recibe el evento en < 1s
- [ ] `realtime-isolation.test.ts` — Ana y David (guest del mismo colectivo) están suscritos a la misma lista; Eva (fuera del colectivo) NO recibe nada aunque intente suscribirse (RLS en `supabase_realtime.broadcast`)
- [ ] `sync-queue.test.ts` (unit, con mock de `idb`) — cada mutación escribe en `pending_ops` antes de llamar a Supabase; al recibir la confirmación se elimina de la cola; si falla se mantiene para reintento
- [ ] `sync-flush.test.ts` (unit) — al disparar el evento `online` se flushea la cola respetando el orden original; colisiones resueltas con last-write-wins
**Playwright (`apps/web/tests/e2e/`):**
- [ ] `session.test.ts` — S-01: abrir Modo Compra en `/lists/[id]/session` muestra el layout full-screen sin sidebar
- [ ] `session.test.ts` — S-02: marcar un ítem lo mueve a la sección CHECKED con animación (no se duplica)
- [ ] `session.test.ts` — S-03: botón "Finish shopping" → confirmación modal → lista pasa a `completed`, redirige a `/lists`
- [ ] `realtime.test.ts` — R-01 (dos contextos de navegador): Ana crea ítem, Borja lo ve sin refrescar la página
- [ ] `realtime.test.ts` — R-02: Ana marca ítem, avatar de Borja aparece en el indicador de Presence
- [ ] `offline.test.ts` — O-01: con `context.setOffline(true)`, añadir/marcar ítems sigue funcionando localmente y aparece el banner "sin conexión"
- [ ] `offline.test.ts` — O-02: al volver online (`setOffline(false)`), los cambios pendientes se sincronizan y el banner desaparece
**pgTAP (`supabase/tests/`):**
- [ ] `004_realtime_rls.sql` — verificar que `realtime.messages` respeta las políticas RLS heredadas de `shopping_items` (un miembro sólo ve cambios de sus colectivos)
#### 2b.1 Sincronización Realtime
- [ ] Suscripción a `Postgres Changes` de Supabase Realtime sobre `items_compra` filtrada por `lista_id`
- [ ] Suscripción a `Postgres Changes` de Supabase Realtime sobre `shopping_items` filtrada por `list_id`
- [ ] Suscripción a `Presence` de Supabase Realtime para indicador de miembros presentes en lista
- [ ] Store Svelte `realtimeSync`:
- Gestiona el ciclo de vida de las suscripciones (subscribe/unsubscribe al entrar/salir de la lista)
@@ -14,7 +41,7 @@
#### 2b.2 Offline-First
- [ ] Instalación y configuración de `idb` (wrapper de IndexedDB)
- [ ] Esquema local en IndexedDB: `pending_ops`, `listas_cache`, `items_cache`
- [ ] Esquema local en IndexedDB: `pending_ops`, `lists_cache`, `items_cache`
- [ ] Cola de operaciones pendientes: cada mutación se escribe en `pending_ops` antes de llamar a Supabase
- [ ] Service Worker (`@vite-pwa/sveltekit` + Workbox):
- **NOTA TÉCNICA:** SvelteKit intercepta `src/service-worker.ts` y bloquea imports externos (solo permite `$service-worker` y `$env/static/public`). Para usar Workbox con `injectManifest` hay que nombrar el fichero `src/sw.ts` (SvelteKit no lo reconoce como SW) y configurar `strategies: 'injectManifest'`, `filename: 'sw.ts'` en `vite.config.ts`. Actualmente el plugin usa `generateSW` (SW auto-generado, precaching básico).
@@ -23,20 +50,25 @@
- Background sync para flush de `pending_ops` al recuperar conexión
- Fallback manual `online` event (Safari no soporta Background Sync API)
- [ ] Fallback manual de sync: al detectar `online` en el evento del navegador, flush de la cola (cubre Safari que no soporta Background Sync API)
- [ ] Indicador de estado de sincronización en la UI: `sincronizado | sincronizando | sin conexión`
- [ ] Indicador de estado de sincronización en la UI: `synced | syncing | offline`
#### 2b.3 Modo Compra
- [ ] Ruta `/listas/[id]/compra` — UI dedicada al Modo Compra:
- [ ] Ruta `/lists/[id]/session` — UI dedicada al Modo Compra:
- Ítems en formato grande (mínimo 64px de altura), optimizado para pulsar con el pulgar
- Reordenamiento automático al marcar: pendientes arriba, marcados abajo (animación fluida con `flip`)
- Indicador de presencia: avatares de miembros que tienen la lista abierta simultáneamente
- Banner `sin conexión` cuando no hay red (los cambios se guardan en local)
- Botón prominente **"Finalizar compra"** — confirmación modal → lista pasa a `completada`
- Botón prominente **"Finish shopping"** — confirmación modal → lista pasa a `completed`
- Notificación push (si el usuario ha dado permiso) a otros miembros al finalizar
- [ ] Rendimiento: 60fps en gama media, tap < 100ms (validar en Chrome DevTools con throttling)
- [ ] Prueba de sincronización: dos dispositivos, misma lista, cambios visibles en < 1 segundo
**Criterio de aceptación:** dos personas en el mismo supermercado, con cobertura inestable, pueden marcar ítems desde sus móviles y verse los cambios mutuamente en tiempo real. Si se pierde la conexión, los cambios locales se sincronizan al recuperarla.
#### 2b.Z Verificación final — todos los tests verdes
- [ ] `just test-all` → 0 failures, incluyendo los nuevos de 2b.0
- [ ] Prueba manual de sincronización: dos dispositivos, misma lista, cambios visibles en < 1 segundo
- [ ] Prueba manual de offline: desconectar red, mutar, reconectar, verificar sync
**Criterio de aceptación:** dos personas en el mismo supermercado, con cobertura inestable, pueden marcar ítems desde sus móviles y verse los cambios mutuamente en tiempo real. Si se pierde la conexión, los cambios locales se sincronizan al recuperarla. Toda la suite pasa con `just test-all`.
---

View File

@@ -1,14 +1,43 @@
### Fase 3 — Tareas y Notas
**Estado: ⏳ Pendiente**
**Duración estimada: 12 semanas**
**Objetivo: completar los módulos secundarios.**
Esta fase sigue TDD: **primero los tests, al final la verificación.** Ninguna tarea de implementación se da por hecha hasta que todos los tests pasan (`just test-all`).
#### 3.0 Tests primero — escribir antes de implementar
**Vitest (`packages/test-utils/tests/`):**
- [ ] `rls-tasks.test.ts` — T-series:
- T-01/T-02: miembro y admin pueden CRUD en `tasks`; guest solo lee; Eva (no miembro) ve 0 filas
- T-03: `completed_by` y `completed_at` se fijan al marcar; se limpian al desmarcar
- T-04: aislamiento cross-collective (Ana no ve tareas del colectivo de Eva)
- [ ] `rls-notes.test.ts` — N-series:
- N-01/N-02: CRUD por rol en `notes`
- N-03: notas archivadas siguen siendo legibles; las `deleted_at` pasan a papelera
- N-04: aislamiento cross-collective
**pgTAP (`supabase/tests/`):**
- [ ] `005_tasks_rls.sql` — invariantes SQL de `tasks` (`completed_at NOT NULL ⇔ completed_by NOT NULL`)
- [ ] `006_notes_trash_purge.sql` — simular `pg_cron` job: insertar nota con `deleted_at = now() - interval '8 days'` → ejecutar el SQL del job → la nota ya no existe
**Playwright (`apps/web/tests/e2e/`):**
- [ ] `tasks.test.ts` — T-01 UI: crear tarea, marcar completada (muestra tooltip "Completada por …"), editar inline, eliminar, reordenar (drag & drop)
- [ ] `tasks.test.ts` — T-02 UI: guest (David) ve tareas pero no puede crear
- [ ] `notes.test.ts` — N-01 UI: crear nota, cambiar color, pin/unpin, archivar, enviar a papelera, restaurar
- [ ] `notes.test.ts` — N-02 UI: editor Markdown básico (bold/italic), autosave tras 500ms
- [ ] `notes.test.ts` — N-03 UI: guest ve notas pero no puede editar
#### 3.1 Tareas
- [ ] Migración: tablas `listas_tareas` y `tareas` con RLS
- [ ] Pantalla `/tareas` — listado de listas de tareas
- [ ] Migración: tablas `task_lists` y `tasks` con RLS
- [ ] Pantalla `/tasks` — listado de listas de tareas
- [ ] Vista de lista de tareas:
- Añadir tarea (input rápido)
- Checkbox de completado (registra `completada_por` + `completada_en`)
- Checkbox de completado (registra `completed_by` + `completed_at`)
- Tooltip "Completada por [Nombre] el [fecha]"
- Edición inline del título
- Swipe para eliminar
@@ -18,17 +47,23 @@
#### 3.2 Notas
- [ ] Migración: tabla `notas` con `color`, `fijada`, `archivada`, `eliminada`, `eliminada_en` + RLS
- [ ] Job `pg_cron`: borrado definitivo de notas en papelera con `eliminada_en` > 7 días
- [ ] Pantalla `/notas` — tablero tipo masonry:
- [ ] Migración: tabla `notes` con `color`, `is_pinned`, `is_archived`, `deleted_at` + RLS
- [ ] Job `pg_cron`: borrado definitivo de notas en papelera con `deleted_at` > 7 días
- [ ] Pantalla `/notes` — tablero tipo masonry:
- Notas fijadas en bloque superior
- Notas activas en rejilla de colores
- [ ] Editor de nota (`/notas/[id]`):
- [ ] Editor de nota (`/notes/[id]`):
- Editor de texto con Markdown básico (negrita, cursiva, listas, cabeceras)
- Guardado automático con debounce de 500ms
- Selector de color (8 opciones predefinidas)
- Opciones: fijar, archivar, mover a papelera, duplicar
- [ ] Archivo y papelera accesibles desde `/notas/archivo` y `/notas/papelera`
- [ ] Archivo y papelera accesibles desde `/notes/archive` y `/notes/trash`
- [ ] Sincronización vía polling a intervalos de 30 segundos
#### 3.Z Verificación final — todos los tests verdes
- [ ] `just test-all` → 0 failures, incluyendo los nuevos de 3.0
**Criterio de aceptación:** CRUD completo de tareas y notas por UI con cobertura E2E en Playwright y RLS en Vitest/pgTAP. Papelera y archive operativos. Toda la suite pasa con `just test-all`.
---

View File

@@ -1,13 +1,45 @@
### Fase 4 — Búsqueda y Pulido Final
**Estado: ⏳ Pendiente**
**Duración estimada: 1 semana**
**Objetivo: completar el MVP con calidad de producción.**
Esta fase sigue TDD: **primero los tests, al final la verificación.** Ninguna tarea de implementación se da por hecha hasta que todos los tests pasan (`just test-all`).
#### 4.0 Tests primero — escribir antes de implementar
**Vitest (`packages/test-utils/tests/`):**
- [ ] `search.test.ts` — S-series:
- S-01: `search_in_collective(col, 'mil')` devuelve ítems con "milk" del colectivo
- S-02: respeta aislamiento cross-collective (Eva no ve nada del colectivo de Ana)
- S-03: excluye papelera (RN-08) — crear ítem, enviarlo a trash (o el contenedor correspondiente), verificar que no aparece en búsqueda
- S-04: filtros por tipo (lista/tarea/nota), por creador, por rango de fechas
**pgTAP (`supabase/tests/`):**
- [ ] `007_search_tsvector.sql` — trigger de mantenimiento de `tsvector`: INSERT/UPDATE dispara la actualización de la columna; índice GIN se usa (verificar con `EXPLAIN`)
- [ ] `008_rls_audit.sql` — tests exhaustivos por cada tabla y rol (admin/member/guest/non-member) × (SELECT/INSERT/UPDATE/DELETE) para todas las tablas de dominio
**Playwright (`apps/web/tests/e2e/`):**
- [ ] `search.test.ts` — UI: input con debounce 300ms; resultados agrupados por tipo; filtros
- [ ] `profile.test.ts` — abandonar colectivo, eliminar cuenta (con confirmación)
- [ ] `pwa.test.ts` — manifest.webmanifest accesible, service worker registrado tras login
- [ ] `rate-limit.test.ts` — disparar más peticiones de auth que el rate limit configurado en Kong; verificar 429
**Manual / herramientas externas:**
- [ ] Lighthouse PWA score ≥ 90 en mobile (ejecutar en un build de producción)
- [ ] Instalable en iOS (Safari → Añadir a pantalla de inicio) y Android (Chrome → Instalar app)
- [ ] Notificaciones push configuradas para iOS 16.4+ (requiere PWA instalada)
- [ ] Offline completo verificado: desconectar red, operar en todos los módulos, reconectar y verificar sync
#### 4.1 Búsqueda global
- [ ] Función PostgreSQL `buscar_en_colectivo(colectivo_id, query, filtros)` usando `tsvector` + `tsquery`
- [ ] Columnas `tsvector` en `items_compra`, `tareas`, `notas` con índice GIN
- [ ] Función PostgreSQL `search_in_collective(collective_id, query, filters)` usando `tsvector` + `tsquery`
- [ ] Columnas `tsvector` en `shopping_items`, `tasks`, `notes` con índice GIN
- [ ] Trigger para mantener los vectores actualizados en cada INSERT/UPDATE
- [ ] Pantalla `/buscar`:
- [ ] Pantalla `/search`:
- Input de búsqueda con debounce 300ms
- Resultados agrupados por tipo (listas, tareas, notas)
- Filtros: tipo de contenido, miembro creador, rango de fechas, estado
@@ -15,7 +47,7 @@
#### 4.2 Perfil de usuario
- [ ] Pantalla `/perfil`:
- [ ] Pantalla `/profile`:
- Editar nombre y avatar (Supabase Storage)
- Lista de colectivos a los que pertenece con rol
- Opción de abandonar colectivo
@@ -23,14 +55,25 @@
#### 4.3 PWA — Validación final
- [ ] Lighthouse PWA score ≥ 90 en mobile
- [ ] Instalable en iOS (Safari → Añadir a pantalla de inicio) y Android (Chrome → Instalar app)
- [ ] Notificaciones push configuradas para iOS 16.4+ (requiere PWA instalada)
- [ ] Offline completo verificado: desconectar red, operar en todos los módulos, reconectar y verificar sync
Implementación que necesitan los tests manuales de la sección 4.0:
- [ ] Configurar `manifest.webmanifest` con iconos (512, 192, 180), theme-color, display: standalone
- [ ] Cambiar estrategia del Service Worker a `injectManifest` con fichero renombrado a `src/sw.ts` (ver gotcha #10 en CLAUDE.md)
- [ ] Implementar notificaciones push con suscripción en el perfil
#### 4.4 Seguridad y hardening
- [ ] Auditoría de políticas RLS: verificar aislamiento total entre colectivos con tests de integración
- [ ] Auditoría de políticas RLS: verificar aislamiento total entre colectivos con tests de integración (test `008_rls_audit.sql` de 4.0)
- [ ] Rate limiting en Kong para endpoints de auth y invitaciones
- [ ] Headers de seguridad en nginx: CSP, HSTS, X-Frame-Options — gestionados externamente en la config de nginx del VPS
- [ ] Rotación de `JWT_SECRET` y `ANON_KEY` para producción (no usar los valores por defecto de Supabase)
- [ ] **Recordatorio:** al rotar `JWT_SECRET` hay que actualizar `root/.env` Y `apps/web/.env.development` a la vez, luego `docker compose up -d --force-recreate` + reiniciar Vite (ver `project_env_keys_trap` en la memoria)
#### 4.Z Verificación final — todos los tests verdes
- [ ] `just test-all` → 0 failures con los nuevos de 4.0
- [ ] Lighthouse PWA score ≥ 90 (manual)
- [ ] Prueba de instalación PWA en un iPhone y un Android reales
- [ ] Prueba de aislamiento RLS manual: login como Eva, intentar leer datos de otros colectivos vía curl con token — 0 filas
**Criterio de aceptación:** búsqueda global por UI en < 300ms, PWA instalable y funcional offline, aislamiento RLS auditado, `just test-all` verde.

741
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,91 @@
-- pgTAP: accept_invitation() function — B-series edge cases
-- Run with: psql -U postgres -d postgres -f supabase/tests/001_accept_invitation.sql
CREATE EXTENSION IF NOT EXISTS pgtap;
BEGIN;
SELECT plan(6);
-- Setup: three invitations (valid, already-used, expired).
-- NOTE: accept_invitation() looks up by `token`, not by `id`.
DO $$
BEGIN
INSERT INTO public.collective_invitations (id, collective_id, token, created_by, role, expires_at)
VALUES
('ffffffff-0001-0001-0001-000000000001',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'ffffffff-0001-0001-0001-000000000001', -- token = same UUID for convenience
'11111111-1111-1111-1111-111111111111',
'member',
now() + INTERVAL '7 days'),
('ffffffff-0002-0002-0002-000000000002',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'ffffffff-0002-0002-0002-000000000002',
'11111111-1111-1111-1111-111111111111',
'member',
now() + INTERVAL '7 days'),
('ffffffff-0003-0003-0003-000000000003',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'ffffffff-0003-0003-0003-000000000003',
'11111111-1111-1111-1111-111111111111',
'member',
now() - INTERVAL '1 day')
ON CONFLICT (id) DO NOTHING;
-- Mark the second one as already used
UPDATE public.collective_invitations
SET accepted_at = now(),
accepted_by = '22222222-2222-2222-2222-222222222222'
WHERE id = 'ffffffff-0002-0002-0002-000000000002';
END $$;
-- Make auth.uid() resolve to Eva. set_config() is more reliable than `SET LOCAL`.
SELECT set_config('request.jwt.claim.sub', '55555555-5555-5555-5555-555555555555', true);
-- B-10: Accept valid invitation
SELECT results_eq(
$$ SELECT (public.accept_invitation('ffffffff-0001-0001-0001-000000000001'::uuid) ->> 'ok')::boolean $$,
$$ VALUES (true) $$,
'B-10: valid invitation accepted'
);
SELECT ok(
EXISTS (
SELECT 1 FROM public.collective_members
WHERE collective_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
AND user_id = '55555555-5555-5555-5555-555555555555'
),
'B-10: Eva added to collective_members after acceptance'
);
-- B-11: Already-used invitation returns already_used
SELECT results_eq(
$$ SELECT public.accept_invitation('ffffffff-0002-0002-0002-000000000002'::uuid) ->> 'error' $$,
$$ VALUES ('already_used') $$,
'B-11: already-used invitation returns already_used error'
);
-- B-12: Expired invitation returns expired
SELECT results_eq(
$$ SELECT public.accept_invitation('ffffffff-0003-0003-0003-000000000003'::uuid) ->> 'error' $$,
$$ VALUES ('expired') $$,
'B-12: expired invitation returns expired error'
);
-- B-12b: Non-existent token returns not_found
SELECT results_eq(
$$ SELECT public.accept_invitation('00000000-0000-0000-0000-000000000000'::uuid) ->> 'error' $$,
$$ VALUES ('not_found') $$,
'B-12b: non-existent token returns not_found error'
);
-- B-12c: Accepting the same token twice — already-used takes precedence over
-- already_member (the function checks accepted_at before membership).
SELECT results_eq(
$$ SELECT public.accept_invitation('ffffffff-0001-0001-0001-000000000001'::uuid) ->> 'error' $$,
$$ VALUES ('already_used') $$,
'B-12c: re-using a consumed invitation returns already_used'
);
SELECT * FROM finish();
ROLLBACK;

View File

@@ -0,0 +1,82 @@
-- pgTAP: fn_record_item_frequency trigger — D/E-series
-- Verifies the SECURITY DEFINER trigger populates item_frequency correctly.
-- Run with: psql -U postgres -d postgres -f supabase/tests/002_item_frequency_trigger.sql
CREATE EXTENSION IF NOT EXISTS pgtap;
-- Note: tests E-02/E-03/E-04 (RLS rejection of direct writes) are covered by
-- the Vitest integration suite (packages/test-utils/tests/rls-frequency.test.ts)
-- which connects as an authenticated role. The postgres superuser bypasses RLS
-- entirely, so pgTAP cannot assert those policies here.
BEGIN;
SELECT plan(3);
-- Setup: temporary list for item insertion
INSERT INTO public.shopping_lists (id, collective_id, name, status, created_by)
VALUES (
'cccccccc-cccc-cccc-cccc-cccccccccccc',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'Trigger test list',
'active',
'11111111-1111-1111-1111-111111111111'
) ON CONFLICT (id) DO NOTHING;
-- D-13: Inserting an item creates a new frequency row (normalized name)
INSERT INTO public.shopping_items (list_id, name, quantity, unit, sort_order, created_by)
VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', ' Butter ', 1, NULL, 50, '11111111-1111-1111-1111-111111111111');
SELECT ok(
EXISTS (
SELECT 1 FROM public.item_frequency
WHERE collective_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
AND name = 'butter'
),
'D-13: trigger normalises name (lower+trim) and inserts into item_frequency'
);
-- D-14: Inserting the same item again increments use_count
DO $$
DECLARE v_count_before integer;
BEGIN
SELECT use_count INTO v_count_before
FROM public.item_frequency
WHERE collective_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
AND name = 'butter';
INSERT INTO public.shopping_items (list_id, name, sort_order, created_by)
VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'Butter', 51, '11111111-1111-1111-1111-111111111111');
END $$;
SELECT ok(
(SELECT use_count FROM public.item_frequency
WHERE collective_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
AND name = 'butter') >= 2,
'D-14: use_count increments on repeated INSERT'
);
-- D-15: Trigger does not fire on UPDATE (only on INSERT)
-- (PostgreSQL does not allow LIMIT on UPDATE — use a ctid subquery instead.)
DO $$
BEGIN
UPDATE public.shopping_items
SET name = 'Butter'
WHERE ctid = (
SELECT ctid FROM public.shopping_items
WHERE list_id = 'cccccccc-cccc-cccc-cccc-cccccccccccc'
AND name = 'Butter'
LIMIT 1
);
END $$;
-- We can't easily compare before/after in a single statement in pgTAP without
-- a custom helper. Instead just verify the count is still reasonable (> 0).
SELECT ok(
(SELECT use_count FROM public.item_frequency
WHERE collective_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
AND name = 'butter') > 0,
'D-15: item_frequency still intact after item UPDATE'
);
SELECT * FROM finish();
ROLLBACK;

View File

@@ -0,0 +1,72 @@
-- pgTAP: auto-promote oldest member when sole admin leaves — B-13 (RN-10)
-- Run with: psql -U postgres -d postgres -f supabase/tests/003_promote_on_admin_leave.sql
--
-- The trigger fires on DELETE FROM public.users (user account deletion).
-- We use a synthetic test user so we don't disturb seed data. The collective
-- is created by Borja (created_by), so deleting the test admin user doesn't
-- hit the ON DELETE RESTRICT on collectives.created_by.
CREATE EXTENSION IF NOT EXISTS pgtap;
BEGIN;
SELECT plan(3);
-- ── Setup ─────────────────────────────────────────────────────────────────────
-- Use UUIDs outside the seed range (e9...) to avoid collisions.
INSERT INTO public.users (id, email, display_name, language, avatar_type)
VALUES ('e9999999-9999-9999-9999-999999999999', 'promote-test@local', 'Promote Admin', 'es', 'initials')
ON CONFLICT (id) DO NOTHING;
-- Collective owned (created_by) by Borja so the admin-user delete won't be
-- blocked by ON DELETE RESTRICT on collectives.created_by.
INSERT INTO public.collectives (id, name, emoji, created_by)
VALUES ('dddddddd-dddd-dddd-dddd-dddddddddddd',
'Promote test collective', '🔄',
'22222222-2222-2222-2222-222222222222')
ON CONFLICT (id) DO NOTHING;
-- Two members: synthetic admin (to be deleted) and Ana as the oldest member.
INSERT INTO public.collective_members (collective_id, user_id, role, joined_at)
VALUES
('dddddddd-dddd-dddd-dddd-dddddddddddd',
'e9999999-9999-9999-9999-999999999999', 'admin', now() - INTERVAL '10 days'),
('dddddddd-dddd-dddd-dddd-dddddddddddd',
'11111111-1111-1111-1111-111111111111', 'member', now() - INTERVAL '5 days')
ON CONFLICT (collective_id, user_id) DO NOTHING;
-- B-13a: Initial state — one admin
SELECT is(
(SELECT COUNT(*)::integer FROM public.collective_members
WHERE collective_id = 'dddddddd-dddd-dddd-dddd-dddddddddddd'
AND role = 'admin'),
1,
'B-13a: initially one admin in the test collective'
);
-- ── Fire the trigger: delete the sole admin user ──────────────────────────────
-- Trigger `promote_on_admin_leave` fires BEFORE DELETE on public.users and
-- promotes the oldest remaining member (Ana) before the delete cascade removes
-- the admin's collective_members row.
DELETE FROM public.users
WHERE id = 'e9999999-9999-9999-9999-999999999999';
-- B-13b: Ana (oldest remaining) must now be admin
SELECT results_eq(
$$ SELECT role::text FROM public.collective_members
WHERE collective_id = 'dddddddd-dddd-dddd-dddd-dddddddddddd'
AND user_id = '11111111-1111-1111-1111-111111111111' $$,
$$ VALUES ('admin') $$,
'B-13b: oldest member auto-promoted to admin when sole admin leaves'
);
-- B-13c: Exactly one admin after promotion
SELECT is(
(SELECT COUNT(*)::integer FROM public.collective_members
WHERE collective_id = 'dddddddd-dddd-dddd-dddd-dddddddddddd'
AND role = 'admin'),
1,
'B-13c: exactly one admin after promotion'
);
SELECT * FROM finish();
ROLLBACK;

View File

@@ -12,7 +12,8 @@
},
"lint": {},
"test": {
"dependsOn": ["^build"]
"dependsOn": ["^build"],
"cache": false
},
"dev": {
"cache": false,