feat(fase-3): Tareas + Notas — TDD complete (148 tests green)

3.0 Tests primero
  pgTAP: 005_tasks_rls.sql (5 — completion CHECK), 006_notes_trash_purge.sql (4 — purge SQL inline + pin/archive CHECK)
  Vitest RLS: rls-tasks.test.ts (14), rls-notes.test.ts (15)
  Playwright: tasks.test.ts (3), notes.test.ts (4)

3.1 Tareas
  Migration 008_tasks.sql: task_lists + tasks + task_list_collective_id() helper
  CHECK constraint: is_completed ⇔ completed_by ⇔ completed_at
  RLS by role + cross-collective isolation
  Store apps/web/src/lib/stores/tasks.ts (optimistic CRUD + reorder)
  /tasks overview (grid + sticky create input + 5s polling)
  /tasks/[id] detail with svelte-dnd-action reorder, flip animation,
    inline edit (dblclick), "Completed by …" attribution via lazy-loaded
    collective_members

3.2 Notas
  Migration 009_notes.sql: notes + note_color enum (8) + pin/archive CHECK
    + 7d trash window in RLS + fn_notes_touch trigger + pg_cron purge job
  Store apps/web/src/lib/stores/notes.ts (CRUD + pin/archive/trash/duplicate)
  /notes board (pinned section testid notes-pinned + 30s polling)
  /notes/[id] editor (title + textarea, 500ms debounced autosave,
    color picker, pin/archive/duplicate/trash actions)
  /notes/archive (restore + send to trash)
  /notes/trash (restore + permanent delete)

3.Z Verification
  just test-all → 148 verdes, 2 skipped:
    25 pgTAP (was 16, +9)
    88 Vitest integration (was 59, +29; 2 presence skipped)
    6 Vitest unit (unchanged)
    29 Playwright (was 22, +3 tasks +4 notes)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 10:28:19 +02:00
parent e7a961a66d
commit 104eeba02e
23 changed files with 2563 additions and 81 deletions

View File

@@ -0,0 +1,106 @@
/**
* N-series (UI) — Fase 3.2
*
* Notes board at /notes; editor at /notes/[id]. Create, change color, pin,
* archive, trash, restore. Guest is read-only.
*/
import { test, expect, type Page } from '@playwright/test';
import { USERS } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
async function gotoNotes(page: Page) {
await page.goto('/notes');
await expect(
page.getByRole('button', { name: /new note|nueva nota/i })
).toBeVisible({ timeout: 15_000 });
}
async function createNote(page: Page, title: string, content: string) {
await page.getByRole('button', { name: /new note|nueva nota/i }).click();
await expect(page).toHaveURL(/\/notes\/[0-9a-f-]+$/, { timeout: 10_000 });
const titleInput = page.getByPlaceholder(/title|título/i);
await titleInput.fill(title);
const contentInput = page.getByRole('textbox', { name: /content|contenido/i });
await contentInput.fill(content);
// Wait beyond the 500ms autosave debounce + buffer.
await page.waitForTimeout(900);
}
test.describe('Notes — member (Borja)', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, USERS.borja);
});
test('N-UI-01: create + autosave + back to board shows the note', async ({ page }) => {
await gotoNotes(page);
const title = `Note ${Date.now()}`;
await createNote(page, title, 'autosave content');
await page.goto('/notes');
await expect(
page.getByRole('heading', { name: new RegExp(`^${title}$`) })
).toBeVisible({ timeout: 5_000 });
});
test('N-UI-02: pin a note → it appears in the pinned section', async ({ page }) => {
await gotoNotes(page);
const title = `Pin ${Date.now()}`;
await createNote(page, title, 'pin me');
await page.getByRole('button', { name: /pin note|fijar nota/i }).click();
await page.waitForTimeout(800);
await page.goto('/notes');
const pinnedSection = page.getByTestId('notes-pinned');
await expect(pinnedSection.getByText(title)).toBeVisible({ timeout: 5_000 });
});
test('N-UI-03: trash → restore from /notes/trash', async ({ page }) => {
await gotoNotes(page);
const title = `Trash ${Date.now()}`;
await createNote(page, title, 'trash me');
await page.getByRole('button', { name: /move to trash|enviar a la papelera/i }).click();
await page.waitForTimeout(800);
await page.goto('/notes');
await expect(
page.getByRole('heading', { name: new RegExp(`^${title}$`) })
).not.toBeVisible({ timeout: 3_000 });
await page.goto('/notes/trash');
const trashedRow = page
.locator('[role="listitem"], [data-testid="note-card"]')
.filter({ hasText: title })
.first();
await expect(trashedRow).toBeVisible({ timeout: 5_000 });
await trashedRow.getByRole('button', { name: /restore|restaurar/i }).click();
await page.waitForTimeout(800);
await page.goto('/notes');
await expect(
page.getByRole('heading', { name: new RegExp(`^${title}$`) })
).toBeVisible({ timeout: 5_000 });
});
});
test.describe('Notes — guest (David, read-only)', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, USERS.david);
});
test('N-UI-04: guest can read the board, create button is hidden or no-op', async ({ page }) => {
await page.goto('/notes');
// The "new note" button must either be absent, or clicking it must not
// land us on a /notes/[id] route (RLS blocks the insert).
const newBtn = page.getByRole('button', { name: /new note|nueva nota/i });
if (await newBtn.isVisible({ timeout: 2_000 }).catch(() => false)) {
await newBtn.click();
await page.waitForTimeout(1_500);
await expect(page).not.toHaveURL(/\/notes\/[0-9a-f-]+$/);
}
});
});