From 0c51689dfd69834cf57cb56a603256bf5a6c7ed1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:01:28 +0000 Subject: [PATCH] =?UTF-8?q?fix(cards):=20Sitzung,=20Entwurf=20und=20Datei-?= =?UTF-8?q?Aufr=C3=A4umen=20beim=20=C3=96ffnen=20und=20Speichern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gäste/abgelaufene Sitzung: Appwrite antwortet mit 404; withLogin und openSavedCard melden dann an (Dialog, einmal wiederholen) statt „nicht gefunden“ zu zeigen. - Öffnen einer Karte fragt vor dem Verwerfen eines ungespeicherten Entwurfs nach; ein fehlgeschlagenes Öffnen setzt einen gefüllten Editor nicht zurück. - Update ohne Bildwechsel schickt kein imageFileId mehr (veralteter Stand zeigte sonst auf eine gelöschte Datei). - Uploads werden nur nach eindeutiger Ablehnung (4xx ohne 408/429) gelöscht, nicht bei Netzfehler, 5xx oder 429. Co-Authored-By: Claude Opus 5.5 --- web/src/account/cards.test.ts | 80 +++++++++- web/src/account/cards.ts | 36 ++++- web/src/account/with-login.test.ts | 79 ++++++++++ web/src/account/with-login.ts | 38 ++++- web/src/editor/editor-page.test.ts | 237 ++++++++++++++++++++++++++--- web/src/editor/editor-page.ts | 64 ++++++-- web/src/pages/cards-page.test.ts | 69 +++++++++ 7 files changed, 546 insertions(+), 57 deletions(-) diff --git a/web/src/account/cards.test.ts b/web/src/account/cards.test.ts index 180338d..641dc5a 100644 --- a/web/src/account/cards.test.ts +++ b/web/src/account/cards.test.ts @@ -147,6 +147,9 @@ const newInput = (patch: Partial = {}): SaveInput => ({ const service = (fake: ReturnType, userId: () => string = () => 'u1') => createCardsService(fake.api, userId); +/** Der Server hat die Anfrage eindeutig abgelehnt (4xx): es wurde nichts geschrieben. */ +const rejected = (code = 400) => new AppwriteException('Abgelehnt', code, 'document_invalid_structure'); + describe('normalizeName', () => { it('trimmt und fasst Leerraum zusammen', () => { expect(normalizeName(' Mein Urlaub ')).toBe('Mein Urlaub'); @@ -240,14 +243,60 @@ describe('save: neue Karte', () => { expect((fake.methods('createFile')[1].args[0] as File).name).toBe('preview.png'); }); - it('scheitert createRow, werden beide neuen Dateien gelöscht und der Fehler kommt an', async () => { + it('lehnt der Server createRow eindeutig ab (4xx), werden beide neuen Dateien gelöscht und der Fehler kommt an', async () => { const fake = fakeApi(); - const boom = new Error('createRow'); + const boom = rejected(400); fake.failOnce.set('createRow', boom); await expect(service(fake).save(newInput())).rejects.toBe(boom); expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['file1', 'file2']); }); + it('auch 401 (Sitzung weg) und andere 4xx gelten als eindeutig abgelehnt', async () => { + for (const code of [401, 403, 404, 409, 413]) { + const fake = fakeApi(); + fake.failOnce.set('createRow', rejected(code)); + await expect(service(fake).save(newInput())).rejects.toThrow(); + expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['file1', 'file2']); + } + }); + + describe('unklarer Ausgang beim Schreiben der Zeile: Uploads bleiben liegen', () => { + const ambiguous: [string, () => unknown][] = [ + ['Netzfehler (TypeError)', () => new TypeError('Failed to fetch')], + ['503', () => new AppwriteException('Server', 503, 'general_server_error')], + ['500', () => new AppwriteException('Server', 500, 'general_server_error')], + ['429 (zu viele Anfragen)', () => new AppwriteException('Rate', 429, 'general_rate_limit_exceeded')], + ['408 (Zeitüberschreitung)', () => new AppwriteException('Timeout', 408, 'general_timeout')], + ['ein Fehler ohne Statuscode', () => new Error('unbekannt')], + ]; + + for (const [label, make] of ambiguous) { + it(`neue Karte, createRow: ${label}`, async () => { + const fake = fakeApi(); + const boom = make(); + fake.failOnce.set('createRow', boom); + await expect(service(fake).save(newInput())).rejects.toBe(boom); + expect(fake.methods('deleteFile')).toHaveLength(0); + }); + } + + it('bestehende Karte, updateRow: Netzfehler löscht weder neue noch alte Dateien', async () => { + const fake = fakeApi([{ $id: 'row1', $updatedAt: NOW, name: 'Alt', config: serializeConfig(config()), imageFileId: 'oldImage', previewFileId: 'oldPreview' }]); + const boom = new TypeError('Failed to fetch'); + fake.failOnce.set('updateRow', boom); + await expect(service(fake).save(newInput({ imageChanged: true, previous: previous() }))).rejects.toBe(boom); + expect(fake.methods('deleteFile')).toHaveLength(0); + }); + + it('Zeile verschwunden, dann Netzfehler beim Anlegen: nichts wird gelöscht', async () => { + const fake = fakeApi(); + fake.failOnce.set('updateRow', new AppwriteException('Row not found', 404, 'row_not_found')); + fake.failOnce.set('createRow', new TypeError('Failed to fetch')); + await expect(service(fake).save(newInput({ imageChanged: false, previous: previous() }))).rejects.toThrow(TypeError); + expect(fake.methods('deleteFile')).toHaveLength(0); + }); + }); + it('scheitert die Vorschau, wird das Bild gelöscht und keine Zeile angelegt', async () => { const fake = fakeApi(); const boom = new Error('preview'); @@ -259,7 +308,7 @@ describe('save: neue Karte', () => { it('Fehler beim Aufräumen überdecken den Ursprungsfehler nicht', async () => { const fake = fakeApi(); - const boom = new Error('createRow'); + const boom = rejected(400); fake.failOnce.set('createRow', boom); const original = fake.api.deleteFile; fake.api.deleteFile = async (id) => { @@ -309,10 +358,13 @@ describe('save: bestehende Karte', () => { const [file] = fake.methods('createFile'); expect((file.args[0] as File).name).toBe('preview.webp'); expect(file.args[1]).toEqual(OWNER); + // Ohne Bildwechsel steht kein imageFileId im Update: ein veralteter Schnappschuss darf die Zeile nicht auf eine + // inzwischen gelöschte Bilddatei zurücksetzen. expect(fake.methods('updateRow')[0].args).toEqual([ 'row1', - { name: 'Neu', config: serializeConfig(c), imageFileId: 'oldImage', previewFileId: 'file1' }, + { name: 'Neu', config: serializeConfig(c), previewFileId: 'file1' }, ]); + expect(fake.methods('updateRow')[0].args[1]).not.toHaveProperty('imageFileId'); expect(fake.methods('deleteFile')[0].args).toEqual(['oldPreview']); expect(saved.id).toBe('row1'); expect(saved.imageFileId).toBe('oldImage'); @@ -320,6 +372,18 @@ describe('save: bestehende Karte', () => { expect(saved.config.network).toBe('jcb'); }); + it('veralteter Schnappschuss (Bild in anderem Tab ersetzt): das Update schickt kein altes imageFileId, die Zeile behält das neue Bild', async () => { + // Der Server kennt schon das neue Bild; der Editor hält noch den Stand von vorher. + const fake = fakeApi([{ ...existing(), imageFileId: 'serverImage', previewFileId: 'serverPreview' }]); + const saved = await service(fake).save( + newInput({ imageChanged: false, previous: previous({ imageFileId: 'deletedImage', previewFileId: 'stalePreview' }) }), + ); + expect(fake.methods('updateRow')[0].args[1]).not.toHaveProperty('imageFileId'); + expect(saved.imageFileId).toBe('serverImage'); + // Gelöscht wird nur die Vorschau des Schnappschusses, nie die Bilddatei. + expect(fake.methods('deleteFile').map((c) => c.args[0])).toEqual(['stalePreview']); + }); + it('mit Bildwechsel: zwei neue Dateien, nach dem Update werden beide alten gelöscht', async () => { const fake = fakeApi([existing()]); const saved = await service(fake).save(newInput({ imageChanged: true, previous: previous() })); @@ -331,9 +395,9 @@ describe('save: bestehende Karte', () => { expect(saved.imageFileId).toBe('file1'); }); - it('scheitert updateRow, werden nur die neuen Dateien gelöscht', async () => { + it('lehnt der Server updateRow ab, werden nur die neuen Dateien gelöscht', async () => { const fake = fakeApi([existing()]); - const boom = new Error('updateRow'); + const boom = rejected(400); fake.failOnce.set('updateRow', boom); await expect(service(fake).save(newInput({ imageChanged: true, previous: previous() }))).rejects.toBe(boom); expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['file1', 'file2']); @@ -341,7 +405,7 @@ describe('save: bestehende Karte', () => { it('scheitert updateRow ohne Bildwechsel, wird nur die neue Vorschau gelöscht', async () => { const fake = fakeApi([existing()]); - fake.failOnce.set('updateRow', new Error('updateRow')); + fake.failOnce.set('updateRow', rejected(400)); await expect(service(fake).save(newInput({ imageChanged: false, previous: previous() }))).rejects.toThrow(); expect(fake.methods('deleteFile').map((c) => c.args[0])).toEqual(['file1']); }); @@ -401,7 +465,7 @@ describe('save: bestehende Karte', () => { it('scheitert auch das Anlegen, werden alle neuen Dateien gelöscht, die alten bleiben, der Fehler kommt an', async () => { const fake = fakeApi([existing()]); - const boom = new Error('createRow'); + const boom = rejected(400); fake.failOnce.set('updateRow', gone()); fake.failOnce.set('createRow', boom); await expect(service(fake).save(newInput({ imageChanged: false, previous: previous() }))).rejects.toBe(boom); diff --git a/web/src/account/cards.ts b/web/src/account/cards.ts index 70720c4..5dfbb64 100644 --- a/web/src/account/cards.ts +++ b/web/src/account/cards.ts @@ -87,6 +87,15 @@ async function deleteQuietly(api: CardsApi, fileIds: string[]): Promise { await Promise.allSettled(fileIds.map((id) => api.deleteFile(id))); } +/** + * Der Server hat eine Anfrage eindeutig abgelehnt (4xx außer Zeitüberschreitung 408 und Drosselung 429): es wurde nichts + * geschrieben. Netzfehler, 5xx und alles ohne Statuscode lassen offen, ob die Zeile doch angekommen ist. + */ +function isDefinitiveFailure(err: unknown): boolean { + const code = typeof err === 'object' && err !== null ? (err as { code?: unknown }).code : undefined; + return typeof code === 'number' && code >= 400 && code < 500 && code !== 408 && code !== 429; +} + export function createCardsService(api: CardsApi, userId: () => string) { async function save(input: SaveInput): Promise { // Ohne Nutzer wirft das hier, bevor etwas hochgeladen wird. @@ -109,25 +118,36 @@ export function createCardsService(api: CardsApi, userId: () => string) { let row: CardRow; // Die Zeile gab es nicht mehr (anderer Tab, anderes Konto): dann wurde eine neue Karte angelegt, die alten Dateien sind Müll. let replacedMissingCard = false; + // Scheitert ein Upload, hängt noch keine Zeile an den Dateien. Beim Schreiben der Zeile ist der Ausgang bei + // Netzfehler/5xx dagegen offen (sie kann angekommen sein): dann bleiben die Uploads lieber liegen, als dass + // eine Zeile auf gelöschte Dateien zeigt. + let writingRow = false; try { - const imageFileId = imageFile ? await upload(imageFile) : (prev as SavedCardRef).imageFileId; + const newImageId = imageFile ? await upload(imageFile) : null; const previewFileId = await upload(previewFile); - const data: CardRowData = { name, config, imageFileId, previewFileId }; + writingRow = true; if (!prev) { - row = await api.createRow(data, permissions); + // Ohne vorherige Karte wird immer ein Bild hochgeladen (uploadImage). + row = await api.createRow({ name, config, imageFileId: newImageId as string, previewFileId }, permissions); } else { + // Ohne Bildwechsel steht kein imageFileId im Update: der Schnappschuss des Editors kann veraltet sein + // (Bild in einem anderen Tab ersetzt) und würde die Zeile auf eine gelöschte Datei zurücksetzen. + const update: Partial = { name, config, previewFileId }; + if (newImageId) update.imageFileId = newImageId; try { - row = await api.updateRow(prev.id, data); + row = await api.updateRow(prev.id, update); } catch (err) { if (errorType(err) !== 'row_not_found') throw err; replacedMissingCard = true; - // Ohne Bildwechsel zeigt `data` auf die Datei der verschwundenen Karte; die neue Karte bekommt ein eigenes Bild. - const ownImageId = imageFile ? imageFileId : await upload(toUploadFile(input.image, 'image')); - row = await api.createRow({ ...data, imageFileId: ownImageId }, permissions); + // Ohne Bildwechsel gibt es noch keine Bilddatei der neuen Karte; die der verschwundenen gehört ihr nicht. + writingRow = false; + const ownImageId = newImageId ?? (await upload(toUploadFile(input.image, 'image'))); + writingRow = true; + row = await api.createRow({ name, config, imageFileId: ownImageId, previewFileId }, permissions); } } } catch (err) { - await deleteQuietly(api, uploaded); + if (!writingRow || isDefinitiveFailure(err)) await deleteQuietly(api, uploaded); throw err; } diff --git a/web/src/account/with-login.test.ts b/web/src/account/with-login.test.ts index 3975fa8..5c768c6 100644 --- a/web/src/account/with-login.test.ts +++ b/web/src/account/with-login.test.ts @@ -3,6 +3,11 @@ import { LoginCancelled, withLogin } from './with-login'; const unauthorized = () => Object.assign(new Error('Unauthorized'), { code: 401, type: 'user_unauthorized' }); +// Appwrite antwortet Gästen auf Lesezugriffe mit 404, nicht mit 401 (Zeilen- und Dateirechte). +const rowNotFound = () => Object.assign(new Error('Row not found'), { code: 404, type: 'row_not_found' }); +const fileNotFound = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' }); +const user = { $id: 'u1', email: 'a@b.de' }; + describe('withLogin', () => { it('gibt das Ergebnis zurück, ohne den Login-Dialog zu öffnen', async () => { const fn = vi.fn().mockResolvedValue('ok'); @@ -76,4 +81,78 @@ describe('withLogin', () => { await expect(withLogin(fn, requireLogin, refreshUser)).resolves.toBe('ok'); expect(requireLogin).toHaveBeenCalledOnce(); }); + + describe('404 für Gäste (row_not_found / storage_file_not_found)', () => { + it.each([ + ['row_not_found', rowNotFound], + ['storage_file_not_found', fileNotFound], + ])('%s ohne Sitzung: wie eine abgelaufene Sitzung behandeln (anmelden, einmal wiederholen)', async (_name, make) => { + const order: string[] = []; + const fn = vi + .fn() + .mockImplementationOnce(async () => { + order.push('fn1'); + throw make(); + }) + .mockImplementationOnce(async () => { + order.push('fn2'); + return 'ergebnis'; + }); + const refreshUser = vi.fn(async () => { + order.push('refresh'); + return null; + }); + const requireLogin = vi.fn(async () => { + order.push('login'); + return true; + }); + await expect(withLogin(fn, requireLogin, refreshUser)).resolves.toBe('ergebnis'); + expect(order).toEqual(['fn1', 'refresh', 'login', 'fn2']); + expect(refreshUser).toHaveBeenCalledOnce(); + }); + + it('ist der Nutzer noch angemeldet, ist es ein echtes „nicht gefunden“ und wird durchgereicht', async () => { + const notFound = rowNotFound(); + const fn = vi.fn().mockRejectedValue(notFound); + const requireLogin = vi.fn(); + const refreshUser = vi.fn().mockResolvedValue(user); + await expect(withLogin(fn, requireLogin, refreshUser)).rejects.toBe(notFound); + expect(fn).toHaveBeenCalledOnce(); + expect(requireLogin).not.toHaveBeenCalled(); + }); + + it('scheitert die Nutzerprüfung (Netz), wird das ursprüngliche 404 durchgereicht', async () => { + const notFound = fileNotFound(); + const fn = vi.fn().mockRejectedValue(notFound); + const requireLogin = vi.fn(); + const refreshUser = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); + await expect(withLogin(fn, requireLogin, refreshUser)).rejects.toBe(notFound); + expect(requireLogin).not.toHaveBeenCalled(); + }); + + it('Abbruch im Dialog: LoginCancelled, fn wird nicht erneut aufgerufen', async () => { + const fn = vi.fn().mockRejectedValue(rowNotFound()); + const requireLogin = vi.fn().mockResolvedValue(false); + const err = await withLogin(fn, requireLogin, vi.fn().mockResolvedValue(null)).catch((e: unknown) => e); + expect(err).toBeInstanceOf(LoginCancelled); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('ein zweites 404 nach der Anmeldung wird durchgereicht (kein dritter Versuch)', async () => { + const second = rowNotFound(); + const fn = vi.fn().mockRejectedValueOnce(rowNotFound()).mockRejectedValueOnce(second); + const requireLogin = vi.fn().mockResolvedValue(true); + await expect(withLogin(fn, requireLogin, vi.fn().mockResolvedValue(null))).rejects.toBe(second); + expect(fn).toHaveBeenCalledTimes(2); + expect(requireLogin).toHaveBeenCalledOnce(); + }); + + it('ein anderes 404 (z. B. falscher Pfad) löst keinen Dialog aus', async () => { + const other = Object.assign(new Error('Route not found'), { code: 404, type: 'general_route_not_found' }); + const fn = vi.fn().mockRejectedValue(other); + const refreshUser = vi.fn(); + await expect(withLogin(fn, vi.fn(), refreshUser)).rejects.toBe(other); + expect(refreshUser).not.toHaveBeenCalled(); + }); + }); }); diff --git a/web/src/account/with-login.ts b/web/src/account/with-login.ts index 6b3986c..44009c8 100644 --- a/web/src/account/with-login.ts +++ b/web/src/account/with-login.ts @@ -1,6 +1,6 @@ -// Führt eine Kartenanfrage aus und meldet bei abgelaufener Sitzung (401) einmal neu an. +// Führt eine Kartenanfrage aus und meldet bei abgelaufener Sitzung (401, oder 404 für Gäste) einmal neu an. import { getCurrentUser } from './auth'; -import { isUnauthorized } from './errors'; +import { errorType, isUnauthorized } from './errors'; import { requireLogin } from './login-dialog'; /** Der Nutzer hat den Login-Dialog geschlossen, ohne sich anzumelden. */ @@ -11,9 +11,20 @@ export class LoginCancelled extends Error { } } +/** + * Appwrite beantwortet Lesezugriffe von Gästen auf Zeilen und Dateien mit Zeilen-/Dateirechten nicht mit 401, + * sondern mit 404 (die Daten sind für sie „nicht vorhanden“). + */ +function isMissingForGuest(err: unknown): boolean { + const type = errorType(err); + return type === 'row_not_found' || type === 'storage_file_not_found'; +} + /** * Ruft `fn` auf. Antwortet der Server mit 401, wird der Anmeldestatus neu geholt (damit Konto-Menü und * Nutzer-ID nicht veraltet sind), einmal der Login-Dialog gezeigt und `fn` danach genau einmal wiederholt. + * Dasselbe gilt für „Zeile/Datei nicht gefunden“, wenn die Prüfung ergibt, dass gar keine Sitzung mehr besteht; + * ist der Nutzer noch angemeldet (oder die Prüfung scheitert), ist es ein echtes „nicht gefunden“ und geht durch. * Schließt der Nutzer den Dialog, wirft das `LoginCancelled`; jeder andere Fehler geht unverändert durch. */ export async function withLogin( @@ -24,12 +35,23 @@ export async function withLogin( try { return await fn(); } catch (err) { - if (!isUnauthorized(err)) throw err; - } - try { - await refreshUser(); - } catch { - // Ein Netzfehler hier darf die Anmeldung nicht verhindern; der Login-Dialog setzt den Nutzer ohnehin neu. + if (isUnauthorized(err)) { + try { + await refreshUser(); + } catch { + // Ein Netzfehler hier darf die Anmeldung nicht verhindern; der Login-Dialog setzt den Nutzer ohnehin neu. + } + } else if (isMissingForGuest(err)) { + let user: unknown; + try { + user = await refreshUser(); + } catch { + throw err; // ohne Gewissheit über die Sitzung bleibt es beim ursprünglichen Fehler + } + if (user) throw err; + } else { + throw err; + } } if (!(await requireLoginFn())) throw new LoginCancelled(); return fn(); diff --git a/web/src/editor/editor-page.test.ts b/web/src/editor/editor-page.test.ts index 184a29e..2bfe55b 100644 --- a/web/src/editor/editor-page.test.ts +++ b/web/src/editor/editor-page.test.ts @@ -93,6 +93,7 @@ const fileInput = () => root.querySelector('input[type="file"] const errorText = () => root.querySelector('.msg-error')!.textContent; const warningText = () => root.querySelector('.msg-warning')!.textContent; const noticeText = () => root.querySelector('.msg-notice')!.textContent; +const discardDialogEl = () => document.querySelector('dialog.confirm-dialog'); /** Der Editor mit einem gewählten Bild, bereit zum Speichern. */ function withImage(blob = png(), img = image()): { blob: Blob; img: CardImage } { @@ -365,9 +366,9 @@ describe('Karte öffnen (/?karte=)', () => { expect(mocks.decodeImage).not.toHaveBeenCalled(); }); - it('Karte nicht gefunden: Meldung und leerer Editor', async () => { + it('Karte nicht gefunden (angemeldet): Meldung, ein leerer Editor wird zurückgesetzt', async () => { open('gibtsnicht'); - store.set({ image: image(), imageBlob: png(), cardName: 'Altlast' }); + store.set({ config: { ...store.get().config, template: 'minimal' }, cardName: 'Altlast' }); mocks.get.mockRejectedValue(Object.assign(new Error('nope'), { code: 404, type: 'row_not_found' })); mount(); await vi.waitFor(() => @@ -375,28 +376,230 @@ describe('Karte öffnen (/?karte=)', () => { ); expect(store.get()).toEqual(initialEditorState()); expect(noticeText()).toBe(''); + expect(mocks.requireLogin).not.toHaveBeenCalled(); }); - it('Anmeldung abgebrochen: Hinweis, Editor unverändert', async () => { - open('k1'); - mocks.get.mockRejectedValue(Object.assign(new Error('Unauthorized'), { code: 401 })); - mocks.requireLogin.mockResolvedValue(false); + it('Karte nicht gefunden (angemeldet) bei gefülltem Editor: nur die Meldung, der Editor bleibt', async () => { + open('gibtsnicht'); + const { blob, img } = withImage(); + store.set({ cardName: 'Entwurf' }); + const before = store.get(); + mocks.get.mockRejectedValue(Object.assign(new Error('nope'), { code: 404, type: 'row_not_found' })); mount(); - await vi.waitFor(() => expect(warningText()).toBe('Melde dich an, um diese Karte zu öffnen.')); - expect(store.get().saved).toBeNull(); + await vi.waitFor(() => + expect(errorText()).toBe('Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.'), + ); + expect(store.get()).toBe(before); + expect(store.get().imageBlob).toBe(blob); + expect(store.get().image).toBe(img); + expect(store.get().cardName).toBe('Entwurf'); expect(noticeText()).toBe(''); + expect(discardDialogEl()).toBeNull(); }); - it('Sitzung abgelaufen, Anmeldung klappt: Karte wird danach geöffnet', async () => { - open('k1'); - mocks.get - .mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { code: 401 })) - .mockResolvedValueOnce(savedCard()); - mocks.loadImage.mockResolvedValue(png()); - mocks.decodeImage.mockResolvedValue(image()); + it('Karte nicht gefunden bei gefülltem Editor mit gespeicherter Karte: auch hier bleibt alles', async () => { + open('gibtsnicht'); + withImage(); + store.set({ + saved: { id: 'k9', name: 'Neun', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' }, + imageChanged: false, + }); + const before = store.get(); + mocks.get.mockRejectedValue(Object.assign(new Error('nope'), { code: 404, type: 'row_not_found' })); mount(); - await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); - expect(mocks.requireLogin).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(errorText()).not.toBe('')); + expect(store.get()).toBe(before); + }); + + describe('ohne Sitzung (Appwrite antwortet Gästen mit 404, nicht 401)', () => { + it('Gast: zuerst anmelden, dann erst laden; danach wird die Karte geöffnet', async () => { + open('k1'); + const order: string[] = []; + mocks.getCurrentUser.mockImplementation(async () => { + order.push('user'); + return null; + }); + mocks.requireLogin.mockImplementation(async () => { + order.push('login'); + return true; + }); + mocks.get.mockImplementation(async () => { + order.push('get'); + return savedCard(); + }); + mocks.loadImage.mockResolvedValue(png()); + mocks.decodeImage.mockResolvedValue(image()); + mount(); + await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); + expect(order).toEqual(['user', 'login', 'get']); + expect(mocks.requireLogin).toHaveBeenCalledWith('login'); + expect(errorText()).toBe(''); + }); + + it('Gast bricht den Dialog ab: Hinweis, nichts wird geladen, der Editor bleibt unberührt', async () => { + open('k1'); + const { blob } = withImage(); + const before = store.get(); + mocks.getCurrentUser.mockResolvedValue(null); + mocks.requireLogin.mockResolvedValue(false); + mount(); + await vi.waitFor(() => expect(warningText()).toBe('Melde dich an, um diese Karte zu öffnen.')); + expect(mocks.get).not.toHaveBeenCalled(); + expect(store.get()).toBe(before); + expect(store.get().imageBlob).toBe(blob); + expect(noticeText()).toBe(''); + expect(errorText()).toBe(''); + }); + + it('Sitzung läuft mitten im Laden ab (404 row_not_found, Nutzerprüfung ergibt null): anmelden und einmal wiederholen', async () => { + open('k1'); + mocks.getCurrentUser.mockResolvedValueOnce(user).mockResolvedValue(null); + mocks.get + .mockRejectedValueOnce(Object.assign(new Error('Row not found'), { code: 404, type: 'row_not_found' })) + .mockResolvedValueOnce(savedCard()); + mocks.loadImage.mockResolvedValue(png()); + mocks.decodeImage.mockResolvedValue(image()); + mount(); + await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); + expect(mocks.requireLogin).toHaveBeenCalledTimes(1); + expect(mocks.get).toHaveBeenCalledTimes(2); + }); + + it('Bilddatei 404 ohne Sitzung: anmelden, das Bild wird danach geladen', async () => { + open('k1'); + mocks.get.mockResolvedValue(savedCard()); + mocks.getCurrentUser.mockResolvedValueOnce(user).mockResolvedValue(null); + mocks.loadImage + .mockRejectedValueOnce(Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' })) + .mockResolvedValueOnce(png()); + mocks.decodeImage.mockResolvedValue(image()); + mount(); + await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); + expect(mocks.requireLogin).toHaveBeenCalledTimes(1); + expect(store.get().image).not.toBeNull(); + expect(warningText()).toBe(''); + }); + + it('angemeldet und Bilddatei wirklich weg: die Karte öffnet ohne Bild, kein Dialog', async () => { + open('k1'); + mocks.get.mockResolvedValue(savedCard()); + mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' })); + mount(); + await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); + expect(mocks.requireLogin).not.toHaveBeenCalled(); + expect(warningText()).toBe('Das Bild dieser Karte fehlt.'); + }); + }); + + describe('ungespeicherte Änderungen im Editor', () => { + const dialogButton = (label: string) => + [...discardDialogEl()!.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement; + const loadable = () => { + mocks.get.mockResolvedValue(savedCard()); + mocks.loadImage.mockResolvedValue(png()); + mocks.decodeImage.mockResolvedValue(image(1200, 800)); + }; + + it('ein neues, nie gespeichertes Bild: Rückfrage; „Verwerfen“ übernimmt die geöffnete Karte', async () => { + open('k1'); + loadable(); + withImage(); + mount(); + await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true)); + expect(discardDialogEl()!.querySelector('h2')!.textContent).toBe('Ungespeicherte Änderungen verwerfen?'); + // Solange gefragt wird, bleibt der Entwurf. + expect(store.get().saved).toBeNull(); + expect(store.get().imageChanged).toBe(true); + dialogButton('Verwerfen').click(); + await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); + expect(store.get().imageChanged).toBe(false); + expect(store.get().cardName).toBe('Urlaub'); + expect(discardDialogEl()).toBeNull(); + expect(noticeText()).toBe(''); + }); + + it('„Abbrechen“: Entwurf bleibt, ?karte= verschwindet aus der Adresse, die Karte wird nicht übernommen', async () => { + open('k1'); + loadable(); + const { blob, img } = withImage(); + const before = store.get(); + mount(); + await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true)); + dialogButton('Abbrechen').click(); + await vi.waitFor(() => expect(discardDialogEl()).toBeNull()); + await vi.waitFor(() => expect(noticeText()).toBe('')); + expect(store.get()).toBe(before); + expect(store.get().imageBlob).toBe(blob); + expect(store.get().image).toBe(img); + expect(location.pathname + location.search).toBe('/'); + }); + + it('Esc im Dialog verwirft nichts', async () => { + open('k1'); + loadable(); + withImage(); + const before = store.get(); + mount(); + await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true)); + discardDialogEl()!.dispatchEvent(new Event('close')); + await vi.waitFor(() => expect(discardDialogEl()).toBeNull()); + await vi.waitFor(() => expect(location.search).toBe('')); + expect(store.get()).toBe(before); + }); + + it('geänderte Konfiguration einer anderen gespeicherten Karte: ebenfalls Rückfrage', async () => { + open('k1'); + loadable(); + withImage(); + const config = store.get().config; + store.set({ + saved: { id: 'k7', name: 'Sieben', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' }, + savedConfig: config, + imageChanged: false, + config: { ...config, template: 'minimal' }, + }); + mount(); + await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true)); + dialogButton('Verwerfen').click(); + await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); + }); + + it('eine andere gespeicherte Karte ohne Änderungen wird ohne Rückfrage ersetzt', async () => { + open('k1'); + loadable(); + withImage(); + const config = store.get().config; + store.set({ + saved: { id: 'k7', name: 'Sieben', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' }, + savedConfig: config, + imageChanged: false, + }); + mount(); + await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); + expect(discardDialogEl()).toBeNull(); + }); + + it('ein leerer Editor wird ohne Rückfrage befüllt', async () => { + open('k1'); + loadable(); + mount(); + await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); + expect(discardDialogEl()).toBeNull(); + }); + + it('verlässt der Nutzer die Seite, während gefragt wird, schließt der Dialog und nichts wird übernommen', async () => { + open('k1'); + loadable(); + withImage(); + const before = store.get(); + mount(); + await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true)); + unmount?.(); + unmount = null; + await vi.waitFor(() => expect(discardDialogEl()).toBeNull()); + await new Promise((r) => setTimeout(r, 0)); + expect(store.get()).toBe(before); + }); }); it('anderer Fehler (Netz): Meldung, Editor unverändert', async () => { diff --git a/web/src/editor/editor-page.ts b/web/src/editor/editor-page.ts index ecf6d0e..a623a1f 100644 --- a/web/src/editor/editor-page.ts +++ b/web/src/editor/editor-page.ts @@ -51,8 +51,14 @@ function hasUnsavedChanges(s: EditorState): boolean { return s.savedConfig !== null && serializeConfig(s.config) !== serializeConfig(s.savedConfig); } +/** Hält der Editor ein Bild mit Änderungen, die ein Öffnen einer anderen Karte verlieren würde? */ +function hasUnsavedWork(s: EditorState): boolean { + return s.image !== null && hasUnsavedChanges(s); +} + export function mountEditor(root: HTMLElement, store: EditorStore): () => void { let disposed = false; + const disposal = new AbortController(); // schließt eine offene Rückfrage beim Verlassen der Seite const patchConfig = (patch: Partial) => store.set({ config: { ...store.get().config, ...patch } }); @@ -213,6 +219,12 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { preview.setWarning(null); preview.setNotice(LOADING_CARD); try { + // Appwrite beantwortet Gäste mit 404 statt 401: ohne Sitzung zuerst anmelden, statt „nicht gefunden“ zu melden. + if (!(await getCurrentUser())) { + if (stale()) return; + if (!(await requireLogin('login'))) throw new LoginCancelled(); + if (stale()) return; + } const card = await withLogin(() => service.get(id)); if (stale()) return; @@ -229,6 +241,16 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { const image: CardImage | null = blob ? await decodeImage(blob) : null; if (stale()) return; + // Die geladene Karte würde einen ungespeicherten Entwurf ersetzen: erst fragen (wie bei „Neue Karte“). + if (hasUnsavedWork(store.get())) { + const discard = await askToDiscard(); + if (stale()) return; + if (!discard) { + dropCardParam(); + return; + } + } + const config = image ? { ...card.config, crop: clampCrop(image, card.config.crop) } : card.config; store.set({ config, @@ -245,8 +267,9 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { if (err instanceof LoginCancelled) { preview.setWarning(LOGIN_TO_OPEN); } else if (errorType(err) === 'row_not_found') { - // Die Karte gibt es nicht (für dieses Konto): kein halb übernommener Zustand, sondern ein leerer Editor. - store.set(initialEditorState()); + // Die Karte gibt es nicht (für dieses Konto). Ein leerer Editor wird zurückgesetzt; hält er Arbeit, bleibt sie. + const s = store.get(); + if (s.image === null && s.saved === null) store.set(initialEditorState()); preview.setError(errorMessage(err, 'load')); } else { preview.setError(err instanceof Error && err.message === READ_MESSAGE ? READ_MESSAGE : errorMessage(err, 'load')); @@ -256,24 +279,32 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { } } - // „Neue Karte“: löst den Editor von der gespeicherten Karte, damit ein anderes Bild sie nicht überschreibt. + // Rückfrage vor dem Verwerfen von Änderungen („Neue Karte“, Öffnen einer anderen Karte). let askingToDiscard = false; - async function startNewCard(): Promise { - if (askingToDiscard || store.get().saving || !store.get().saved) return; - if (hasUnsavedChanges(store.get())) { - askingToDiscard = true; - let discard = false; - try { - discard = await confirmDialog({ + async function askToDiscard(): Promise { + askingToDiscard = true; + try { + return await confirmDialog( + { title: 'Ungespeicherte Änderungen verwerfen?', text: 'Alles, was du seit dem letzten Speichern geändert hast, geht verloren.', confirmLabel: 'Verwerfen', - }); - } catch { - // Dialog nicht darstellbar: dann lieber nichts verwerfen. - } finally { - askingToDiscard = false; - } + }, + disposal.signal, + ); + } catch { + // Dialog nicht darstellbar: dann lieber nichts verwerfen. + return false; + } finally { + askingToDiscard = false; + } + } + + // „Neue Karte“: löst den Editor von der gespeicherten Karte, damit ein anderes Bild sie nicht überschreibt. + async function startNewCard(): Promise { + if (askingToDiscard || store.get().saving || !store.get().saved) return; + if (hasUnsavedChanges(store.get())) { + const discard = await askToDiscard(); // Währenddessen kann gespeichert oder die Seite verlassen worden sein. if (!discard || disposed || store.get().saving) return; } @@ -371,6 +402,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { return () => { disposed = true; + disposal.abort(); clearTimeout(flashTimer); offLogout(); unsubscribe(); diff --git a/web/src/pages/cards-page.test.ts b/web/src/pages/cards-page.test.ts index 0f1751c..9c1df73 100644 --- a/web/src/pages/cards-page.test.ts +++ b/web/src/pages/cards-page.test.ts @@ -279,6 +279,75 @@ describe('Meine Karten: Öffnen und Herunterladen', () => { }); }); +describe('Meine Karten: Sitzung weg, Appwrite antwortet mit 404 statt 401', () => { + const fileGone = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' }); + const rowGone = () => Object.assign(new Error('Row not found'), { code: 404, type: 'row_not_found' }); + /** Beim Laden der Seite ist der Nutzer noch da; danach (Nutzerprüfung nach dem 404) nicht mehr. */ + const sessionEndsAfterLoad = () => mocks.getCurrentUser.mockResolvedValueOnce(user).mockResolvedValue(null); + + it('Vorschau: erst anmelden, dann wird die Vorschau geladen (statt „Keine Vorschau“)', async () => { + sessionEndsAfterLoad(); + mocks.list.mockResolvedValue([card('a', 'Alpha')]); + mocks.loadPreview.mockReset(); + mocks.loadPreview + .mockRejectedValueOnce(fileGone()) + .mockImplementation(async () => new Blob(['p'], { type: 'image/webp' })); + mount(); + await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull()); + expect(mocks.requireLogin).toHaveBeenCalledOnce(); + expect(mocks.loadPreview).toHaveBeenCalledTimes(2); + }); + + it('Herunterladen: erst anmelden, dann wird das Bild geladen (statt „Das Bild dieser Karte fehlt.“)', async () => { + const png = new Blob(['png'], { type: 'image/png' }); + const image = { source: {}, w: 3000, h: 2000 }; + sessionEndsAfterLoad(); + mocks.list.mockResolvedValue([card('a', 'Alpha')]); + mocks.loadImage.mockRejectedValueOnce(fileGone()).mockResolvedValueOnce(new Blob(['i'], { type: 'image/jpeg' })); + mocks.decodeImage.mockResolvedValue(image); + mocks.loadCardAssets.mockResolvedValue({ image, network: null, brand: null }); + mocks.exportCard.mockResolvedValue(png); + mount(); + await vi.waitFor(() => expect(tiles()).toHaveLength(1)); + button(tileFor('a'), 'Herunterladen').click(); + await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-alpha.png')); + expect(mocks.requireLogin).toHaveBeenCalledOnce(); + expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe(''); + }); + + it('Löschen: die Karte gilt nicht als „schon gelöscht“, sondern wird nach dem Anmelden wirklich gelöscht', async () => { + sessionEndsAfterLoad(); + mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); + mocks.remove.mockRejectedValueOnce(rowGone()).mockResolvedValueOnce(undefined); + mount(); + await vi.waitFor(() => expect(tiles()).toHaveLength(2)); + await vi.waitFor(() => expect(button(tileFor('b'), 'Löschen').disabled).toBe(false)); + button(tileFor('b'), 'Löschen').click(); + await vi.waitFor(() => expect(dialog()?.open).toBe(true)); + button(dialog()!, 'Löschen').click(); + await vi.waitFor(() => expect(tiles()).toHaveLength(1)); + expect(mocks.requireLogin).toHaveBeenCalledOnce(); + expect(mocks.remove).toHaveBeenCalledTimes(2); + }); + + it('Löschen, Anmeldung abgebrochen: die Karte bleibt, Hinweis an der Kachel', async () => { + sessionEndsAfterLoad(); + mocks.requireLogin.mockResolvedValue(false); + mocks.list.mockResolvedValue([card('a', 'Alpha')]); + mocks.remove.mockRejectedValue(rowGone()); + mount(); + await vi.waitFor(() => expect(tiles()).toHaveLength(1)); + await vi.waitFor(() => expect(button(tileFor('a'), 'Löschen').disabled).toBe(false)); + button(tileFor('a'), 'Löschen').click(); + await vi.waitFor(() => expect(dialog()?.open).toBe(true)); + button(dialog()!, 'Löschen').click(); + await vi.waitFor(() => + expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Bitte melde dich an, um fortzufahren.'), + ); + expect(tiles()).toHaveLength(1); + }); +}); + describe('Meine Karten: Umbenennen', () => { const startRename = async () => { mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);