Files
flowdeck/e2e/trash_ui.spec.js
T
bruno 6582df3bcb
FlowDeck CI / docker (push) Successful in 1m52s
FlowDeck CI / lint (push) Successful in 2m1s
FlowDeck CI / test (push) Successful in 15m21s
feat: poubelle flexible (sélection groupée, purge, tri/filtres, dates) + 4 bugs (v7.45.5)
Page /trash alignée sur ce que fait ce type de section :
- sélection multiple (Select all porté au filtre courant) + barre d'actions
  groupées Restore / Delete / Clear ;
- Empty Trash via POST /board/api/trash/empty (purge en masse, 1 requête) ;
- tri Recently deleted / Oldest first / Name et filtre emplacement RÉELS —
  remplacent les deux boutons décoratifs « Last edited by ▾ » et « In ▾ » qui
  ne faisaient rien ;
- date de suppression + jours restants par élément (rétention 30 j alignée sur
  app/services/trash.py, horodatages UTC/ISO gérés) ;
- compteur de résultats, états vides distincts (vide vs filtre sans résultat +
  Clear filters), toasts sur chaque action.

Bugs trouvés en route et corrigés :
1. Restore/Delete de la page ne marchaient JAMAIS : getCsrf() renvoie la chaîne
   du jeton mais le code faisait csrf?.[1] → 2e caractère → 403 CSRF silencieux
   avalé par if (r.ok).
2. « Move to Trash » de l'éditeur = 404 permanent : route appelée
   /board/api/pages/{id}/trash (inexistante) alors que la route réelle est
   /api/pages/{id}/trash, et le .then() naviguait quand même → la page partait
   à l'accueil SANS être mise à la poubelle. URL corrigée + r.ok vérifié.
3. Page restaurée invisible en Library/Recents/Private : la suppression éditeur
   réécrivait parent_section='Trash' et la restauration ne le remettait pas,
   alors que tous les listings filtrent parent_section != 'Trash'. Écriture
   retirée (deleted_at = source de vérité unique) + réparation idempotente au
   boot dans db.init_db + requête sidebar /trash alignée sur deleted_at.
4. Routes trash sans aucune authentification (le CSRF ne protège pas : cookie
   lisible + en-tête forgé) : 401 ajouté sur list/restore/delete/empty et sur
   POST /api/pages/{id}/trash — vérifié anonyme → 401.

