fix(cards): Sitzung, Entwurf und Datei-Aufräumen beim Öffnen und Speichern

- 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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 11:01:28 +00:00
1 parent 09de06e797
commit 0c51689dfd
7 files changed
+546 -57

No files matched your search

+220 -17
View File
@@ -93,6 +93,7 @@ const fileInput = () => root.querySelector<HTMLInputElement>('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<HTMLDialogElement>('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=<id>)', () => {
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=<id>)', () => {
);
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 () => {