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:
1 parent
09de06e797
commit
0c51689dfd
7 files changed
+546
-57
No files matched your search
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user