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:
106
apps/web/tests/e2e/notes.test.ts
Normal file
106
apps/web/tests/e2e/notes.test.ts
Normal 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-]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user