Files
AirCardIOS/web/src/pages/cards-page.test.ts
T
ClaudeandClaude Sonnet 5.5 f6b9ee61f4 feat(community): Veröffentlichen aus Meine Karten
Neuer Dialog (publish-dialog.ts) laut Spec § 6: Titel (Pflicht, höchstens 60), Herausgeber/Bank (40),
Tags (bis 5, je 20, Komma-getrennt), Anzeigename (30, leer = Anonym, nie vorbelegt) und die
Pflicht-Checkbox im Wortlaut der Spec. Prüfung vor dem Senden mit deutschen Texten, während des Sendens
lässt sich der Dialog nicht schließen; Fehler stehen im Dialog („Veröffentlichen fehlgeschlagen – …“).

Meine Karten zeigt je Kachel den Status aus myPublications (einmal je Seitenaufbau, danach lokal
nachgeführt): Veröffentlicht + Ansehen + Zurückziehen, Ausgeblendet (gemeldet) + Zurückziehen, sonst
Veröffentlichen. Bei 409 wird der Status neu geholt; bleibt der Ausgang offen (503/5xx/429/408), wird vor
der Fehlermeldung nachgesehen und bei Erfolg der Erfolgszustand gezeigt. Zurückziehen fragt nach, 404 gilt
als erledigt. Nicht angemeldet läuft über withLogin. Das Löschen einer veröffentlichten Karte weist darauf
hin, dass die Veröffentlichung bleibt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 15:47:56 +00:00

