feat(cards): Karten speichern und Meine Karten

- account/cards.ts: Kartendienst (neu/bestehend speichern mit Aufräumen halber
  Uploads, Umbenennen, Löschen, Laden) über ein schmales CardsApi-Interface,
  Appwrite-Anschluss mit 22.4.1-Objektparametern, fetchFile über
  client.prepareRequest (Projekt-Header + Cookie, kein getFileView)
- account/with-login.ts: bei 401 Nutzer neu prüfen, einmal anmelden, wiederholen
- editor/save.ts: Speichern-Ablauf (ein Lauf zur Zeit, 401 -> Login -> einmal
  wiederholen, deutsche Fehlermeldung)
- Editor: Speichern mit Schnappschuss und 480-px-Vorschau, Karte öffnen über
  ?karte=<id>, Bildwechsel während des Speicherns gesperrt
- pages/cards-page.ts: /karten mit Kacheln, Öffnen, Herunterladen, Umbenennen,
  Löschen (eigener Dialog), Abmelde- und Leer-Ansicht
- Tests für Dienst, withLogin, Speichern-Ablauf, Karten-Seite und Editor-Anbindung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 09:18:27 +00:00
1 parent a147d3b496
commit ba4d9f76d4
14 files changed
+2849 -16

No files matched your search

