Repository Public
1// Owner side of sharing: the share dialog and the Shares screen, the portal dialog, the Requests screen and the detail2// of a portal with its deposits. Visitors and depositors are separate anonymous contexts or plain HTTP clients.3import { type APIRequestContext, type Browser, expect, type Page, test } from '@playwright/test';4import { mkdirSync } from 'node:fs';5import { apiSignUp } from './helpers.ts';67test.use({ locale: 'fr-CA' });8const BASE = process.env.SPD_E2E_BASE ?? 'http://localhost:5183';9const SHOTS = process.env.SPD_SHOTS ? '/tmp/spd-shots/sharing' : '';10const shot = async (page: Page, name: string, info: { project: { name: string } }) => { if (SHOTS) { mkdirSync(SHOTS, { recursive: true }); await page.screenshot({ path: `${SHOTS}/owner-${name}-${info.project.name}.png` }); } };11const clientIp = () => `10.${Math.floor(Math.random() * 250) + 1}.${Math.floor(Math.random() * 250) + 1}.${Math.floor(Math.random() * 250) + 1}`;1213async function call<T = any>(page: Page, method: string, path: string, body?: unknown): Promise<T> {14 return page.evaluate(async ([m, p, b]) => {15 const r = await fetch(`/api/v1${p}`, { method: m as string, headers: { 'X-SPD-CSRF': '1', ...(b !== undefined ? { 'Content-Type': 'application/json' } : {}) }, body: b !== undefined ? JSON.stringify(b) : m === 'GET' ? undefined : '{}' });16 const j = await r.json().catch(() => null);17 if (!r.ok) throw new Error(`${m} ${p}: ${r.status} ${JSON.stringify(j)}`);18 return j;19 }, [method, path, body] as const);20}21async function upload(page: Page, folderId: string, name: string, text: string): Promise<{ id: string }> {22 const r = await page.evaluate(async ([folder, n, body]) => {23 const h = { 'X-SPD-CSRF': '1' }, bytes = new TextEncoder().encode(body);24 const u = await (await fetch('/api/v1/uploads', { method: 'POST', headers: { ...h, 'Content-Type': 'application/json' }, body: JSON.stringify({ folderId: folder, name: n, size: bytes.length }) })).json();25 await fetch(`/api/v1/uploads/${u.id}?offset=0`, { method: 'PATCH', headers: { ...h, 'Content-Type': 'application/octet-stream' }, body: bytes });26 return (await fetch(`/api/v1/uploads/${u.id}/complete`, { method: 'POST', headers: { ...h, 'Content-Type': 'application/json' }, body: '{}' })).json();27 }, [folderId, name, text] as const);28 return r.file;29}30/** An anonymous visitor in its own browser context. */31async function visitor(browser: Browser, info: { project: { use: object } }): Promise<Page> {32 const ctx = await browser.newContext({ ...info.project.use, locale: 'fr-CA', baseURL: BASE, extraHTTPHeaders: { 'X-Forwarded-For': clientIp() } });33 return ctx.newPage();34}35const pathOf = (url: string) => new URL(url).pathname;36const menu = async (scope: ReturnType<Page['locator']>, page: Page, item: string) => { await scope.getByRole('button', { name: /^Actions pour/ }).click(); await page.getByRole('menuitem', { name: item }).click(); };3738test('share links: create, visit, journal, edit, rotate, revoke and delete', async ({ context, page, browser }, info) => {39 await apiSignUp(context, page);40 const folder = await call(page, 'POST', '/folders', { parentId: 'root', name: 'Comptabilité' });41 const file = await upload(page, folder.id, 'Bilan 2025.txt', 'Actif = Passif');42 await call(page, 'POST', '/shares', { fileId: file.id, label: 'Premier lien' });43 await call(page, 'POST', '/shares', { folderId: folder.id, label: 'Dossier pour le conseil' });4445 await page.goto('/shares');46 const screen = page.getByTestId('shares-screen');47 await expect(screen.getByRole('heading', { level: 1, name: 'Liens de partage' })).toBeVisible();48 await expect(screen.getByTestId('share-item')).toHaveCount(2);49 // a folder link leads to the folder; a file link opens the share dialog of the file50 await expect(screen.getByRole('link', { name: 'Comptabilité' })).toHaveAttribute('href', `/folder/${folder.id}`);51 await screen.getByRole('button', { name: 'Bilan 2025.txt' }).click();52 const dialog = page.getByRole('dialog', { name: 'Partager « Bilan 2025.txt »' });53 await expect(dialog.getByTestId('share-item')).toHaveCount(1);5455 // --- create ---56 await dialog.getByRole('button', { name: 'Créer un lien' }).click();57 await dialog.getByLabel('Libellé').fill('Pour Julie');58 await dialog.getByRole('switch', { name: 'Autoriser le téléchargement' }).click();59 await expect(dialog.getByText(/Cela n’empêche pas une capture d’écran/)).toBeVisible();60 await dialog.getByLabel('Mot de passe (facultatif)').fill('abc');61 await dialog.getByRole('radio', { name: '7 jours' }).click();62 await dialog.getByLabel('Consultations max.').fill('5');63 await dialog.getByRole('button', { name: 'Créer un lien' }).click();64 await expect(dialog.getByText('Le mot de passe doit compter au moins 4 caractères.')).toBeVisible();65 await dialog.getByLabel('Mot de passe (facultatif)').fill('hibou-12');66 await dialog.getByRole('button', { name: 'Créer un lien' }).click();67 const once = dialog.getByTestId('link-once');68 await expect(once.getByText(/il ne sera plus jamais affiché/)).toBeVisible();69 await shot(page, 'share-created', info);70 const url = await once.getByRole('textbox', { name: 'Lien' }).inputValue();71 expect(url).toMatch(/\/s\/[A-Za-z0-9_-]{43}$/);72 await dialog.getByRole('button', { name: 'Terminé' }).click();73 await expect(dialog.getByTestId('share-item')).toHaveCount(2);74 await expect(dialog.getByTestId('link-once')).toHaveCount(0); // the link is not shown a second time75 expect(await dialog.innerHTML()).not.toContain(pathOf(url));7677 // --- a visitor uses it: one wrong password, then in ---78 const guest = await visitor(browser, info);79 await guest.goto(pathOf(url));80 await guest.getByLabel('Mot de passe').fill('raté');81 await guest.getByRole('button', { name: 'Ouvrir' }).click();82 await expect(guest.getByText('Mot de passe incorrect.')).toBeVisible();83 await guest.getByLabel('Mot de passe').fill('hibou-12');84 await guest.getByRole('button', { name: 'Ouvrir' }).click();85 await expect(guest.getByRole('heading', { level: 1, name: 'Bilan 2025.txt' })).toBeVisible();86 await expect(guest.getByRole('button', { name: /Télécharger/ })).toHaveCount(0); // view-only8788 // --- the owner sees the visit ---89 await dialog.getByRole('button', { name: 'Fermer' }).click();90 await page.reload();91 const item = screen.getByTestId('share-item').filter({ hasText: 'Pour Julie' });92 await expect(item.getByTestId('share-counters')).toContainText('1 consultation / 5');93 await shot(page, 'shares', info);94 await expect(item).toContainText('Lecture seule');95 await expect(item).toContainText('Mot de passe');96 await menu(item, page, 'Journal d’accès');97 const journal = page.getByRole('dialog', { name: /Journal d’accès — Pour Julie/ });98 await expect(journal.getByTestId('share-events').getByRole('listitem')).toHaveCount(2);99 await shot(page, 'share-journal', info);100 await expect(journal.locator('[data-kind="open"][data-ok="true"]')).toContainText('Ouverture');101 await expect(journal.locator('[data-kind="password"][data-ok="false"]')).toContainText('Refusé');102 await expect(journal.locator('[data-kind="open"]')).toContainText(/10\.\d+\.\d+\.\d+/);103 await journal.getByRole('button', { name: 'Fermer' }).last().click();104105 // --- edit ---106 await menu(item, page, 'Modifier les réglages');107 const edit = page.getByRole('dialog', { name: 'Réglages du lien' });108 await expect(edit.getByLabel('Libellé')).toHaveValue('Pour Julie');109 await shot(page, 'share-edit', info);110 await edit.getByLabel('Libellé').fill('Pour Julie B.');111 await edit.getByRole('switch', { name: 'Autoriser le téléchargement' }).click();112 await edit.getByLabel('Téléchargements max.').fill('3');113 await edit.getByRole('button', { name: 'Enregistrer' }).click();114 const edited = screen.getByTestId('share-item').filter({ hasText: 'Pour Julie B.' });115 await expect(edited).toContainText('Téléchargement autorisé');116 await expect(edited.getByTestId('share-counters')).toContainText('0 téléchargement / 3');117118 // --- rotate: a new link once, the old one dead ---119 await menu(edited, page, 'Nouveau lien');120 await page.getByRole('dialog', { name: 'Nouveau lien' }).getByRole('button', { name: 'Générer un nouveau lien' }).click();121 const fresh = await page.getByTestId('link-once').getByRole('textbox', { name: 'Lien' }).inputValue();122 expect(fresh).not.toBe(url);123 await page.getByRole('button', { name: 'Terminé' }).click();124 await guest.goto(pathOf(url));125 await expect(guest.getByRole('heading', { name: 'Ce lien n’est pas disponible' })).toBeVisible();126 await guest.goto(pathOf(fresh));127 await expect(guest.getByRole('heading', { name: 'Ce contenu est protégé' })).toBeVisible();128129 // --- revoke: immediate ---130 await menu(edited, page, 'Révoquer');131 await page.getByRole('dialog', { name: 'Révoquer' }).getByRole('button', { name: 'Révoquer' }).click();132 await expect(edited).toHaveAttribute('data-status', 'revoked');133 await guest.reload();134 await expect(guest.getByRole('heading', { name: 'Ce lien n’est pas disponible' })).toBeVisible();135 await guest.context().close();136137 // --- filter by state, then delete ---138 await screen.getByRole('button', { name: /^Révoqué/ }).click();139 await expect(screen.getByTestId('share-item')).toHaveCount(1);140 await menu(edited, page, 'Supprimer');141 await page.getByRole('dialog', { name: 'Supprimer' }).getByRole('button', { name: 'Supprimer' }).click();142 await expect(screen.getByText('Pour Julie B.')).toHaveCount(0);143 expect((await call(page, 'GET', '/shares')).map((s: { label: string }) => s.label).sort()).toEqual(['Dossier pour le conseil', 'Premier lien']);144});145146/** A depositor without a browser: the public endpoints, as the deposit page calls them. */147async function deposit(request: APIRequestContext, token: string, form: Record<string, unknown>, files: { name: string; body: Buffer }[]): Promise<{ ref: string; status: string }> {148 const headers = { 'X-SPD-CSRF': '1', Origin: BASE, 'X-Forwarded-For': clientIp() };149 const res = await request.post(`/api/v1/public/requests/${token}/submissions`, { headers, data: { ...form, files: files.map((f) => ({ name: f.name, size: f.body.length })) } });150 expect(res.status(), await res.text()).toBe(200);151 const sub = await res.json();152 const auth = { ...headers, Authorization: `Bearer ${sub.submissionToken}` };153 for (const [i, it] of sub.items.entries()) expect((await request.patch(`/api/v1/public/uploads/${it.uploadId}?offset=0`, { headers: { ...auth, 'Content-Type': 'application/octet-stream' }, data: files[i]!.body })).status()).toBe(200);154 expect((await request.post('/api/v1/public/submissions/finalize', { headers: auth, data: {} })).status()).toBe(200);155 for (let i = 0; i < 60; i++) {156 const s = await (await request.get('/api/v1/public/submissions/status', { headers: auth })).json();157 if (s.status === 'completed' || s.status === 'rejected') return { ref: sub.ref, status: s.status };158 await new Promise((r) => setTimeout(r, 500));159 }160 throw new Error('the deposit was not processed in time');161}162163test('deposit portals: create with every section, pause, deposits and their journal, rotate, edit, revoke, delete', async ({ context, page, browser, request }, info) => {164 await apiSignUp(context, page);165 const clients = await call(page, 'POST', '/folders', { parentId: 'root', name: 'Clients' });166 const target = await call(page, 'POST', '/folders', { parentId: clients.id, name: 'Dossier Roy' });167168 await page.goto('/requests');169 const screen = page.getByTestId('requests-screen');170 await expect(screen.getByRole('heading', { name: 'Aucun portail de dépôt' })).toBeVisible();171 await expect(screen.getByText(/Un portail est une page publique/)).toBeVisible();172 await screen.getByRole('button', { name: 'Nouveau portail' }).click();173174 // --- folder picker: lazy tree, root included ---175 const picker = page.getByRole('dialog', { name: 'Dossier de destination' });176 await expect(picker.getByRole('button', { name: 'Mon Drive', exact: true })).toBeVisible();177 await expect(picker.getByRole('button', { name: 'Choisir ce dossier' })).toBeDisabled();178 await picker.getByRole('button', { name: 'Développer Clients' }).click();179 await picker.getByRole('button', { name: 'Dossier Roy', exact: true }).click();180 await picker.getByRole('button', { name: 'Choisir ce dossier' }).click();181182 // --- every section of the form ---183 const form = page.getByRole('dialog', { name: 'Nouveau portail de dépôt' });184 await expect(form.getByText('Les fichiers déposés arrivent dans « Dossier Roy »')).toBeVisible();185 await expect(form.getByLabel('Taille max. par fichier')).toHaveValue('200'); // defaults are visible186 await expect(form.getByLabel('Volume total du portail')).toHaveValue('5');187 await form.getByRole('button', { name: 'Créer le portail' }).click();188 await expect(form.getByText('Donnez un titre au portail.')).toBeVisible();189 await form.getByLabel('Titre').fill('Pièces pour votre déclaration');190 await form.getByLabel('Description').fill('Déposez ici vos relevés et reçus.');191 await form.getByLabel('Consignes').fill('Un fichier par document.\nPDF de préférence.');192 await form.getByLabel('Nom affiché').fill('Cabinet Roy & associés');193 await form.getByRole('button', { name: '#0f766e' }).click();194 await expect(form.getByTestId('brand-preview')).toContainText('Pièces pour votre déclaration');195 await expect(form.getByTestId('brand-preview')).toContainText('Cabinet Roy & associés');196 await shot(page, 'portal-form-top', info);197 await form.getByLabel('Mot de passe (facultatif)').fill('depot-2026');198 const inTenDays = new Date(Date.now() + 10 * 86_400_000);199 await form.getByLabel('Date d’expiration').fill(`${inTenDays.getFullYear()}-${String(inTenDays.getMonth() + 1).padStart(2, '0')}-${String(inTenDays.getDate()).padStart(2, '0')}T17:00`);200 await form.getByLabel('Taille max. par fichier').fill('50');201 await form.getByLabel('Fichiers par dépôt').fill('5');202 await form.getByLabel('Volume total du portail').fill('1');203 await form.getByLabel('Nombre max. de dépôts').fill('10');204 await form.getByLabel('Dépôts par heure et par adresse').fill('6');205 await form.getByRole('button', { name: 'Documents', exact: true }).click();206 await form.getByLabel('Ajouter une extension').fill('exe');207 await form.getByRole('button', { name: 'Ajouter', exact: true }).click();208 await expect(form.getByText('.exe est un type exécutable : il est toujours refusé.')).toBeVisible();209 await form.getByLabel('Ajouter une extension').fill('.JPG, png');210 await form.getByRole('button', { name: 'Ajouter', exact: true }).click();211 await expect(form.getByTestId('ext-chips')).toContainText('.jpg');212 await expect(form.getByTestId('ext-chips')).toContainText('.pdf');213 await form.getByLabel('Nom', { exact: true }).selectOption('required');214 await form.getByLabel('Courriel', { exact: true }).selectOption('optional');215 await form.getByLabel('Référence', { exact: true }).selectOption('required');216 await form.getByRole('button', { name: 'Ajouter un champ personnalisé' }).click();217 await form.getByRole('textbox', { name: 'Champ personnalisé 1' }).fill('Numéro de client');218 await form.getByTestId('custom-field').getByRole('checkbox').check();219 await form.getByRole('switch', { name: 'Exiger un consentement' }).click();220 await form.getByLabel('Texte du consentement').fill('J’autorise le cabinet à traiter ces documents.');221 await form.getByLabel('Note de conservation').fill('Conservés 7 ans.');222 await expect(form.getByRole('switch', { name: 'Un sous-dossier par dépôt' })).toHaveAttribute('aria-checked', 'true');223 await expect(form.getByRole('switch', { name: 'Me notifier à chaque dépôt' })).toHaveAttribute('aria-checked', 'true');224 await form.getByLabel('Message de confirmation').fill('Merci, nous avons bien reçu vos pièces.');225 await shot(page, 'portal-form-bottom', info);226 await form.getByRole('button', { name: 'Créer le portail' }).click();227228 // --- the link, once, with its QR code ---229 const created = page.getByRole('dialog', { name: 'Portail créé' });230 await expect(created.getByText(/il ne sera plus jamais affiché/)).toBeVisible();231 await expect(created.getByTestId('qr')).toBeVisible();232 await shot(page, 'portal-created', info);233 const url = await created.getByRole('textbox', { name: 'Lien' }).inputValue();234 expect(url).toMatch(/\/request\/[A-Za-z0-9_-]{43}$/);235 await created.getByRole('button', { name: 'Terminé' }).click();236237 const [saved] = await call(page, 'GET', '/requests');238 expect(saved).toMatchObject({239 folderId: target.id, title: 'Pièces pour votre déclaration', description: 'Déposez ici vos relevés et reçus.', instructions: 'Un fichier par document.\nPDF de préférence.', brandName: 'Cabinet Roy & associés', brandColor: '#0f766e',240 hasPassword: true, active: true, maxFileBytes: 50 * 1024 ** 2, maxFilesPerSubmission: 5, maxTotalBytes: 1024 ** 3, maxSubmissions: 10, submissionsPerHour: 6, requireConsent: true,241 consentText: 'J’autorise le cabinet à traiter ces documents.', retentionNote: 'Conservés 7 ans.', subfolderPerSubmission: true, notifyOwner: true, confirmationMessage: 'Merci, nous avons bien reçu vos pièces.', state: 'open',242 fields: { name: 'required', email: 'optional', reference: 'required', custom: [{ key: 'numero_de_client', label: 'Numéro de client', required: true }] },243 });244 expect(saved.allowedExtensions).toEqual(expect.arrayContaining(['pdf', 'docx', 'jpg', 'png']));245 expect(new Date(saved.expiresAt).getTime()).toBeGreaterThan(Date.now() + 8 * 86_400_000);246247 // --- the card, pause and reactivate ---248 const card = screen.getByTestId('portal-card');249 await expect(card).toHaveCount(1);250 await expect(card.getByTestId('portal-state')).toHaveText('Ouvert');251 await expect(card.getByRole('link', { name: 'Dossier Roy' })).toHaveAttribute('href', `/folder/${target.id}`);252 await card.getByRole('button', { name: 'Mettre en pause' }).click();253 await expect(card.getByTestId('portal-state')).toHaveText('En pause');254 const guest = await visitor(browser, info);255 await guest.goto(pathOf(url));256 await expect(guest.getByRole('heading', { name: 'Ce portail n’accepte plus de dépôts' })).toBeVisible();257 await card.getByRole('button', { name: 'Réactiver' }).click();258 await expect(card.getByTestId('portal-state')).toHaveText('Ouvert');259 await guest.reload();260 await expect(guest.getByRole('heading', { level: 1, name: 'Pièces pour votre déclaration' })).toBeVisible();261 await expect(guest.getByTestId('portal-banner')).toContainText('Cabinet Roy & associés');262 await expect(guest.getByTestId('portal-banner')).toHaveCSS('background-color', 'rgb(15, 118, 110)'); // the portal's accent colour263 await expect(guest.getByTestId('portal-banner')).toHaveCSS('color', 'rgb(255, 255, 255)');264 await expect(guest.getByLabel('Numéro de client')).toBeVisible();265266 // --- a deposit: one good file, one program disguised as an image ---267 const token = pathOf(url).split('/').pop()!;268 const exe = Buffer.concat([Buffer.from('MZ\x90\x00\x03\x00\x00\x00', 'latin1'), Buffer.alloc(1024, 1)]);269 const done = await deposit(request, token, { password: 'depot-2026', name: 'Hélène Roy', email: 'helene@example.test', reference: 'T1-2025', custom: { numero_de_client: 'C-0077' }, consent: true },270 [{ name: 'T4.pdf', body: Buffer.from('%PDF-1.4\n%%EOF\n') }, { name: 'photo.jpg', body: exe }]);271 expect(done.status).toBe('completed');272273 // --- the deposits of the portal ---274 await card.getByRole('button', { name: 'Voir les dépôts' }).click();275 const detail = page.getByTestId('request-detail');276 await expect(detail.getByRole('heading', { level: 1, name: 'Pièces pour votre déclaration' })).toBeVisible();277 await expect(detail.getByTestId('portal-facts')).toContainText('50 Mo par fichier');278 await expect(detail.getByTestId('portal-facts')).toContainText('Numéro de client (obligatoire)');279 const row = detail.getByTestId('submissions').locator('tbody tr');280 await expect(row).toHaveCount(1);281 await expect(row).toContainText(done.ref);282 await expect(row).toContainText('Hélène Roy');283 await expect(row).toContainText('Terminé');284 await expect(row).toContainText('1 accepté');285 await expect(row).toContainText('1 refusé');286 await shot(page, 'portal-detail', info);287 await expect(row.getByRole('link', { name: 'Ouvrir le dossier' })).toHaveAttribute('href', /\/folder\/[0-9a-f-]{36}$/);288 await row.getByRole('button', { name: `Détail du dépôt ${done.ref}` }).click();289 const sub = page.getByRole('dialog', { name: `Dépôt ${done.ref}` });290 await expect(sub.getByTestId('answers')).toContainText('Hélène Roy');291 await expect(sub.getByTestId('answers')).toContainText('T1-2025');292 await expect(sub.getByTestId('answers')).toContainText('Numéro de client');293 await expect(sub.getByTestId('answers')).toContainText('C-0077');294 await expect(sub.getByTestId('answers')).toContainText(/10\.\d+\.\d+\.\d+/);295 await expect(sub.getByTestId('sub-items').locator('[data-status="placed"]')).toContainText('T4.pdf');296 await expect(sub.getByTestId('sub-items').locator('[data-status="rejected"]')).toContainText('Refusé — le contenu est un programme ou un script.');297 const events = sub.getByTestId('sub-events').getByRole('listitem');298 await expect(events).toHaveCount(4);299 await expect(events.nth(0)).toContainText('Dépôt commencé : 2 fichier(s)');300 await expect(events.nth(1)).toContainText('Envoi terminé : 2 fichier(s)');301 await expect(events.nth(2)).toContainText('« photo.jpg » refusé');302 await expect(events.nth(3)).toContainText('Dépôt terminé : 1 fichier(s) rangé(s), 1 refusé(s).');303 await shot(page, 'submission', info);304 await sub.getByRole('button', { name: 'Fermer' }).last().click();305306 // --- rotate from the list: new link once, old link dead ---307 await detail.getByRole('link', { name: 'Portails de dépôt' }).click();308 await expect(card.getByTestId('portal-deposits')).toHaveText('1 / 10');309 await shot(page, 'requests', info);310 await menu(card, page, 'Nouveau lien');311 await page.getByRole('dialog', { name: 'Générer un nouveau lien' }).getByRole('button', { name: 'Générer un nouveau lien' }).click();312 const fresh = await page.getByTestId('link-once').getByRole('textbox', { name: 'Lien' }).inputValue();313 expect(fresh).not.toBe(url);314 await expect(page.getByTestId('qr')).toBeVisible();315 await page.getByRole('button', { name: 'Terminé' }).click();316 await guest.goto(pathOf(url));317 await expect(guest.getByRole('heading', { name: 'Ce lien de dépôt n’est pas disponible' })).toBeVisible();318 await guest.goto(pathOf(fresh));319 await expect(guest.getByRole('heading', { level: 1, name: 'Pièces pour votre déclaration' })).toBeVisible();320321 // --- edit: values are back, the link is not ---322 await menu(card, page, 'Modifier');323 const edit = page.getByRole('dialog', { name: 'Modifier le portail' });324 await expect(edit.getByLabel('Titre')).toHaveValue('Pièces pour votre déclaration');325 await expect(edit.getByRole('textbox', { name: 'Champ personnalisé 1' })).toHaveValue('Numéro de client');326 await expect(edit.getByTestId('link-once')).toHaveCount(0);327 await expect(edit.getByText(/Le lien public ne peut pas être réaffiché/)).toBeVisible();328 await expect(edit.getByRole('button', { name: 'Générer un nouveau lien' })).toBeVisible();329 await edit.getByLabel('Titre').fill('Pièces — déclaration 2025');330 await edit.getByRole('button', { name: 'Enregistrer' }).click();331 await expect(card.getByRole('link', { name: 'Pièces — déclaration 2025' })).toBeVisible();332 const [after] = await call(page, 'GET', '/requests');333 expect(after).toMatchObject({ title: 'Pièces — déclaration 2025', hasPassword: true, maxSubmissions: 10, fields: { custom: [{ key: 'numero_de_client' }] } });334 expect(after.expiresAt).toBe(saved.expiresAt);335336 // --- revoke, then delete ---337 await menu(card, page, 'Révoquer');338 const revoke = page.getByRole('dialog', { name: 'Révoquer le portail' });339 await expect(revoke.getByText(/refusés à leur prochain bloc/)).toBeVisible();340 await revoke.getByRole('button', { name: 'Révoquer' }).click();341 await expect(card.getByTestId('portal-state')).toHaveText('Révoqué');342 await expect(card.getByRole('button', { name: 'Mettre en pause' })).toHaveCount(0);343 await guest.reload();344 await expect(guest.getByRole('heading', { name: 'Ce lien de dépôt n’est pas disponible' })).toBeVisible();345 await guest.context().close();346 await menu(card, page, 'Supprimer');347 const purge = page.getByRole('dialog', { name: 'Supprimer le portail' });348 await expect(purge.getByText(/Les fichiers déjà déposés restent dans votre dossier/)).toBeVisible();349 await purge.getByRole('button', { name: 'Supprimer' }).click();350 await expect(screen.getByRole('heading', { name: 'Aucun portail de dépôt' })).toBeVisible();351 // the deposited file is still in the owner's folder352 const inTarget = await call(page, 'GET', `/folders/${target.id}`);353 expect(inTarget.folders).toHaveLength(1);354 expect((await call(page, 'GET', `/folders/${inTarget.folders[0].id}`)).files.map((f: { name: string }) => f.name)).toEqual(['T4.pdf']);355});356