1111 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import type { GalleryCard } from '../community/gallery';
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(),
publishSavedCard: vi.fn(),
withdrawCard: vi.fn(),
myPublications: 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.',
PIXELS_MESSAGE: 'Das Bild ist zu groß (max. 40 Megapixel).',
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,
}),
}));
vi.mock('../community/publish', async (importOriginal) => ({
...(await importOriginal<typeof import('../community/publish')>()),
publishSavedCard: mocks.publishSavedCard,
}));
vi.mock('../community/api', async (importOriginal) => ({
...(await importOriginal<typeof import('../community/api')>()),
withdrawCard: mocks.withdrawCard,
}));
vi.mock('../community/gallery', () => ({ myPublications: mocks.myPublications }));
import { ApiError } from '../community/api';
import { 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.myPublications.mockResolvedValue(new Map());
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('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('/gestalten');
});
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('/gestalten?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 eines über 40 Megapixel großen Bildes zeigt die Größen-Meldung an der Kachel', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/png' }));
mocks.decodeImage.mockRejectedValue(new Error('Das Bild ist zu groß (max. 40 Megapixel).'));
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 ist zu groß (max. 40 Megapixel).'),
);
expect(mocks.saveBlobAs).not.toHaveBeenCalled();
});
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: 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')]);
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('der vorhandene Name ist beim Öffnen komplett markiert (Tippen ersetzt ihn, statt davor einzufügen)', async () => {
const input = await startRename();
expect(document.activeElement).toBe(input);
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe('Beta'.length);
});
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);
});
});
describe('Meine Karten: Seite wird verlassen', () => {
const savedRef = (id: string, name: string) => ({
id,
name,
imageFileId: `img-${id}`,
previewFileId: `prev-${id}`,
updatedAt: '2026-10-04T12:00:00.000Z',
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('ein offener Löschen-Dialog wird geschlossen, nichts wird gelöscht', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Löschen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
unmount?.();
unmount = null;
expect(dialog()).toBeNull();
await new Promise((r) => setTimeout(r, 0));
expect(mocks.remove).not.toHaveBeenCalled();
});
it('Umbenennen, das erst nach dem Verlassen fertig wird, stellt den Editor-Zustand trotzdem richtig', async () => {
store.set({ saved: savedRef('a', 'Alpha'), cardName: 'Alpha' });
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
let finish!: (c: SavedCard) => void;
mocks.rename.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Umbenennen').click();
const input = tileFor('a').querySelector<HTMLInputElement>('input')!;
input.value = 'Neu';
submit(input.closest('form')!);
await vi.waitFor(() => expect(mocks.rename).toHaveBeenCalled());
unmount?.();
unmount = null;
finish(card('a', 'Neu', { updatedAt: '2026-10-06T08:00:00.000Z' }));
await vi.waitFor(() => expect(store.get().cardName).toBe('Neu'));
expect(store.get().saved).toMatchObject({ id: 'a', name: 'Neu' });
});
it('Löschen, das erst nach dem Verlassen fertig wird, löst den Editor trotzdem von der Karte', async () => {
store.set({ saved: savedRef('a', 'Alpha'), savedConfig: structuredClone(DEFAULT_CONFIG) });
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
let finish!: () => void;
mocks.remove.mockReturnValue(new Promise<void>((r) => (finish = r)));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Löschen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
button(dialog()!, 'Löschen').click();
await vi.waitFor(() => expect(mocks.remove).toHaveBeenCalled());
unmount?.();
unmount = null;
finish();
await vi.waitFor(() => expect(store.get().saved).toBeNull());
expect(store.get().savedConfig).toBeNull();
});
it('eine zweite Vorschau derselben Datei legt keine zweite URL an (sonst bliebe die erste ungenutzt)', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
const pending: Array<(b: Blob) => void> = [];
mocks.loadPreview.mockImplementation(() => new Promise<Blob>((r) => pending.push(r)));
mount();
await vi.waitFor(() => expect(pending).toHaveLength(1));
// Abmelden und wieder anmelden lädt die Liste neu, während die erste Vorschau noch unterwegs ist.
for (const fn of mocks.authListeners) fn(null);
for (const fn of mocks.authListeners) fn(user);
await vi.waitFor(() => expect(pending).toHaveLength(2));
pending[0](new Blob(['p'], { type: 'image/webp' }));
await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull());
pending[1](new Blob(['p'], { type: 'image/webp' }));
await new Promise((r) => setTimeout(r, 0));
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
unmount?.();
unmount = null;
expect(revoked).toEqual(['blob:preview-1']);
});
});
const published = (id: string, sourceCardId: string, patch: Partial<GalleryCard> = {}): GalleryCard => ({
id,
title: 'Mein Motiv',
issuer: '',
tags: [],
displayName: '',
imageFileId: `gi-${id}`,
previewFileId: `gp-${id}`,
sourceCardId,
ownerId: 'u1',
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T12:00:00.000Z',
...patch,
});
const labels = (scope: ParentNode) => [...scope.querySelectorAll('.card-buttons button, .card-buttons a')].map((n) => n.textContent);
const badge = (id: string) => tileFor(id).querySelector('.card-pub')?.textContent ?? '';
const publishDialogEl = () => document.querySelector<HTMLDialogElement>('dialog.publish-dialog');
const dlgInput = (name: string) => publishDialogEl()!.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
const sendForm = () =>
publishDialogEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
const fillDialog = (title = 'Mein Motiv', values: Record<string, string> = {}) => {
dlgInput('title').value = title;
for (const [name, value] of Object.entries(values)) dlgInput(name).value = value;
dlgInput('consent').checked = true;
};
const dlgError = () => publishDialogEl()!.querySelector('[role="alert"]')!.textContent;
const openPublish = async (id: string) => {
await vi.waitFor(() => expect(button(tileFor(id), 'Veröffentlichen').disabled).toBe(false));
button(tileFor(id), 'Veröffentlichen').click();
await vi.waitFor(() => expect(publishDialogEl()?.open).toBe(true));
};
describe('Meine Karten: Veröffentlichung (Status an der Kachel)', () => {
const pubMap = (...cards: GalleryCard[]) => new Map(cards.map((c) => [c.sourceCardId, c]));
const view = (id: string) => tileFor(id).querySelector<HTMLAnchorElement>('a[data-action="view"]');
const mountWith = async (publications: Map<string, GalleryCard> = new Map()) => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mocks.myPublications.mockResolvedValue(publications);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(button(tileFor('a'), 'Löschen')).toBeTruthy());
await vi.waitFor(() => expect(tileFor('a').querySelector('[data-action="publish"], [data-action="withdraw"]')).not.toBeNull());
};
it('lädt den Status einmal je Seitenaufbau (für die angemeldete Nutzer-ID), nicht abgemeldet', async () => {
await mountWith();
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
expect(mocks.myPublications).toHaveBeenCalledWith('u1');
unmount?.();
unmount = null;
mocks.myPublications.mockClear();
mocks.getCurrentUser.mockResolvedValue(null);
mount();
await vi.waitFor(() => expect(text()).toContain('Melde dich an'));
expect(mocks.myPublications).not.toHaveBeenCalled();
});
it('nicht veröffentlicht: „Veröffentlichen“, kein Badge', async () => {
await mountWith();
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Veröffentlichen', 'Löschen']);
expect(badge('a')).toBe('');
expect(view('a')).toBeNull();
});
it('veröffentlicht: Badge „Veröffentlicht“, „Ansehen“ (/community/<id>) und „Zurückziehen“ statt „Veröffentlichen“', async () => {
await mountWith(pubMap(published('g1', 'a')));
expect(badge('a')).toBe('Veröffentlicht');
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Ansehen', 'Zurückziehen', 'Löschen']);
expect(view('a')!.getAttribute('href')).toBe('/community/g1');
expect(view('a')!.hasAttribute('data-link')).toBe(true);
// Die andere Kachel bleibt unberührt.
expect(badge('b')).toBe('');
expect(labels(tileFor('b'))).toContain('Veröffentlichen');
});
it('ausgeblendet (gemeldet): Badge „Ausgeblendet (gemeldet)“ und „Zurückziehen“, aber kein „Ansehen“ und kein „Veröffentlichen“', async () => {
await mountWith(pubMap(published('g1', 'a', { hidden: true, reportCount: 3 })));
expect(badge('a')).toBe('Ausgeblendet (gemeldet)');
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Zurückziehen', 'Löschen']);
expect(view('a')).toBeNull();
});
it('solange der Status lädt, gibt es weder „Veröffentlichen“ noch „Zurückziehen“; danach erscheint der Stand', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
let finish!: (m: Map<string, GalleryCard>) => void;
mocks.myPublications.mockReturnValue(new Promise((r) => (finish = r)));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Löschen']);
finish(pubMap(published('g1', 'a')));
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
});
it('Status lässt sich nicht laden: Hinweis mit „Nochmal versuchen“, „Veröffentlichen“ bleibt möglich (409 fängt Doppeltes ab)', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.myPublications.mockRejectedValueOnce(new TypeError('offline'));
mount();
await vi.waitFor(() => expect(text()).toContain('Der Veröffentlichungsstatus konnte nicht geladen werden.'));
expect(labels(tileFor('a'))).toContain('Veröffentlichen');
mocks.myPublications.mockResolvedValue(pubMap(published('g1', 'a')));
button(root, 'Nochmal versuchen').click();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(text()).not.toContain('konnte nicht geladen werden');
});
it('Abmelden räumt den Status weg, nach dem Anmelden wird er neu geholt', async () => {
await mountWith(pubMap(published('g1', 'a')));
for (const fn of mocks.authListeners) fn(null);
expect(tiles()).toHaveLength(0);
mocks.myPublications.mockResolvedValue(new Map());
for (const fn of mocks.authListeners) fn(user);
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
expect(badge('a')).toBe('');
expect(mocks.myPublications).toHaveBeenCalledTimes(2);
});
it('der Status überlebt Umbenennen (Liste wird neu aufgebaut) ohne neue Anfrage', async () => {
mocks.rename.mockImplementation(async (id: string, name: string) => card(id, name, { updatedAt: '2026-10-06T08:00:00.000Z' }));
await mountWith(pubMap(published('g1', 'a')));
button(tileFor('a'), 'Umbenennen').click();
const input = tileFor('a').querySelector<HTMLInputElement>('input')!;
input.value = 'Anders';
submit(input.closest('form')!);
await vi.waitFor(() => expect(tileFor('a').querySelector('.card-name')!.textContent).toBe('Anders'));
expect(badge('a')).toBe('Veröffentlicht');
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
});
it('Löschen einer veröffentlichten Karte weist darauf hin, dass die Veröffentlichung bleibt', async () => {
await mountWith(pubMap(published('g1', 'a')));
button(tileFor('a'), 'Löschen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
expect(dialog()!.textContent).toContain('bleibt in der Community-Galerie');
button(dialog()!, 'Abbrechen').click();
await vi.waitFor(() => expect(dialog()).toBeNull());
button(tileFor('b'), 'Löschen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
expect(dialog()!.textContent).not.toContain('Community-Galerie');
});
});
describe('Meine Karten: Veröffentlichen', () => {
const start = async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
};
it('„Veröffentlichen“ öffnet den Dialog: leere Felder, nie die E-Mail im Anzeigenamen', async () => {
await start();
await openPublish('a');
expect(dlgInput('title').value).toBe('');
expect(dlgInput('displayName').value).toBe('');
expect(dlgInput('displayName').placeholder).toBe('Anonym');
expect(publishDialogEl()!.textContent).not.toContain('a@b.de');
expect(publishDialogEl()!.textContent).toContain('„Alpha“');
expect(mocks.publishSavedCard).not.toHaveBeenCalled();
});
it('Abbrechen veröffentlicht nichts, Fokus zurück auf „Veröffentlichen“', async () => {
await start();
await openPublish('a');
button(publishDialogEl()!, 'Abbrechen').click();
await vi.waitFor(() => expect(publishDialogEl()).toBeNull());
expect(mocks.publishSavedCard).not.toHaveBeenCalled();
await vi.waitFor(() => expect(document.activeElement).toBe(button(tileFor('a'), 'Veröffentlichen')));
});
it('ohne Pflicht-Checkbox wird nichts veröffentlicht', async () => {
await start();
await openPublish('a');
dlgInput('title').value = 'Mein Motiv';
sendForm();
expect(dlgError()).toBe('Bitte bestätige die Erklärung zum Bild.');
expect(mocks.publishSavedCard).not.toHaveBeenCalled();
});
it('Erfolg: Badge „Veröffentlicht“ und „Ansehen“ erscheinen sofort, ohne die Liste neu zu laden', async () => {
mocks.publishSavedCard.mockResolvedValue({ id: 'g9' });
await start();
await openPublish('a');
fillDialog('Mein Motiv', { issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' });
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(mocks.publishSavedCard).toHaveBeenCalledWith(
expect.objectContaining({ id: 'a', name: 'Alpha' }),
{ title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' },
);
expect(publishDialogEl()).toBeNull();
const link = tileFor('a').querySelector<HTMLAnchorElement>('a[data-action="view"]')!;
expect(link.textContent).toBe('Ansehen');
expect(link.getAttribute('href')).toBe('/community/g9');
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Ansehen', 'Zurückziehen', 'Löschen']);
// Alle Knöpfe der Kachel sind wieder bedienbar, die andere Kachel ist unberührt.
expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false);
expect(labels(tileFor('b'))).toContain('Veröffentlichen');
expect(mocks.list).toHaveBeenCalledTimes(1);
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
// Der Fokus geht auf „Ansehen“ (der auslösende Knopf ist weg).
expect(document.activeElement).toBe(link);
});
it('während des Veröffentlichens sind die Kachel-Knöpfe gesperrt und der Dialog lässt sich nicht schließen', async () => {
let finish!: (r: { id: string }) => void;
mocks.publishSavedCard.mockReturnValue(new Promise((r) => (finish = r)));
await start();
await openPublish('a');
fillDialog();
sendForm();
expect(button(publishDialogEl()!, 'Veröffentlichen').disabled).toBe(true);
expect(button(tileFor('a'), 'Löschen').disabled).toBe(true);
expect(tileFor('a').getAttribute('aria-busy')).toBe('true');
finish({ id: 'g9' });
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(tileFor('a').hasAttribute('aria-busy')).toBe(false);
});
it('Fehler (400): Text im Dialog, kein Status geändert, keine Nachprüfung, neuer Versuch möglich', async () => {
mocks.publishSavedCard.mockRejectedValueOnce(new ApiError(400, 'Der Titel enthält ungültige Zeichen.'));
await start();
await openPublish('a');
fillDialog();
sendForm();
await vi.waitFor(() => expect(dlgError()).toBe('Veröffentlichen fehlgeschlagen – Der Titel enthält ungültige Zeichen.'));
expect(publishDialogEl()?.open).toBe(true);
expect(badge('a')).toBe('');
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
mocks.publishSavedCard.mockResolvedValueOnce({ id: 'g9' });
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
});
it('409: „Diese Karte ist schon veröffentlicht.“, der Status wird neu geholt und zeigt „Veröffentlicht“', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(409, 'Diese Karte ist schon veröffentlicht.'));
await start();
await openPublish('a');
fillDialog();
mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]]));
sendForm();
await vi.waitFor(() => expect(publishDialogEl()!.textContent).toContain('Diese Karte ist schon veröffentlicht.'));
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(mocks.myPublications).toHaveBeenCalledTimes(2);
button(publishDialogEl()!, 'Schließen').click();
await vi.waitFor(() => expect(publishDialogEl()).toBeNull());
expect(tileFor('a').querySelector<HTMLAnchorElement>('a[data-action="view"]')!.getAttribute('href')).toBe('/community/g1');
expect(mocks.publishSavedCard).toHaveBeenCalledOnce();
});
describe('Ausgang offen (503, 429, 408, 5xx): Nachprüfung vor der Fehlermeldung', () => {
it('war die Karte doch veröffentlicht: Erfolgszustand statt Fehler', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await start();
await openPublish('a');
fillDialog();
mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]]));
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(publishDialogEl()).toBeNull();
expect(tileFor('a').querySelector<HTMLAnchorElement>('a[data-action="view"]')!.getAttribute('href')).toBe('/community/g1');
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('');
expect(mocks.myPublications).toHaveBeenCalledTimes(2);
});
it.each([408, 429, 500, 504])('auch bei %i wird nachgeprüft', async (code) => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(code, 'Offen.'));
await start();
await openPublish('a');
fillDialog();
mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]]));
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
});
it('war sie es nicht: Fehlermeldung im Dialog, Status unverändert', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await start();
await openPublish('a');
fillDialog();
sendForm();
await vi.waitFor(() =>
expect(dlgError()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.'),
);
expect(mocks.myPublications).toHaveBeenCalledTimes(2);
expect(badge('a')).toBe('');
expect(publishDialogEl()?.open).toBe(true);
});
it('scheitert auch die Nachprüfung: die ursprüngliche Meldung bleibt', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await start();
await openPublish('a');
fillDialog();
mocks.myPublications.mockRejectedValue(new TypeError('offline'));
sendForm();
await vi.waitFor(() =>
expect(dlgError()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.'),
);
expect(badge('a')).toBe('');
});
it('die Nachprüfung findet nur die Veröffentlichung dieser Karte (eine andere zählt nicht)', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await start();
await openPublish('a');
fillDialog();
mocks.myPublications.mockResolvedValue(new Map([['b', published('g2', 'b')]]));
sendForm();
await vi.waitFor(() => expect(dlgError()).toContain('Der Dienst ist gerade nicht erreichbar.'));
expect(badge('a')).toBe('');
expect(badge('b')).toBe('Veröffentlicht');
});
});
it('abgelaufene Sitzung (401): Anmelden, dann wird veröffentlicht', async () => {
mocks.publishSavedCard
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { code: 401 }))
.mockResolvedValueOnce({ id: 'g9' });
await start();
await openPublish('a');
fillDialog();
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(mocks.requireLogin).toHaveBeenCalledOnce();
expect(mocks.publishSavedCard).toHaveBeenCalledTimes(2);
});
it('Anmeldung abgebrochen: Hinweis im Dialog, nichts veröffentlicht', async () => {
mocks.requireLogin.mockResolvedValue(false);
mocks.publishSavedCard.mockRejectedValue(Object.assign(new Error('Unauthorized'), { code: 401 }));
await start();
await openPublish('a');
fillDialog();
sendForm();
await vi.waitFor(() => expect(dlgError()).toBe('Bitte melde dich an, um fortzufahren.'));
expect(badge('a')).toBe('');
});
it('die Seite wird verlassen: der Dialog geht zu, eine spätere Antwort ändert nichts mehr', async () => {
let finish!: (r: { id: string }) => void;
mocks.publishSavedCard.mockReturnValue(new Promise((r) => (finish = r)));
await start();
await openPublish('a');
fillDialog();
sendForm();
unmount?.();
unmount = null;
expect(publishDialogEl()).toBeNull();
finish({ id: 'g9' });
await new Promise((r) => setTimeout(r, 0));
expect(root.querySelector('.card-badge')).toBeNull();
});
it('die Seite wird verlassen, solange der Dialog offen ist: er geht zu, nichts wird veröffentlicht', async () => {
await start();
await openPublish('a');
unmount?.();
unmount = null;
expect(publishDialogEl()).toBeNull();
expect(mocks.publishSavedCard).not.toHaveBeenCalled();
});
it('Titel und Namen erscheinen als Text, nie als HTML', async () => {
mocks.list.mockResolvedValue([card('a', '<img src=x onerror=alert(1)>')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
await openPublish('a');
expect(publishDialogEl()!.querySelector('img')).toBeNull();
expect(publishDialogEl()!.textContent).toContain('<img src=x onerror=alert(1)>');
});
});
describe('Meine Karten: Zurückziehen', () => {
const start = async (patch: Partial<GalleryCard> = {}) => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a', patch)]]));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Zurückziehen'));
};
const openConfirm = async () => {
button(tileFor('a'), 'Zurückziehen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
};
it('fragt nach, zieht dann die Veröffentlichung (Galerie-ID) zurück; die Kachel zeigt wieder „Veröffentlichen“', async () => {
mocks.withdrawCard.mockResolvedValue(undefined);
await start();
await openConfirm();
expect(dialog()!.querySelector('h2')!.textContent).toBe('Karte „Mein Motiv“ zurückziehen?');
expect(dialog()!.textContent).toContain('Deine private Karte bleibt erhalten.');
expect(mocks.withdrawCard).not.toHaveBeenCalled();
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
expect(mocks.withdrawCard).toHaveBeenCalledWith('g1');
expect(badge('a')).toBe('');
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Veröffentlichen', 'Löschen']);
expect(dialog()).toBeNull();
expect(mocks.list).toHaveBeenCalledTimes(1);
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
// Fokus auf dem neuen „Veröffentlichen“-Knopf.
expect(document.activeElement).toBe(button(tileFor('a'), 'Veröffentlichen'));
});
it('ausgeblendete Kacheln behalten „Zurückziehen“: danach lässt sich neu veröffentlichen', async () => {
mocks.withdrawCard.mockResolvedValue(undefined);
await start({ hidden: true, reportCount: 3 });
expect(badge('a')).toBe('Ausgeblendet (gemeldet)');
await openConfirm();
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
expect(mocks.withdrawCard).toHaveBeenCalledWith('g1');
expect(badge('a')).toBe('');
});
it('„Abbrechen“ zieht nichts zurück', async () => {
await start();
await openConfirm();
button(dialog()!, 'Abbrechen').click();
await vi.waitFor(() => expect(dialog()).toBeNull());
expect(mocks.withdrawCard).not.toHaveBeenCalled();
expect(badge('a')).toBe('Veröffentlicht');
await vi.waitFor(() => expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false));
});
it('Fehler: Text des Dienstes an der Kachel, Status bleibt', async () => {
mocks.withdrawCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await start();
await openConfirm();
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() =>
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Der Dienst ist gerade nicht erreichbar.'),
);
expect(badge('a')).toBe('Veröffentlicht');
expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false);
});
it('die Veröffentlichung gibt es schon nicht mehr (404): Ziel erreicht, die Kachel zeigt „Veröffentlichen“', async () => {
mocks.withdrawCard.mockRejectedValue(new ApiError(404, 'Diese Karte gibt es nicht.'));
await start();
await openConfirm();
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('');
});
it('abgelaufene Sitzung (401): Anmelden, dann wird zurückgezogen', async () => {
mocks.withdrawCard
.mockRejectedValueOnce(new ApiError(401, 'Bitte melde dich an.'))
.mockResolvedValueOnce(undefined);
await start();
await openConfirm();
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
expect(mocks.requireLogin).toHaveBeenCalledOnce();
expect(mocks.withdrawCard).toHaveBeenCalledTimes(2);
});
it('Seite verlassen mit offener Rückfrage: nichts wird zurückgezogen', async () => {
await start();
await openConfirm();
unmount?.();
unmount = null;
expect(dialog()).toBeNull();
await new Promise((r) => setTimeout(r, 0));
expect(mocks.withdrawCard).not.toHaveBeenCalled();
});
});