+456
View File
@@ -0,0 +1,456 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { User } from '../account/auth';
import type { SavedCard } from '../account/cards';
import { DEFAULT_CONFIG } from '../card/config';
import { createStore, initialEditorState } from '../editor/state';
const mocks = vi.hoisted(() => ({
list: vi.fn(),
loadPreview: vi.fn(),
loadImage: vi.fn(),
rename: vi.fn(),
remove: vi.fn(),
getCurrentUser: vi.fn(),
requireLogin: vi.fn(),
navigate: vi.fn(),
decodeImage: vi.fn(),
loadCardAssets: vi.fn(),
exportCard: vi.fn(),
saveBlobAs: vi.fn(),
authListeners: new Set<(user: User | null) => void>(),
}));
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({
getCurrentUser: mocks.getCurrentUser,
currentUserId: () => 'u1',
onAuthChange: (fn: (user: User | null) => void) => {
mocks.authListeners.add(fn);
return () => mocks.authListeners.delete(fn);
},
}));
vi.mock('../account/login-dialog', () => ({ requireLogin: mocks.requireLogin }));
vi.mock('./router', () => ({ navigate: mocks.navigate }));
vi.mock('../editor/upload', () => ({ READ_MESSAGE: 'Das Bild konnte nicht gelesen werden.', decodeImage: mocks.decodeImage }));
vi.mock('../card/render', () => ({ loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard }));
vi.mock('../editor/download', async (importOriginal) => ({
...(await importOriginal<typeof import('../editor/download')>()),
saveBlobAs: mocks.saveBlobAs,
}));
vi.mock('../account/cards', async (importOriginal) => ({
...(await importOriginal<typeof import('../account/cards')>()),
cardsService: () => ({
list: mocks.list,
loadPreview: mocks.loadPreview,
loadImage: mocks.loadImage,
rename: mocks.rename,
remove: mocks.remove,
}),
}));
import { formatDate, mountCardsPage } from './cards-page';
const user: User = { $id: 'u1', email: 'a@b.de' };
const card = (id: string, name: string, patch: Partial<SavedCard> = {}): SavedCard => ({
id,
name,
imageFileId: `img-${id}`,
previewFileId: `prev-${id}`,
updatedAt: '2026-10-04T12:00:00.000Z',
config: structuredClone(DEFAULT_CONFIG),
...patch,
});
let root: HTMLElement;
let unmount: (() => void) | null;
let store: ReturnType<typeof createStore>;
let urlCounter: number;
const revoked: string[] = [];
beforeEach(() => {
for (const fn of Object.values(mocks)) if (typeof fn === 'function' && 'mockReset' in fn) fn.mockReset();
mocks.authListeners.clear();
revoked.length = 0;
urlCounter = 0;
URL.createObjectURL = vi.fn(() => `blob:preview-${++urlCounter}`);
URL.revokeObjectURL = vi.fn((url: string) => {
revoked.push(url);
});
document.body.innerHTML = '<main id="app-root"></main>';
root = document.getElementById('app-root') as HTMLElement;
store = createStore(initialEditorState());
mocks.getCurrentUser.mockResolvedValue(user);
mocks.requireLogin.mockResolvedValue(true);
mocks.loadPreview.mockImplementation(async () => new Blob(['p'], { type: 'image/webp' }));
unmount = null;
});
afterEach(() => {
unmount?.();
});
const mount = () => {
unmount = mountCardsPage(root, store);
};
const text = () => root.textContent ?? '';
const tiles = () => [...root.querySelectorAll<HTMLElement>('.card-tile')];
const tileFor = (id: string) => tiles().find((t) => t.dataset.card === id)!;
const button = (scope: ParentNode, label: string) =>
[...scope.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement;
const dialog = () => document.querySelector<HTMLDialogElement>('dialog.confirm-dialog');
const submit = (form: HTMLFormElement) => form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
describe('formatDate', () => {
it('schreibt dd.mm.yyyy', () => {
expect(formatDate(new Date(2026, 9, 4, 12).toISOString())).toBe('04.10.2026');
expect(formatDate(new Date(2026, 0, 31, 12).toISOString())).toBe('31.01.2026');
});
it('gibt bei unlesbarem Datum nichts zurück', () => {
expect(formatDate('kaputt')).toBe('');
});
});
describe('Meine Karten: Zustände', () => {
it('abgemeldet: Hinweis und Anmelden-Knopf, keine Anfrage an den Server', async () => {
mocks.getCurrentUser.mockResolvedValue(null);
mount();
await vi.waitFor(() => expect(text()).toContain('Melde dich an, um deine Karten zu sehen.'));
expect(mocks.list).not.toHaveBeenCalled();
button(root, 'Anmelden').click();
expect(mocks.requireLogin).toHaveBeenCalledWith('login');
});
it('nach dem Anmelden (Konto-Menü oder Knopf) wird die Liste geladen', async () => {
mocks.getCurrentUser.mockResolvedValue(null);
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mount();
await vi.waitFor(() => expect(text()).toContain('Melde dich an'));
mocks.getCurrentUser.mockResolvedValue(user);
for (const fn of mocks.authListeners) fn(user);
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
});
it('leer: Hinweis und Link zum Editor', async () => {
mocks.list.mockResolvedValue([]);
mount();
await vi.waitFor(() => expect(text()).toContain('Noch keine Karten gespeichert.'));
const link = root.querySelector<HTMLAnchorElement>('a[data-link]')!;
expect(link.getAttribute('href')).toBe('/');
});
it('Fehler beim Laden: Meldung und „Nochmal versuchen“', async () => {
mocks.list.mockRejectedValueOnce(new TypeError('offline')).mockResolvedValueOnce([card('a', 'Alpha')]);
mount();
await vi.waitFor(() => expect(text()).toContain('Keine Verbindung zum Server.'));
button(root, 'Nochmal versuchen').click();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
});
it('Sitzung läuft beim Laden ab: Anmelden, dann wird die Liste angezeigt', async () => {
const unauthorized = Object.assign(new Error('Unauthorized'), { code: 401 });
mocks.list.mockRejectedValueOnce(unauthorized).mockResolvedValueOnce([card('a', 'Alpha')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
expect(mocks.requireLogin).toHaveBeenCalledOnce();
});
it('Sitzung läuft ab und der Dialog wird geschlossen: Anmelden-Ansicht', async () => {
mocks.list.mockRejectedValue(Object.assign(new Error('Unauthorized'), { code: 401 }));
mocks.requireLogin.mockResolvedValue(false);
mount();
await vi.waitFor(() => expect(text()).toContain('Melde dich an, um deine Karten zu sehen.'));
});
it('Abmelden räumt die Liste weg und gibt die Vorschau-URLs frei', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mount();
await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull());
for (const fn of mocks.authListeners) fn(null);
expect(text()).toContain('Melde dich an, um deine Karten zu sehen.');
expect(tiles()).toHaveLength(0);
expect(revoked).toEqual(['blob:preview-1']);
});
});
describe('Meine Karten: Kacheln', () => {
it('zeigt Name, Datum und Vorschau', async () => {
const updatedAt = new Date(2026, 9, 4, 12).toISOString();
mocks.list.mockResolvedValue([card('a', 'Alpha', { updatedAt }), card('b', 'Beta')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
expect(tileFor('a').querySelector('.card-name')!.textContent).toBe('Alpha');
expect(tileFor('a').querySelector('.card-date')!.textContent).toBe('04.10.2026');
await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull());
await vi.waitFor(() => expect(tileFor('b').querySelector('img')).not.toBeNull());
expect(tileFor('a').querySelector('img')!.getAttribute('src')).toMatch(/^blob:preview-/);
expect(mocks.loadPreview).toHaveBeenCalledTimes(2);
});
it('eine Vorschau, die nicht lädt, zeigt „Keine Vorschau“, die Kachel bleibt bedienbar', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.loadPreview.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
mount();
await vi.waitFor(() => expect(tileFor('a').textContent).toContain('Keine Vorschau'));
expect(button(tileFor('a'), 'Öffnen').disabled).toBe(false);
});
it('lädt höchstens vier Vorschauen gleichzeitig', async () => {
const cards = Array.from({ length: 10 }, (_, i) => card(`c${i}`, `Karte ${i}`));
mocks.list.mockResolvedValue(cards);
let active = 0;
let peak = 0;
mocks.loadPreview.mockImplementation(async () => {
active += 1;
peak = Math.max(peak, active);
await new Promise((r) => setTimeout(r, 5));
active -= 1;
return new Blob(['p'], { type: 'image/webp' });
});
mount();
await vi.waitFor(() => expect(root.querySelectorAll('img')).toHaveLength(10));
expect(peak).toBeLessThanOrEqual(4);
expect(peak).toBeGreaterThan(1);
});
it('beim Verlassen der Seite werden alle Vorschau-URLs freigegeben', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mount();
await vi.waitFor(() => expect(root.querySelectorAll('img')).toHaveLength(2));
unmount?.();
unmount = null;
expect([...revoked].sort()).toEqual(['blob:preview-1', 'blob:preview-2']);
});
it('eine Vorschau, die erst nach dem Verlassen ankommt, bekommt keine URL', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
let finish!: (b: Blob) => void;
mocks.loadPreview.mockReturnValue(new Promise<Blob>((r) => (finish = r)));
mount();
await vi.waitFor(() => expect(mocks.loadPreview).toHaveBeenCalled());
unmount?.();
unmount = null;
finish(new Blob(['p'], { type: 'image/webp' }));
await new Promise((r) => setTimeout(r, 0));
expect(URL.createObjectURL).not.toHaveBeenCalled();
});
});
describe('Meine Karten: Öffnen und Herunterladen', () => {
it('Öffnen navigiert in den Editor', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Öffnen').click();
expect(mocks.navigate).toHaveBeenCalledWith('/?karte=a');
});
it('Herunterladen: Bild laden, als PNG rendern und unter aircard-<name>.png speichern', async () => {
const png = new Blob(['png'], { type: 'image/png' });
const image = { source: {}, w: 3000, h: 2000 };
mocks.list.mockResolvedValue([card('a', 'Mein Urlaub', { config: { ...DEFAULT_CONFIG, crop: { x: 99999, y: 0, zoom: 1 } } })]);
mocks.loadImage.mockResolvedValue(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).toHaveBeenCalled());
expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-mein-urlaub.png');
expect(mocks.exportCard).toHaveBeenCalledWith(expect.anything(), expect.anything(), { width: 1536, type: 'image/png' });
// Der gespeicherte Ausschnitt wird auf das Bild begrenzt, bevor gerendert wird.
const rendered = mocks.exportCard.mock.calls[0][0];
expect(rendered.crop.x).toBeLessThan(99999);
await vi.waitFor(() => expect(button(tileFor('a'), 'Herunterladen').disabled).toBe(false));
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('');
});
it('Herunterladen ohne Bilddatei zeigt die Meldung an der Kachel', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Herunterladen').click();
await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Das Bild dieser Karte fehlt.'));
expect(mocks.saveBlobAs).not.toHaveBeenCalled();
expect(button(tileFor('a'), 'Herunterladen').disabled).toBe(false);
});
});
describe('Meine Karten: Umbenennen', () => {
const startRename = async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
button(tileFor('b'), 'Umbenennen').click();
return tileFor('b').querySelector<HTMLInputElement>('input')!;
};
it('zeigt eine Eingabe mit dem Namen, Enter speichert, die Karte steht danach vorn', async () => {
mocks.rename.mockImplementation(async (id: string, name: string) => card(id, name.trim(), { updatedAt: '2026-10-06T08:00:00.000Z' }));
const input = await startRename();
expect(input.value).toBe('Beta');
expect(document.activeElement).toBe(input);
input.value = ' Gamma ';
submit(input.closest('form')!);
await vi.waitFor(() => expect(tiles().map((t) => t.dataset.card)).toEqual(['b', 'a']));
expect(mocks.rename).toHaveBeenCalledWith('b', ' Gamma ');
expect(tileFor('b').querySelector('.card-name')!.textContent).toBe('Gamma');
expect(tileFor('b').querySelector('input')).toBeNull();
});
it('Esc bricht ab, ohne etwas zu speichern', async () => {
const input = await startRename();
input.value = 'Anders';
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
expect(tileFor('b').querySelector('input')).toBeNull();
expect(tileFor('b').querySelector('.card-name')!.textContent).toBe('Beta');
expect(mocks.rename).not.toHaveBeenCalled();
});
it('„Abbrechen“ bricht ab; ein unveränderter Name löst keine Anfrage aus', async () => {
const input = await startRename();
button(tileFor('b'), 'Abbrechen').click();
expect(tileFor('b').querySelector('input')).toBeNull();
button(tileFor('b'), 'Umbenennen').click();
const again = tileFor('b').querySelector<HTMLInputElement>('input')!;
again.value = ' Beta ';
submit(again.closest('form')!);
expect(tileFor('b').querySelector('input')).toBeNull();
expect(mocks.rename).not.toHaveBeenCalled();
expect(input).not.toBe(again);
});
it('ein Fehler lässt die Eingabe offen und zeigt die Meldung', async () => {
mocks.rename.mockRejectedValue(new TypeError('offline'));
const input = await startRename();
input.value = 'Gamma';
submit(input.closest('form')!);
await vi.waitFor(() => expect(tileFor('b').querySelector('.card-msg')!.textContent).toBe('Keine Verbindung zum Server.'));
const open = tileFor('b').querySelector<HTMLInputElement>('input')!;
expect(open.value).toBe('Gamma');
expect(open.readOnly).toBe(false);
});
it('hält die Karte im Editor-Zustand aktuell (sonst überschreibt Speichern den neuen Namen)', async () => {
store.set({
saved: { id: 'b', name: 'Beta', imageFileId: 'img-b', previewFileId: 'prev-b', updatedAt: '2026-10-04T12:00:00.000Z' },
cardName: 'Beta',
});
mocks.rename.mockImplementation(async (id: string, name: string) => card(id, name, { updatedAt: '2026-10-06T08:00:00.000Z' }));
const input = await startRename();
input.value = 'Gamma';
submit(input.closest('form')!);
await vi.waitFor(() => expect(store.get().cardName).toBe('Gamma'));
expect(store.get().saved).toMatchObject({ id: 'b', name: 'Gamma', updatedAt: '2026-10-06T08:00:00.000Z' });
});
});
describe('Meine Karten: Löschen', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
/** Klickt „Löschen“ an der Kachel (sobald der Knopf wieder bedienbar ist) und wartet auf den Dialog. */
const openDialog = async (id: string) => {
await vi.waitFor(() => expect(button(tileFor(id), 'Löschen').disabled).toBe(false));
button(tileFor(id), 'Löschen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
};
const start = async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(root.querySelectorAll('img')).toHaveLength(2));
};
it('fragt in einem eigenen Dialog nach und löscht erst nach „Löschen“', async () => {
mocks.remove.mockResolvedValue(undefined);
const confirm = vi.fn();
vi.stubGlobal('confirm', confirm);
await start();
await openDialog('b');
expect(dialog()!.querySelector('h2')!.textContent).toBe('Karte „Beta“ löschen?');
expect(mocks.remove).not.toHaveBeenCalled();
button(dialog()!, 'Löschen').click();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
expect(mocks.remove).toHaveBeenCalledWith(expect.objectContaining({ id: 'b', imageFileId: 'img-b', previewFileId: 'prev-b' }));
expect(tiles()[0].dataset.card).toBe('a');
expect(dialog()).toBeNull();
expect(confirm).not.toHaveBeenCalled();
expect(revoked).toHaveLength(1);
});
it('„Abbrechen“, Esc und Klick auf den Hintergrund löschen nichts', async () => {
await start();
await openDialog('b');
button(dialog()!, 'Abbrechen').click();
await vi.waitFor(() => expect(dialog()).toBeNull());
await openDialog('b');
dialog()!.dispatchEvent(new Event('close'));
await vi.waitFor(() => expect(dialog()).toBeNull());
await openDialog('b');
dialog()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await vi.waitFor(() => expect(dialog()).toBeNull());
expect(mocks.remove).not.toHaveBeenCalled();
expect(tiles()).toHaveLength(2);
await vi.waitFor(() => expect(button(tileFor('b'), 'Löschen').disabled).toBe(false));
});
it('ein Fehler beim Löschen lässt die Karte stehen und zeigt die Meldung', async () => {
mocks.remove.mockRejectedValue(new TypeError('offline'));
await start();
await openDialog('b');
button(dialog()!, 'Löschen').click();
await vi.waitFor(() => expect(tileFor('b').querySelector('.card-msg')!.textContent).toBe('Keine Verbindung zum Server.'));
expect(tiles()).toHaveLength(2);
expect(button(tileFor('b'), 'Löschen').disabled).toBe(false);
});
it('eine schon gelöschte Karte (row_not_found) verschwindet ohne Fehlermeldung', async () => {
mocks.remove.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'row_not_found' }));
await start();
await openDialog('b');
button(dialog()!, 'Löschen').click();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
});
it('die letzte Karte gelöscht: Leer-Ansicht', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.remove.mockResolvedValue(undefined);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
await openDialog('a');
button(dialog()!, 'Löschen').click();
await vi.waitFor(() => expect(text()).toContain('Noch keine Karten gespeichert.'));
});
it('die im Editor geöffnete Karte wird dort „ungespeichert“, damit Speichern eine neue anlegt', async () => {
store.set({
saved: { id: 'b', name: 'Beta', imageFileId: 'img-b', previewFileId: 'prev-b', updatedAt: '2026-10-04T12:00:00.000Z' },
});
mocks.remove.mockResolvedValue(undefined);
await start();
await openDialog('b');
button(dialog()!, 'Löschen').click();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
expect(store.get().saved).toBeNull();
});
it('eine andere Karte im Editor bleibt unberührt', async () => {
const saved = { id: 'a', name: 'Alpha', imageFileId: 'img-a', previewFileId: 'prev-a', updatedAt: '2026-10-04T12:00:00.000Z' };
store.set({ saved });
mocks.remove.mockResolvedValue(undefined);
await start();
await openDialog('b');
button(dialog()!, 'Löschen').click();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
expect(store.get().saved).toEqual(saved);
});
});