Gates : tests/test_trash_api.py (5) · e2e/trash_ui.spec.js (1, avec garde « on
ne vide jamais la poubelle d'autrui ») · pytest 1099 passed · ruff OK ·
e2e probe_nav_perf + partial_nav + editor_mount + logout_dnd + smoke = 10 passed ·
OpenAPI 510 chemins / 7.45.5.
2026-10-03 01:35:26 -04:00

132 lines
5.9 KiB
JavaScript

/**
* Page /trash (v7.45.5) : rendu, dates, sélection groupée, restauration, purge.
*
* Couvre aussi le fix CSRF du front : l'ancien code faisait `csrf?.[1]` alors
* que getCsrf() renvoie une CHAÎNE → 403 silencieux, les boutons ne marchaient
* pas. Ici on clique pour de vrai et on vérifie l'état via l'API.
*/
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false);
if (!ok) {
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForURL('**/workspaces', { timeout: 15000 });
}
}
// Appel API depuis la page (cookies + jeton CSRF du document).
async function api(page, method, url) {
return page.evaluate(async ([m, u]) => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch(u, { method: m, headers: { 'X-CSRF-Token': csrf } });
return { ok: r.ok, status: r.status, body: await r.json().catch(() => null) };
}, [method, url]);
}
async function mkTrashed(page, name) {
const c = await api(page, 'POST', '/board/api/pages?title=' + encodeURIComponent(name));
expect(c.ok, 'create page').toBeTruthy();
const t = await api(page, 'POST', '/api/pages/' + c.body.id + '/trash');
expect(t.ok, 'soft delete').toBeTruthy();
return c.body.id;
}
const trashIds = async (page) => {
const r = await api(page, 'GET', '/board/api/trash');
expect(r.ok).toBeTruthy();
return (r.body || []).map((i) => i.id);
};
test('trash : rendu, dates, sélection groupée, restauration, purge', async ({ page }) => {
test.setTimeout(90000);
const errs = [];
page.on('pageerror', (e) => errs.push('PAGEERROR ' + String(e).slice(0, 160)));
page.on('console', (m) => { if (m.type() === 'error') errs.push('CONSOLE ' + m.text().slice(0, 160)); });
page.on('dialog', (d) => d.accept()); // confirm() des actions destructives
await login(page);
// DB persistante : nommage unique par exécution, on ne dépend jamais des
// restes des runs précédents.
const TAG = 'E2E Trash ' + Date.now().toString(36).toUpperCase();
const nameA = TAG + ' A', nameB = TAG + ' B', nameC = TAG + ' C', nameD = TAG + ' D';
const idA = await mkTrashed(page, nameA);
const idB = await mkTrashed(page, nameB);
await page.goto(`${FD_BASE}/trash`, { waitUntil: 'load' });
await page.waitForTimeout(1000);
// On ne travaille QUE sur nos pages : la poubelle partagée avec l'instance
// de test peut contenir d'autres éléments (jamais on ne touche aux leurs).
await page.fill('input[placeholder="Search pages in Trash"]', TAG);
await page.waitForTimeout(400);
const rowA = page.locator('.trash-item', { hasText: nameA });
const rowB = page.locator('.trash-item', { hasText: nameB });
await expect(rowA).toHaveCount(1);
await expect(rowB).toHaveCount(1);
// Date de suppression + jours restants affichés (standard « trash »)
await expect(rowA.first()).toContainText('Deleted');
await expect(rowA.first()).toContainText('left');
// Compteur + purge totale présents
await expect(page.locator('label input[type=checkbox]')).toHaveCount(1);
await expect(page.getByRole('button', { name: 'Empty Trash' })).toBeVisible();
// Sélection groupée (portée au filtre courant) → restauration
const rowCount = await page.locator('.trash-item').count();
expect(rowCount).toBeGreaterThanOrEqual(2);
await page.locator('label input[type=checkbox]').check();
await expect(page.locator('span', { hasText: rowCount + ' selected' })).toHaveCount(1);
await page.locator('button.btn-sm', { hasText: 'Restore' }).click();
await page.waitForTimeout(900);
await expect(page.locator('.trash-item', { hasText: nameA })).toHaveCount(0);
await expect(page.locator('.trash-item', { hasText: nameB })).toHaveCount(0);
let ids = await trashIds(page);
expect(ids).not.toContain(idA);
expect(ids).not.toContain(idB);
// Restauration unitaire (le vrai test du fix CSRF : avant = 403 silencieux)
const idC = await mkTrashed(page, nameC);
await page.reload({ waitUntil: 'load' });
await page.waitForTimeout(900);
const rowC = page.locator('.trash-item', { hasText: nameC });
await expect(rowC).toHaveCount(1);
await rowC.first().locator('button[title="Restore"]').click();
await page.waitForTimeout(900);
await expect(rowC).toHaveCount(0);
ids = await trashIds(page);
expect(ids).not.toContain(idC);
// Purge totale — seulement si la poubelle ne contient QUE des pages E2E
// (l'instance de test est partagée : on ne vide jamais la poubelle d'autrui).
await mkTrashed(page, nameD);
await page.reload({ waitUntil: 'load' });
await page.waitForTimeout(900);
await expect(page.locator('.trash-item', { hasText: nameD })).toHaveCount(1);
const all = (await api(page, 'GET', '/board/api/trash')).body || [];
if (all.every((i) => i.name.startsWith('E2E Trash'))) {
await page.getByRole('button', { name: 'Empty Trash' }).click();
await page.waitForTimeout(900);
expect(await trashIds(page)).toHaveLength(0);
await expect(page.locator('.lib-empty', { hasText: 'Trash is empty' })).toBeVisible();
} else {
console.log('SKIP purge : la poubelle contient des pages non-E2E ' +
JSON.stringify(all.filter((i) => !i.name.startsWith('E2E Trash')).map((i) => i.name)));
}
const fatal = errs.filter((e) => e.startsWith('PAGEERROR') || e.includes('Alpine Expression Error'));
expect(fatal, fatal.join('\n')).toHaveLength(0);
});