feat(community): Detailseite, Melden, Admin-Aktionen, Vorlage

Detailseite /community/<id>: große Karte, Titel, Herausgeber, „von …“, Datum,
Tags, Herunterladen (aircard-<slug>.png), „Als Vorlage gestalten“ und Melden-Dialog
(fünf Gründe, Dank-, Doppelt- und Drosselungs-Text). Autor: Zurückziehen; Admin:
Ausblenden/Einblenden, Löschen, Meldungszahl und Gründe. Fehlende oder
ausgeblendete Karten zeigen Besuchern „nicht (mehr) verfügbar“, Autor und Admin
sehen sie mit dem Hinweis „Ausgeblendet“.

Editor: /gestalten?vorlage=<id> übernimmt das Galerie-Vollbild als „Nur Bild“,
setzt saved, savedConfig und cardName zurück, ersetzt die Adresse durch /gestalten
und fragt vorher bei ungespeicherter Arbeit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 15:28:00 +00:00
1 parent c4a66f674f
commit 3cec7151a6
8 files changed
+2221 -15

No files matched your search

+708
View File
@@ -0,0 +1,708 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { User } from '../account/auth';
import { ApiError, type ReportReason } from '../community/api';
import type { GalleryCard, GalleryReport } from '../community/gallery';
// gallery.ts, api.ts und auth.ts hängen am Appwrite-Client; die Seite bekommt alles über ihre Abhängigkeiten.
vi.mock('../account/appwrite', () => ({
account: {},
client: {},
tables: {},
storage: {},
IDS: {},
APPWRITE_ENDPOINT: '',
}));
import { LoginCancelled } from '../account/with-login';
import { mountCommunityPage, type CommunityDeps } from './community-page';
const card = (patch: Partial<GalleryCard> = {}): GalleryCard => ({
id: 'g1',
title: 'Mitternachtsblau',
issuer: 'Beispielbank',
tags: ['dunkel', 'gold'],
displayName: 'Kenso',
imageFileId: 'img1',
previewFileId: 'pre1',
sourceCardId: 'src1',
ownerId: 'u1',
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T12:00:00.000Z',
...patch,
});
const report = (n: number, reason: string): GalleryReport => ({
id: `r${n}`,
galleryId: 'g1',
reason,
createdAt: '2026-10-05T12:00:00.000Z',
});
const settle = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
const blob = () => new Blob(['png'], { type: 'image/png' });
const admin: User = { $id: 'a1', email: 'kenso@example.com', labels: ['admin'] };
const owner: User = { $id: 'u1', email: 'u@example.com', labels: [] };
const stranger: User = { $id: 'u2', email: 'x@example.com', labels: [] };
const UNAVAILABLE = 'Diese Karte ist nicht (mehr) verfügbar.';
let root: HTMLElement;
let unmount: (() => void) | null;
let user: User | null;
let authListeners: Set<(u: User | null) => void>;
let urlCounter: number;
const revoked: string[] = [];
const getCard = vi.fn<(id: string) => Promise<GalleryCard | null>>();
const listReports = vi.fn<(id: string) => Promise<GalleryReport[]>>();
const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise<Blob>>();
const reportCard = vi.fn<(id: string, reason: ReportReason) => Promise<{ schonGemeldet: boolean }>>();
const withdrawCard = vi.fn<(id: string) => Promise<void>>();
const setVisibility = vi.fn<(id: string, visible: boolean) => Promise<void>>();
const navigate = vi.fn<(path: string) => void>();
const saveBlobAs = vi.fn<(b: Blob, name: string) => void>();
const withLogin = vi.fn(<T>(fn: () => Promise<T>): Promise<T> => fn());
const getUser = vi.fn<() => Promise<User | null>>();
function deps(): CommunityDeps {
return {
getCard,
listReports,
loadImage,
reportCard,
withdrawCard,
setVisibility,
currentUser: () => user,
getUser,
onAuthChange: (fn) => {
authListeners.add(fn);
return () => authListeners.delete(fn);
},
navigate,
saveBlobAs,
withLogin: <T>(fn: () => Promise<T>) => withLogin(fn) as Promise<T>,
};
}
async function mount(id = 'g1'): Promise<void> {
unmount = mountCommunityPage(root, id, deps());
await settle();
}
function login(next: User | null): void {
user = next;
for (const fn of [...authListeners]) fn(next);
}
const q = <T extends Element = HTMLElement>(sel: string) => root.querySelector<T>(sel);
const qa = <T extends Element = HTMLElement>(sel: string) => [...root.querySelectorAll<T>(sel)];
const buttonByText = (text: string) =>
qa<HTMLButtonElement>('button').find((b) => b.textContent === text) as HTMLButtonElement | undefined;
const buttonTexts = () => qa<HTMLButtonElement>('button').map((b) => b.textContent);
const confirmEl = () => document.querySelector<HTMLDialogElement>('dialog.confirm-dialog');
const confirmButton = (label: string) =>
[...confirmEl()!.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement;
const reportEl = () => document.querySelector<HTMLDialogElement>('dialog.report-dialog');
const errorText = () => q('[role="alert"]')?.textContent ?? '';
const statusText = () => q('.detail-info [role="status"]')?.textContent ?? '';
beforeEach(() => {
for (const fn of [getCard, listReports, loadImage, reportCard, withdrawCard, setVisibility, navigate, saveBlobAs, withLogin, getUser]) {
fn.mockReset();
}
getCard.mockResolvedValue(card());
listReports.mockResolvedValue([]);
loadImage.mockImplementation(async () => blob());
withdrawCard.mockResolvedValue(undefined);
setVisibility.mockResolvedValue(undefined);
getUser.mockResolvedValue(null);
user = null;
authListeners = new Set();
urlCounter = 0;
revoked.length = 0;
URL.createObjectURL = vi.fn(() => `blob:detail-${++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;
unmount = null;
document.title = 'AirCard-IOS';
});
afterEach(() => {
unmount?.();
document.body.innerHTML = '';
});
describe('Detailseite: nicht (mehr) verfügbar', () => {
it('Gast, Karte nicht vorhanden oder ausgeblendet (404): Text, Link zur Galerie, kein Bild, keine Knöpfe', async () => {
getCard.mockResolvedValue(null);
await mount('g1');
expect(getCard).toHaveBeenCalledWith('g1');
expect(q('h1')?.textContent).toBe(UNAVAILABLE);
const link = q<HTMLAnchorElement>('a[href="/#community"]');
expect(link?.textContent).toBe('Zur Galerie');
expect(link?.hasAttribute('data-link')).toBe(true);
expect(q('img')).toBeNull();
expect(buttonTexts()).toEqual([]);
expect(loadImage).not.toHaveBeenCalled();
expect(listReports).not.toHaveBeenCalled();
expect(document.title).toBe('Community – AirCard-IOS');
});
it('eine ausgeblendete Karte für jemanden, der weder Autor noch Admin ist (Rechte-Fehler): ebenso, ohne Bild', async () => {
user = stranger;
getCard.mockResolvedValue(card({ hidden: true, reportCount: 3 }));
await mount();
expect(q('h1')?.textContent).toBe(UNAVAILABLE);
expect(q('img')).toBeNull();
expect(loadImage).not.toHaveBeenCalled();
expect(root.textContent).not.toContain('Mitternachtsblau');
});
it('Direktaufruf des Autors: der Anmeldestatus ist noch unbekannt, der Server hat die ausgeblendete Karte aber geliefert', async () => {
getCard.mockResolvedValue(card({ hidden: true }));
getUser.mockImplementation(async () => {
user = owner;
return owner;
});
await mount();
expect(getUser).toHaveBeenCalledTimes(1);
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
expect(q('.detail-hidden')?.textContent).toContain('Ausgeblendet');
expect(buttonTexts()).toContain('Zurückziehen');
});
it('eine sichtbare Karte braucht keine Anmeldeabfrage', async () => {
await mount();
expect(getUser).not.toHaveBeenCalled();
});
it('Ladefehler (Netz): Hinweis und „Nochmal versuchen“, danach erscheint die Karte', async () => {
getCard.mockRejectedValueOnce(new TypeError('offline'));
await mount();
expect(errorText()).toBe('Die Karte konnte gerade nicht geladen werden.');
expect(q('img')).toBeNull();
buttonByText('Nochmal versuchen')!.click();
await settle();
expect(getCard).toHaveBeenCalledTimes(2);
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
expect(errorText()).toBe('');
});
it('während des Ladens steht ein Hinweis', async () => {
getCard.mockReturnValue(new Promise(() => {}));
await mount();
expect(q('[role="status"]')?.textContent).toBe('Karte wird geladen …');
});
it('meldet sich der Autor einer ausgeblendeten Karte an, wird sie neu geladen und gezeigt', async () => {
getCard.mockResolvedValueOnce(null);
await mount();
expect(q('h1')?.textContent).toBe(UNAVAILABLE);
getCard.mockResolvedValueOnce(card({ hidden: true }));
login(owner);
await settle();
expect(getCard).toHaveBeenCalledTimes(2);
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
expect(q('.detail-hidden')?.textContent).toContain('Ausgeblendet');
});
});
describe('Detailseite: Karte', () => {
it('zeigt Titel, Herausgeber, „von …“, Datum, Tags und das große Bild; setzt den Seitentitel', async () => {
await mount();
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
expect(q('.detail-issuer')?.textContent).toBe('Beispielbank');
expect(q('.detail-meta')?.textContent).toBe('von Kenso · 04.10.2026');
const tags = qa<HTMLAnchorElement>('.detail-tag');
expect(tags.map((t) => t.textContent)).toEqual(['dunkel', 'gold']);
expect(tags[1].getAttribute('href')).toBe('/?tag=gold#community');
expect(tags[1].hasAttribute('data-link')).toBe(true);
expect(loadImage).toHaveBeenCalledWith(expect.objectContaining({ id: 'g1' }), 'full');
const img = q<HTMLImageElement>('.detail-card img');
expect(img?.getAttribute('src')).toBe('blob:detail-1');
expect(img?.getAttribute('alt')).toBe('Community-Karte „Mitternachtsblau“');
expect(document.title).toBe('Mitternachtsblau – AirCard-IOS');
expect(q('a[href="/#community"]')?.textContent).toContain('Zur Galerie');
});
it('ohne Anzeigenamen „von Anonym“; ohne Herausgeber und Tags fehlen die Zeilen', async () => {
getCard.mockResolvedValue(card({ displayName: '', issuer: '', tags: [] }));
await mount();
expect(q('.detail-meta')?.textContent).toBe('von Anonym · 04.10.2026');
expect(q('.detail-issuer')).toBeNull();
expect(q('.detail-tags')).toBeNull();
});
it('alles Nutzerseitige bleibt Text (kein HTML)', async () => {
getCard.mockResolvedValue(
card({
title: '<b>Fett</b>',
issuer: '<i>Bank</i>',
displayName: '<script>alert(1)</script>',
tags: ['<img src=x onerror=alert(1)>'],
}),
);
await mount();
expect(q('h1')?.textContent).toBe('<b>Fett</b>');
expect(q('.detail-issuer')?.textContent).toBe('<i>Bank</i>');
expect(q('.detail-meta')?.textContent).toContain('<script>alert(1)</script>');
expect(q('.detail-tag')?.textContent).toBe('<img src=x onerror=alert(1)>');
expect(root.querySelector('b, i, script')).toBeNull();
expect(qa('img')).toHaveLength(1); // nur das Kartenbild
});
it('der Gast sieht Herunterladen, Als Vorlage gestalten, Melden – sonst nichts', async () => {
await mount();
expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden']);
expect(listReports).not.toHaveBeenCalled();
expect(q('.detail-admin')).toBeNull();
});
it('Bild lädt nicht: Hinweis mit „Nochmal versuchen“; Herunterladen holt das Bild selbst nach', async () => {
loadImage.mockRejectedValueOnce(new TypeError('offline'));
await mount();
expect(q('.detail-card')?.textContent).toContain('Das Bild konnte nicht geladen werden.');
expect(q('.detail-card img')).toBeNull();
// Herunterladen versucht es erneut und gelingt
buttonByText('Herunterladen')!.click();
await settle();
expect(loadImage).toHaveBeenCalledTimes(2);
expect(saveBlobAs).toHaveBeenCalledTimes(1);
});
it('„Nochmal versuchen“ beim Bild lädt es erneut', async () => {
loadImage.mockRejectedValueOnce(new TypeError('offline'));
await mount();
buttonByText('Nochmal versuchen')!.click();
await settle();
expect(loadImage).toHaveBeenCalledTimes(2);
expect(q('.detail-card img')?.getAttribute('src')).toBe('blob:detail-1');
});
it('ein Bild, das der Browser nicht darstellen kann, zeigt denselben Hinweis und gibt die URL frei', async () => {
await mount();
q('.detail-card img')!.dispatchEvent(new Event('error'));
expect(q('.detail-card')?.textContent).toContain('Das Bild konnte nicht geladen werden.');
expect(revoked).toEqual(['blob:detail-1']);
});
it('Object-URL wird beim Verlassen freigegeben; späte Antworten nach dem Verlassen bewirken nichts', async () => {
await mount();
unmount?.();
unmount = null;
expect(revoked).toEqual(['blob:detail-1']);
// Karte kommt erst nach dem Verlassen
let finishCard!: (c: GalleryCard) => void;
getCard.mockReturnValue(new Promise((r) => (finishCard = r)));
root.replaceChildren();
unmount = mountCommunityPage(root, 'g1', deps());
unmount();
unmount = null;
finishCard(card());
await settle();
expect(loadImage).toHaveBeenCalledTimes(1); // nur vom ersten Aufruf
expect(q('h1')).toBeNull(); // die Karte wird nicht mehr aufgebaut
// Bild kommt erst nach dem Verlassen
let finishImage!: (b: Blob) => void;
getCard.mockResolvedValue(card());
loadImage.mockReturnValue(new Promise((r) => (finishImage = r)));
unmount = mountCommunityPage(root, 'g1', deps());
await settle();
unmount();
unmount = null;
const created = urlCounter;
finishImage(blob());
await settle();
expect(urlCounter).toBe(created);
});
});
describe('Detailseite: Aufräumen', () => {
it('beim Verlassen wird der Auth-Abonnent abgemeldet, danach ändert sich nichts mehr', async () => {
await mount();
expect(authListeners.size).toBe(1);
unmount?.();
unmount = null;
expect(authListeners.size).toBe(0);
});
it('Meldungen, die erst nach dem Verlassen eintreffen, werden verworfen', async () => {
user = admin;
let finish!: (r: GalleryReport[]) => void;
listReports.mockReturnValue(new Promise((r) => (finish = r)));
await mount();
unmount?.();
unmount = null;
const html = root.innerHTML;
finish([report(1, 'spam')]);
await settle();
expect(root.innerHTML).toBe(html);
});
});
describe('Detailseite: Herunterladen und Vorlage', () => {
it('Herunterladen speichert das volle PNG als aircard-<titel-slug>.png', async () => {
const full = blob();
loadImage.mockResolvedValue(full);
getCard.mockResolvedValue(card({ title: 'Grüner Wald!' }));
await mount();
buttonByText('Herunterladen')!.click();
await settle();
expect(saveBlobAs).toHaveBeenCalledTimes(1);
expect(saveBlobAs).toHaveBeenCalledWith(full, 'aircard-gruener-wald.png');
// das schon geladene Bild wird wiederverwendet
expect(loadImage).toHaveBeenCalledTimes(1);
});
it('Herunterladen scheitert: Meldung, der Knopf geht wieder', async () => {
await mount();
q('.detail-card img')!.dispatchEvent(new Event('error')); // kein Bild im Speicher
loadImage.mockRejectedValueOnce(new TypeError('offline'));
buttonByText('Herunterladen')!.click();
await settle();
expect(saveBlobAs).not.toHaveBeenCalled();
expect(errorText()).toBe('Das Bild konnte nicht geladen werden.');
expect(buttonByText('Herunterladen')!.disabled).toBe(false);
});
it('„Als Vorlage gestalten“ führt zu /gestalten?vorlage=<id>', async () => {
await mount();
buttonByText('Als Vorlage gestalten')!.click();
expect(navigate).toHaveBeenCalledWith('/gestalten?vorlage=g1');
});
});
describe('Detailseite: Melden', () => {
const open = async () => {
buttonByText('Melden')!.click();
await settle();
};
const submitReason = async (value: string) => {
const radio = reportEl()!.querySelector<HTMLInputElement>(`input[value="${value}"]`)!;
radio.checked = true;
reportEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
await settle();
};
it('Dialog mit den fünf Gründen; die Wahl geht als API-Wert an reportCard', async () => {
reportCard.mockResolvedValue({ schonGemeldet: false });
await mount();
await open();
expect([...reportEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
'Unangemessen',
'Echte Kartendaten',
'Urheberrecht/Marke',
'Spam',
'Sonstiges',
]);
await submitReason('urheberrecht');
expect(reportCard).toHaveBeenCalledWith('g1', 'urheberrecht');
expect(reportEl()!.textContent).toContain('Danke für deine Meldung.');
});
it('doppelt gemeldet: „Danke, du hast diese Karte schon gemeldet.“', async () => {
reportCard.mockResolvedValue({ schonGemeldet: true });
await mount();
await open();
await submitReason('spam');
expect(reportEl()!.textContent).toContain('Danke, du hast diese Karte schon gemeldet.');
});
it('zu viele Meldungen (429): der Servertext erscheint im Dialog', async () => {
reportCard.mockRejectedValue(new ApiError(429, 'Zu viele Meldungen – bitte später nochmal.'));
await mount();
await open();
await submitReason('spam');
expect(reportEl()!.querySelector('[role="alert"]')!.textContent).toBe('Zu viele Meldungen – bitte später nochmal.');
expect(reportEl()?.open).toBe(true);
});
it('Verlassen der Seite schließt den offenen Dialog', async () => {
await mount();
await open();
expect(reportEl()).not.toBeNull();
unmount?.();
unmount = null;
await settle();
expect(reportEl()).toBeNull();
});
it('eine ausgeblendete Karte (nur Autor/Admin sehen sie) hat keinen Melden-Knopf', async () => {
user = owner;
getCard.mockResolvedValue(card({ hidden: true }));
await mount();
expect(buttonTexts()).not.toContain('Melden');
});
});
describe('Detailseite: Autor', () => {
it('der Autor sieht Zurückziehen, ein Fremder nicht', async () => {
user = owner;
await mount();
expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden', 'Zurückziehen']);
unmount?.();
unmount = null;
user = stranger;
await mount();
expect(buttonTexts()).not.toContain('Zurückziehen');
expect(buttonTexts()).not.toContain('Löschen');
expect(q('.detail-admin')).toBeNull();
});
it('die Knöpfe erscheinen auch, wenn der Nutzer erst nach dem Laden bekannt wird, und verschwinden beim Abmelden', async () => {
await mount();
expect(buttonTexts()).not.toContain('Zurückziehen');
login(owner);
expect(buttonTexts()).toContain('Zurückziehen');
login(null);
expect(buttonTexts()).not.toContain('Zurückziehen');
});
it('Zurückziehen fragt nach; „Abbrechen“ lässt alles, „Zurückziehen“ ruft die API und geht zur Startseite', async () => {
user = owner;
await mount();
buttonByText('Zurückziehen')!.click();
await settle();
expect(confirmEl()?.open).toBe(true);
expect(confirmEl()!.querySelector('h2')!.textContent).toBe('Karte „Mitternachtsblau“ zurückziehen?');
confirmButton('Abbrechen').click();
await settle();
expect(confirmEl()).toBeNull();
expect(withdrawCard).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
buttonByText('Zurückziehen')!.click();
await settle();
confirmButton('Zurückziehen').click();
await settle();
expect(withdrawCard).toHaveBeenCalledWith('g1');
expect(withLogin).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith('/');
});
it('Zurückziehen scheitert: Servertext, nicht navigiert, Knöpfe wieder bedienbar', async () => {
user = owner;
withdrawCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await mount();
buttonByText('Zurückziehen')!.click();
await settle();
confirmButton('Zurückziehen').click();
await settle();
expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.');
expect(navigate).not.toHaveBeenCalled();
expect(qa<HTMLButtonElement>('.detail-actions button').every((b) => !b.disabled)).toBe(true);
});
it('war die Karte schon weg (404), ist das Ziel erreicht: zur Startseite', async () => {
user = owner;
withdrawCard.mockRejectedValue(new ApiError(404, 'Diese Karte gibt es nicht (mehr).'));
await mount();
buttonByText('Zurückziehen')!.click();
await settle();
confirmButton('Zurückziehen').click();
await settle();
expect(navigate).toHaveBeenCalledWith('/');
expect(errorText()).toBe('');
});
it('Anmeldung abgebrochen: Hinweis, nicht navigiert', async () => {
user = owner;
withLogin.mockRejectedValue(new LoginCancelled());
await mount();
buttonByText('Zurückziehen')!.click();
await settle();
confirmButton('Zurückziehen').click();
await settle();
expect(errorText()).toBe('Bitte melde dich an, um fortzufahren.');
expect(navigate).not.toHaveBeenCalled();
});
it('während die Anfrage läuft, ist alles gesperrt und es geht nur eine raus', async () => {
user = owner;
let finish!: () => void;
withdrawCard.mockReturnValue(new Promise<void>((r) => (finish = r)));
await mount();
buttonByText('Zurückziehen')!.click();
await settle();
confirmButton('Zurückziehen').click();
await settle();
expect(qa<HTMLButtonElement>('.detail-actions button').every((b) => b.disabled)).toBe(true);
buttonByText('Zurückziehen')!.click();
await settle();
expect(confirmEl()).toBeNull();
expect(withdrawCard).toHaveBeenCalledTimes(1);
finish();
await settle();
expect(navigate).toHaveBeenCalledWith('/');
});
it('verlässt der Nutzer die Seite, während gefragt wird, schließt der Dialog und nichts passiert', async () => {
user = owner;
await mount();
buttonByText('Zurückziehen')!.click();
await settle();
expect(confirmEl()).not.toBeNull();
unmount?.();
unmount = null;
await settle();
expect(confirmEl()).toBeNull();
expect(withdrawCard).not.toHaveBeenCalled();
});
it('der Autor einer ausgeblendeten Karte sieht sie mit dem Hinweis „Ausgeblendet“', async () => {
user = owner;
getCard.mockResolvedValue(card({ hidden: true, reportCount: 3 }));
await mount();
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
expect(q('.detail-hidden')?.textContent).toContain('Ausgeblendet');
expect(q('.detail-hidden')?.textContent).toContain('für andere nicht sichtbar');
expect(q('.detail-card img')).not.toBeNull();
// Meldungszahl und Gründe sind Sache der Admins
expect(root.textContent).not.toContain('Meldungen');
expect(listReports).not.toHaveBeenCalled();
});
});
describe('Detailseite: Admin', () => {
it('Admin sieht Ausblenden, Löschen, die Meldungszahl und die Gründe', async () => {
user = admin;
getCard.mockResolvedValue(card({ reportCount: 3 }));
listReports.mockResolvedValue([report(1, 'spam'), report(2, 'kartendaten'), report(3, 'unbekannt')]);
await mount();
expect(listReports).toHaveBeenCalledWith('g1');
expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden', 'Ausblenden', 'Löschen']);
expect(q('.detail-reports-count')?.textContent).toBe('3 Meldungen');
expect(qa('.detail-reasons li').map((li) => li.textContent)).toEqual([
'Spam · 05.10.2026',
'Echte Kartendaten · 05.10.2026',
'unbekannt · 05.10.2026',
]);
expect(buttonTexts()).not.toContain('Zurückziehen');
});
it('eine Meldung heißt „1 Meldung“, keine „0 Meldungen“ ohne Liste', async () => {
user = admin;
getCard.mockResolvedValue(card({ reportCount: 1 }));
listReports.mockResolvedValue([report(1, 'spam')]);
await mount();
expect(q('.detail-reports-count')?.textContent).toBe('1 Meldung');
unmount?.();
unmount = null;
getCard.mockResolvedValue(card({ reportCount: 0 }));
listReports.mockResolvedValue([]);
await mount();
expect(q('.detail-reports-count')?.textContent).toBe('0 Meldungen');
expect(q('.detail-reasons')).toBeNull();
});
it('Gründe lassen sich nicht laden: Hinweis, Zahl und Knöpfe bleiben', async () => {
user = admin;
getCard.mockResolvedValue(card({ reportCount: 2 }));
listReports.mockRejectedValue(new TypeError('offline'));
await mount();
expect(q('.detail-reports-count')?.textContent).toBe('2 Meldungen');
expect(q('.detail-admin')?.textContent).toContain('Die Gründe konnten nicht geladen werden.');
expect(buttonTexts()).toContain('Ausblenden');
});
it('Ausblenden ruft die API, zeigt „Ausgeblendet“ und wird zu „Einblenden“', async () => {
user = admin;
getCard.mockResolvedValue(card({ reportCount: 1 }));
listReports.mockResolvedValue([report(1, 'spam')]);
await mount();
buttonByText('Ausblenden')!.click();
await settle();
expect(setVisibility).toHaveBeenCalledWith('g1', false);
expect(withLogin).toHaveBeenCalledTimes(1);
expect(buttonTexts()).toContain('Einblenden');
expect(buttonTexts()).not.toContain('Ausblenden');
expect(buttonTexts()).not.toContain('Melden');
expect(q('.detail-hidden')?.textContent).toContain('Ausgeblendet');
expect(statusText()).toBe('Karte ausgeblendet.');
// Die Meldungen bleiben beim Ausblenden
expect(q('.detail-reports-count')?.textContent).toBe('1 Meldung');
});
it('Einblenden macht die Karte wieder sichtbar; der Server löscht die Meldungen, die Anzeige folgt', async () => {
user = admin;
getCard.mockResolvedValue(card({ hidden: true, reportCount: 3 }));
listReports.mockResolvedValue([report(1, 'spam'), report(2, 'spam'), report(3, 'sonstiges')]);
await mount();
expect(q('.detail-hidden')).not.toBeNull();
buttonByText('Einblenden')!.click();
await settle();
expect(setVisibility).toHaveBeenCalledWith('g1', true);
expect(buttonTexts()).toContain('Ausblenden');
expect(buttonTexts()).toContain('Melden');
expect(q('.detail-hidden')).toBeNull();
expect(q('.detail-reports-count')?.textContent).toBe('0 Meldungen');
expect(q('.detail-reasons')).toBeNull();
expect(statusText()).toBe('Karte eingeblendet.');
});
it('Ausblenden scheitert: Servertext, Zustand unverändert', async () => {
user = admin;
setVisibility.mockRejectedValue(new ApiError(403, 'Nur für Administratoren.'));
await mount();
buttonByText('Ausblenden')!.click();
await settle();
expect(errorText()).toBe('Nur für Administratoren.');
expect(buttonTexts()).toContain('Ausblenden');
expect(q('.detail-hidden')).toBeNull();
expect(buttonByText('Ausblenden')!.disabled).toBe(false);
});
it('Löschen fragt nach und löscht über withdrawCard, danach zur Startseite', async () => {
user = admin;
await mount();
buttonByText('Löschen')!.click();
await settle();
expect(confirmEl()!.querySelector('h2')!.textContent).toBe('Karte „Mitternachtsblau“ löschen?');
confirmButton('Abbrechen').click();
await settle();
expect(withdrawCard).not.toHaveBeenCalled();
buttonByText('Löschen')!.click();
await settle();
confirmButton('Löschen').click();
await settle();
expect(withdrawCard).toHaveBeenCalledWith('g1');
expect(navigate).toHaveBeenCalledWith('/');
});
it('wird jemand erst später als Admin erkannt, erscheinen die Admin-Knöpfe und die Gründe; beim Verlust verschwinden sie', async () => {
getCard.mockResolvedValue(card({ reportCount: 1 }));
listReports.mockResolvedValue([report(1, 'spam')]);
await mount();
expect(q('.detail-admin')).toBeNull();
expect(listReports).not.toHaveBeenCalled();
login(admin);
await settle();
expect(listReports).toHaveBeenCalledTimes(1);
expect(buttonTexts()).toContain('Ausblenden');
expect(q('.detail-reasons li')?.textContent).toBe('Spam · 05.10.2026');
login(stranger);
expect(q('.detail-admin')).toBeNull();
expect(buttonTexts()).not.toContain('Löschen');
});
it('verliert ein Admin sein Recht, während er eine ausgeblendete Karte ansieht, wird sie unzugänglich', async () => {
user = admin;
getCard.mockResolvedValue(card({ hidden: true }));
await mount();
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
login(stranger);
expect(q('h1')?.textContent).toBe(UNAVAILABLE);
expect(q('img')).toBeNull();
});
});