diff --git a/tests/e2e/parcours.spec.js b/tests/e2e/parcours.spec.js index 2dc260a..75f125e 100644 --- a/tests/e2e/parcours.spec.js +++ b/tests/e2e/parcours.spec.js @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; const date = '2026-10-08'; const at = (time, key = date) => Date.parse(`${key}T${time}:00+02:00`); const now = at('18:00'); @@ -33,6 +34,25 @@ async function entries(page) { })); } +async function storedData(page) { + return page.evaluate(() => new Promise((resolve,reject) => { + const request = indexedDB.open('TimeLogDB'); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const stores = ['days','entries','settings']; + const transaction = db.transaction(stores); + const snapshot = {}; + for (const store of stores) { + const query = transaction.objectStore(store).getAll(); + query.onsuccess = () => { snapshot[store] = query.result; }; + } + transaction.oncomplete = () => { db.close(); resolve(snapshot); }; + transaction.onabort = () => { db.close(); reject(transaction.error); }; + }; + })); +} + test('consultation, correction, recalcul, copie et statistiques à 320 px', async ({page,context}) => { await context.grantPermissions(['clipboard-read','clipboard-write']); await importFixture(page); @@ -126,6 +146,36 @@ test('écran données dédié, conservation persistée et accès depuis le rappe await expect(page.getByRole('heading',{name:'Données et sauvegardes',exact:true})).toBeVisible(); }); +test('échec de conservation restaure le champ et les données, puis permet de réessayer', async ({page}) => { + await importFixture(page,fixture({dates:['2025-01-01',date]})); + await page.getByRole('button',{name:'Sauvegarder',exact:true}).click(); + const input = page.getByLabel('Conserver (jours)'); + await expect(input).toHaveValue('30'); + const before = await storedData(page); + await page.evaluate(() => { + const originalPut = IDBObjectStore.prototype.put; + IDBObjectStore.prototype.put = function(record,...args) { + if (this.name === 'settings' && record.key === 'app') { + IDBObjectStore.prototype.put = originalPut; + throw new Error('Échec de conservation simulé'); + } + return originalPut.call(this,record,...args); + }; + }); + await input.fill('60'); await input.press('Tab'); + await expect(page.getByRole('alert')).toContainText('Enregistrement impossible'); + await expect(input).toHaveValue('30'); + expect(await storedData(page)).toEqual(before); + await page.reload(); + await expect(input).toHaveValue('30'); + await input.fill('60'); await input.press('Tab'); + await expect.poll(async () => (await storedData(page)).settings.find(row => row.key === 'app').value.retentionDays).toBe(60); + await expect(page.getByRole('alert')).toHaveCount(0); + const after = await storedData(page); + expect(after.days).toEqual(before.days); + expect(after.entries).toEqual(before.entries); +}); + test('reprise annulée puis concurrente entre deux onglets, choix pause par défaut', async ({page,context}) => { await importFixture(page,fixture({pause:false})); const before = await entries(page); @@ -184,7 +234,7 @@ test('export relu et vérifié, PWA relancée hors ligne', async ({page,context} const download = await downloading; await page.getByRole('button',{name:'Vérifier le fichier exporté…'}).click(); await page.locator('input[type=file]').setInputFiles(await download.path()); - await expect(page.getByRole('status').filter({hasText:'Sauvegarde vérifiée'})).toBeVisible(); + await expect(page.getByRole('status').filter({hasText:/^Sauvegarde vérifiée :/})).toBeVisible(); await page.evaluate(async () => { await navigator.serviceWorker.ready; }); await page.reload(); await expect.poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller))).toBe(true); @@ -195,6 +245,35 @@ test('export relu et vérifié, PWA relancée hors ligne', async ({page,context} await expect(page.locator('.total')).toContainText('8h30'); }); +test('export modifié refusé sans écriture, puis vérification du fichier original', async ({page}) => { + await importFixture(page); + await page.getByRole('button',{name:'Sauvegarder',exact:true}).click(); + const downloading = page.waitForEvent('download'); + await page.getByRole('button',{name:'Exporter les données',exact:true}).click(); + const download = await downloading; + const original = await readFile(await download.path()); + const modified = JSON.parse(original.toString('utf8')); + modified.entries[0].task = 'Tâche modifiée dans le fichier'; + await expect(page.getByRole('status').filter({hasText:'Export préparé'})).toBeVisible(); + const before = await storedData(page); + await page.getByRole('button',{name:'Vérifier le fichier exporté…'}).click(); + await page.locator('input[type=file]').setInputFiles({ + name:'export-modifie.json',mimeType:'application/json',buffer:Buffer.from(JSON.stringify(modified)), + }); + await expect(page.getByRole('alert')).toContainText('Ce fichier ne correspond pas au dernier export préparé'); + expect(await storedData(page)).toEqual(before); + await expect(page.getByRole('status').filter({hasText:/^Sauvegarde vérifiée :/})).toHaveCount(0); + await page.getByRole('button',{name:'Vérifier le fichier exporté…'}).click(); + await page.locator('input[type=file]').setInputFiles(await download.path()); + await expect(page.getByRole('status').filter({hasText:/^Sauvegarde vérifiée :/})).toBeVisible(); + await expect(page.getByRole('alert')).toHaveCount(0); + const after = await storedData(page); + expect(after.days).toEqual(before.days); + expect(after.entries).toEqual(before.entries); + expect(after.settings.find(row => row.key === 'backup-status').value.verifiedExportedAt) + .toBe(before.settings.find(row => row.key === 'backup-status').value.exportedAt); +}); + test('échec IndexedDB visible et reprise avec Réessayer', async ({page}) => { await page.addInitScript(() => { const original = indexedDB.open.bind(indexedDB); @@ -227,6 +306,49 @@ test('purge confirmée et restauration de sa sauvegarde récupérable', async ({ expect(await entries(page)).toHaveLength(6); }); +test('échec de purge conserve les journées et la sauvegarde précédente, puis permet de réessayer', async ({page}) => { + await importFixture(page,fixture({dates:['2025-01-01',date]})); + await page.getByRole('button',{name:'Sauvegarder',exact:true}).click(); + const dialog = page.getByRole('dialog',{name:'Confirmer la purge'}); + await page.getByRole('button',{name:'Purger maintenant'}).click(); + await dialog.getByRole('button',{name:'Confirmer la purge'}).click(); + await expect(dialog).toHaveCount(0); + await page.getByRole('button',{name:'Récupérer la sauvegarde avant purge'}).click(); + await page.getByRole('button',{name:'Fusionner',exact:true}).click(); + await expect(page.getByRole('status').filter({hasText:'1 journée ajoutée'})).toBeVisible(); + const before = await storedData(page); + expect(before.days).toHaveLength(2); + expect(before.entries).toHaveLength(6); + expect(before.settings.find(row => row.key === 'before-purge')).toBeDefined(); + // Make the next backup differ even if the restored records are identical. + await page.clock.runFor(1000); + await page.getByRole('button',{name:'Purger maintenant'}).click(); + await page.evaluate(() => { + const originalDelete = IDBObjectStore.prototype.delete; + IDBObjectStore.prototype.delete = function(...args) { + if (this.name === 'days') { + IDBObjectStore.prototype.delete = originalDelete; + // Entries and the new backup have already been written in this transaction. + throw new Error('Échec de purge simulé'); + } + return originalDelete.apply(this,args); + }; + }); + await dialog.getByRole('button',{name:'Confirmer la purge'}).click(); + await expect(dialog.getByRole('alert')).toContainText('Échec de purge simulé'); + expect(await storedData(page)).toEqual(before); + await expect(dialog.getByRole('button',{name:'Confirmer la purge'})).toBeEnabled(); + await dialog.getByRole('button',{name:'Confirmer la purge'}).click(); + await expect(dialog).toHaveCount(0); + const after = await storedData(page); + expect(after.days).toEqual(before.days.filter(day => day.date === date)); + expect(after.entries).toEqual(before.entries.filter(entry => entry.date === date)); + const recovered = after.settings.find(row => row.key === 'before-purge').value; + expect(recovered.days.map(day => day.date).sort()).toEqual(['2025-01-01',date]); + expect(recovered.entries).toHaveLength(6); + expect(recovered.exportedAt).not.toBe(before.settings.find(row => row.key === 'before-purge').value.exportedAt); +}); + test('dialogue horaire imbriqué garde le focus et le rend à la correction', async ({page}) => { await importFixture(page); await page.getByRole('button',{name:'Voir les détails'}).click();