Logo-Kacheln, Logo-Infozeile und die Detailseite einer Community-Karte zeigen den Benutzernamen des Kontos (Hilfsfunktion byline: Profil, sonst alter Anzeigename, sonst „Anonym“). Die Namen kommen je geladener Seite in einer Abfrage nach, ein Fehler dabei bleibt ohne Fehlertext, ein eigenes Umbenennen aktualisiert die eigenen Einträge sofort. Die Felder „Anzeigename“ in den Dialogen „Logo hochladen“ und „Veröffentlichen“ fallen weg, ebenso account/display-name.ts und der localStorage-Schlüssel; der Client schickt displayName: ''. Vor dem Hochladen und Veröffentlichen verlangt requireUsername() einen Namen („Abbrechen“: es passiert nichts); antwortet der Dienst mit 403 usernameRequired, öffnet sich der Namensdialog, danach ist ein neuer Versuch möglich. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1141 lines
45 KiB
TypeScript
1141 lines
45 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import type { User } from '../account/auth';
|
||
import { ApiError, type ReportReason } from '../community/api';
|
||
import type { CheckedImage, GalleryCard, GalleryReport } from '../community/gallery';
|
||
import { germanLeft } from '../i18n/english-check';
|
||
import { setLang } from '../i18n/i18n';
|
||
|
||
// 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',
|
||
});
|
||
|
||
function deferred<T>() {
|
||
let resolve!: (value: T) => void;
|
||
const promise = new Promise<T>((res) => {
|
||
resolve = res;
|
||
});
|
||
return { promise, resolve };
|
||
}
|
||
|
||
const settle = async () => {
|
||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||
};
|
||
const blob = () => new Blob(['png'], { type: 'image/png' });
|
||
/** Ein geprüftes Bild, wie es loadGalleryImageChecked liefert: Blob plus fertige Object-URL. */
|
||
const image = (b: Blob = blob()): CheckedImage => ({ blob: b, url: `blob:detail-${++urlCounter}` });
|
||
|
||
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<CheckedImage>>();
|
||
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>>();
|
||
/** Die Benutzernamen (eine Abfrage für die Konten der Seite) und die Hörer des eigenen Umbenennens. */
|
||
const usernamesFor = vi.fn<CommunityDeps['usernamesFor']>();
|
||
let nameListeners: Set<(userId: string, username: string) => void>;
|
||
const emitName = (userId: string, username: string) => {
|
||
for (const fn of [...nameListeners]) fn(userId, username);
|
||
};
|
||
|
||
function deps(): CommunityDeps {
|
||
return {
|
||
getCard,
|
||
listReports,
|
||
loadImage,
|
||
reportCard,
|
||
withdrawCard,
|
||
setVisibility,
|
||
currentUser: () => user,
|
||
getUser,
|
||
usernamesFor,
|
||
onUsernameChange: (fn) => {
|
||
nameListeners.add(fn);
|
||
return () => nameListeners.delete(fn);
|
||
},
|
||
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, usernamesFor]) {
|
||
fn.mockReset();
|
||
}
|
||
getCard.mockResolvedValue(card());
|
||
listReports.mockResolvedValue([]);
|
||
loadImage.mockImplementation(async () => image());
|
||
withdrawCard.mockResolvedValue(undefined);
|
||
setVisibility.mockResolvedValue(undefined);
|
||
getUser.mockResolvedValue(null);
|
||
usernamesFor.mockResolvedValue(new Map()); // das Konto des Autors hat kein Profil: es gilt der alte Anzeigename
|
||
user = null;
|
||
authListeners = new Set();
|
||
nameListeners = new Set();
|
||
urlCounter = 0;
|
||
revoked.length = 0;
|
||
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('ein von der Pixelprüfung abgelehntes Bild (zu groß, nicht dekodierbar) zeigt den Hinweis statt eines Bildes', async () => {
|
||
// so lehnt loadGalleryImageChecked ab: es wirft, noch bevor ein <img> entsteht
|
||
loadImage.mockRejectedValue(new Error('Das Bild konnte nicht geladen werden.'));
|
||
await mount();
|
||
expect(q('.detail-card')?.textContent).toContain('Das Bild konnte nicht geladen werden.');
|
||
expect(q('.detail-card img')).toBeNull();
|
||
expect(q('h1')?.textContent).toBe('Mitternachtsblau'); // die Angaben und Knöpfe (Melden!) bleiben bedienbar
|
||
expect(buttonByText('Melden')).toBeDefined();
|
||
// auch Herunterladen bekommt das Bild nicht
|
||
buttonByText('Herunterladen')!.click();
|
||
await settle();
|
||
expect(saveBlobAs).not.toHaveBeenCalled();
|
||
expect(errorText()).toBe('Das Bild konnte nicht geladen werden.');
|
||
});
|
||
|
||
it('das Bild zum Herunterladen kommt aus der Prüfung; eine dabei angelegte URL wird sofort freigegeben', async () => {
|
||
await mount();
|
||
q('.detail-card img')!.dispatchEvent(new Event('error')); // kein Bild im Speicher
|
||
revoked.length = 0;
|
||
buttonByText('Herunterladen')!.click();
|
||
await settle();
|
||
expect(saveBlobAs).toHaveBeenCalledTimes(1);
|
||
expect(revoked).toEqual(['blob:detail-2']);
|
||
});
|
||
|
||
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!: (i: CheckedImage) => void;
|
||
getCard.mockResolvedValue(card());
|
||
loadImage.mockReturnValue(new Promise((r) => (finishImage = r)));
|
||
unmount = mountCommunityPage(root, 'g1', deps());
|
||
await settle();
|
||
unmount();
|
||
unmount = null;
|
||
revoked.length = 0;
|
||
finishImage({ blob: blob(), url: 'blob:detail-late' });
|
||
await settle();
|
||
expect(revoked).toEqual(['blob:detail-late']); // die verspätete URL wird sofort freigegeben
|
||
expect(q('img')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('Detailseite: „von @name“', () => {
|
||
const meta = () => q('.detail-meta')?.textContent;
|
||
|
||
it('fragt den Namen des Autors einmal je geladener Karte, mit seiner Konto-ID', async () => {
|
||
await mount();
|
||
expect(usernamesFor).toHaveBeenCalledExactlyOnceWith(['u1']);
|
||
});
|
||
|
||
it('zeigt „von @name“ und das Datum; ein Profil geht dem alten Anzeigenamen vor', async () => {
|
||
usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso.K']]));
|
||
await mount(); // die Karte trägt noch den alten Anzeigenamen „Kenso“
|
||
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
|
||
});
|
||
|
||
it('ohne Profil: der alte Anzeigename (ohne @), sonst „von Anonym“', async () => {
|
||
await mount();
|
||
expect(meta()).toBe('von Kenso · 04.10.2026');
|
||
unmount?.();
|
||
getCard.mockResolvedValue(card({ displayName: '' }));
|
||
await mount();
|
||
expect(meta()).toBe('von Anonym · 04.10.2026');
|
||
});
|
||
|
||
it('die Karte steht, ohne auf den Namen zu warten; er erscheint, sobald er da ist', async () => {
|
||
const lookup = deferred<Map<string, string>>();
|
||
usernamesFor.mockReturnValue(lookup.promise);
|
||
await mount();
|
||
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
|
||
expect(q('.detail-card img')).not.toBeNull();
|
||
expect(meta()).toBe('von Kenso · 04.10.2026');
|
||
lookup.resolve(new Map([['u1', 'Kenso.K']]));
|
||
await settle();
|
||
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
|
||
});
|
||
|
||
it('schlägt die Abfrage fehl, bleibt es beim Ersatz: kein Fehlertext, die Seite geht weiter', async () => {
|
||
usernamesFor.mockRejectedValue(new TypeError('Failed to fetch'));
|
||
await mount();
|
||
expect(meta()).toBe('von Kenso · 04.10.2026');
|
||
expect(errorText()).toBe('');
|
||
expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden']);
|
||
});
|
||
|
||
it('eine Karte ohne Konto-ID (Altbestand): keine Abfrage, der Ersatz', async () => {
|
||
getCard.mockResolvedValue(card({ ownerId: '', displayName: '' }));
|
||
await mount();
|
||
expect(usernamesFor).not.toHaveBeenCalled();
|
||
expect(meta()).toBe('von Anonym · 04.10.2026');
|
||
});
|
||
|
||
it('der Name erscheint als Text, nie als HTML', async () => {
|
||
usernamesFor.mockResolvedValue(new Map([['u1', '<b>x</b>']]));
|
||
await mount();
|
||
expect(meta()).toBe('von @<b>x</b> · 04.10.2026');
|
||
expect(q('.detail-meta b')).toBeNull();
|
||
});
|
||
|
||
it('eigenes Umbenennen: die Detailseite zeigt sofort den neuen Namen; ein anderes Konto ändert nichts', async () => {
|
||
usernamesFor.mockResolvedValue(new Map([['u1', 'Alt']]));
|
||
await mount();
|
||
expect(meta()).toBe('von @Alt · 04.10.2026');
|
||
emitName('u2', 'Fremd');
|
||
expect(meta()).toBe('von @Alt · 04.10.2026');
|
||
emitName('u1', 'Neu');
|
||
expect(meta()).toBe('von @Neu · 04.10.2026');
|
||
});
|
||
|
||
it('der Autor ohne Profil wählt einen Namen (eigene Karte): „von @name“ ersetzt den Ersatz sofort', async () => {
|
||
await mount();
|
||
expect(meta()).toBe('von Kenso · 04.10.2026');
|
||
emitName('u1', 'Kenso.K');
|
||
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
|
||
});
|
||
|
||
it('dieselbe Meldung noch einmal schreibt die Zeile nicht neu', async () => {
|
||
usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
|
||
await mount();
|
||
const line = q('.detail-meta')!;
|
||
const text = line.firstChild;
|
||
emitName('u1', 'Kenso');
|
||
expect(q('.detail-meta')).toBe(line);
|
||
expect(line.firstChild).toBe(text);
|
||
});
|
||
|
||
it('die Antwort für eine inzwischen verlassene Ansicht (die Karte ist nicht mehr zu sehen) ändert nichts', async () => {
|
||
user = owner;
|
||
getCard.mockResolvedValue(card({ hidden: true }));
|
||
const lookup = deferred<Map<string, string>>();
|
||
usernamesFor.mockReturnValue(lookup.promise);
|
||
await mount();
|
||
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
|
||
login(null); // abgemeldet: die ausgeblendete Karte ist für niemand mehr sichtbar
|
||
expect(q('h1')?.textContent).toBe(UNAVAILABLE);
|
||
const html = root.innerHTML;
|
||
lookup.resolve(new Map([['u1', 'Kenso.K']]));
|
||
await settle();
|
||
expect(root.innerHTML).toBe(html);
|
||
});
|
||
|
||
it('Aufräumen: der Hörer des Umbenennens ist abgemeldet, eine später eintreffende Antwort ändert nichts', async () => {
|
||
const lookup = deferred<Map<string, string>>();
|
||
usernamesFor.mockReturnValue(lookup.promise);
|
||
await mount();
|
||
expect(nameListeners.size).toBe(1);
|
||
unmount?.();
|
||
unmount = null;
|
||
expect(nameListeners.size).toBe(0);
|
||
const html = root.innerHTML;
|
||
lookup.resolve(new Map([['u1', 'Kenso.K']]));
|
||
emitName('u1', 'Neu');
|
||
await settle();
|
||
expect(root.innerHTML).toBe(html);
|
||
});
|
||
|
||
it('eine Karte, die nicht (mehr) verfügbar ist, fragt keinen Namen', async () => {
|
||
getCard.mockResolvedValue(null);
|
||
await mount();
|
||
expect(usernamesFor).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('Englisch: „by @name“', async () => {
|
||
setLang('en', { persist: false });
|
||
usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso.K']]));
|
||
await mount();
|
||
expect(meta()).toBe('by @Kenso.K · Oct 4, 2026');
|
||
expect(germanLeft(root)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('Detailseite: Rechte beim Ausführen', () => {
|
||
it('Admin-Recht verloren, Knopf noch im DOM: Ausblenden sendet nichts', async () => {
|
||
user = admin;
|
||
await mount();
|
||
const hide = buttonByText('Ausblenden')!;
|
||
user = stranger; // ohne auth-Ereignis: der alte Knopf steht noch da
|
||
hide.click();
|
||
await settle();
|
||
expect(setVisibility).not.toHaveBeenCalled();
|
||
expect(withLogin).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('Admin-Recht verloren, Knopf noch im DOM: Löschen fragt nicht einmal nach', async () => {
|
||
user = admin;
|
||
await mount();
|
||
const del = buttonByText('Löschen')!;
|
||
user = stranger;
|
||
del.click();
|
||
await settle();
|
||
expect(confirmEl()).toBeNull();
|
||
expect(withdrawCard).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('Autor-Prüfung beim Ausführen: ein anderer Nutzer kann nicht zurückziehen', async () => {
|
||
user = owner;
|
||
await mount();
|
||
const withdraw = buttonByText('Zurückziehen')!;
|
||
user = stranger;
|
||
withdraw.click();
|
||
await settle();
|
||
expect(confirmEl()).toBeNull();
|
||
expect(withdrawCard).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('Recht geht während der Rückfrage verloren: nach „Löschen“ im Dialog wird nichts gesendet', async () => {
|
||
user = admin;
|
||
await mount();
|
||
buttonByText('Löschen')!.click();
|
||
await settle();
|
||
expect(confirmEl()?.open).toBe(true);
|
||
login(stranger);
|
||
confirmButton('Löschen').click();
|
||
await settle();
|
||
expect(withdrawCard).not.toHaveBeenCalled();
|
||
expect(navigate).not.toHaveBeenCalled();
|
||
expect(errorText()).toBe('');
|
||
});
|
||
|
||
it('Recht geht während der Rückfrage verloren: auch Zurückziehen sendet nichts', async () => {
|
||
user = owner;
|
||
await mount();
|
||
buttonByText('Zurückziehen')!.click();
|
||
await settle();
|
||
login(null);
|
||
confirmButton('Zurückziehen').click();
|
||
await settle();
|
||
expect(withdrawCard).not.toHaveBeenCalled();
|
||
expect(navigate).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('ein Admin, der nicht Autor ist, kann nicht „zurückziehen“, aber löschen', async () => {
|
||
user = admin;
|
||
await mount();
|
||
expect(buttonTexts()).not.toContain('Zurückziehen');
|
||
expect(buttonTexts()).toContain('Löschen');
|
||
});
|
||
});
|
||
|
||
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.mockImplementation(async () => image(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('lässt sich der Dialog nicht darstellen (showModal wirft), erscheint ein Hinweis statt eines unbehandelten Fehlers', async () => {
|
||
const spy = vi.spyOn(HTMLDialogElement.prototype, 'showModal').mockImplementation(() => {
|
||
throw new Error('nicht darstellbar');
|
||
});
|
||
try {
|
||
await mount();
|
||
buttonByText('Melden')!.click();
|
||
await settle();
|
||
expect(errorText()).toBe('Der Melden-Dialog konnte nicht geöffnet werden.');
|
||
expect(reportEl()).toBeNull();
|
||
expect(buttonByText('Melden')!.disabled).toBe(false);
|
||
} finally {
|
||
spy.mockRestore();
|
||
}
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|
||
|
||
describe('Detailseite auf Englisch', () => {
|
||
beforeEach(() => setLang('en', { persist: false }));
|
||
|
||
it('Ansichten ohne Karte: nicht verfügbar, Laden, Ladefehler', async () => {
|
||
getCard.mockResolvedValue(null);
|
||
await mount();
|
||
expect(q('h1')?.textContent).toBe('This card is no longer available.');
|
||
expect(q('a[href="/#community"]')?.textContent).toBe('Back to gallery');
|
||
expect(document.title).toBe('Community – AirCard-IOS');
|
||
expect(germanLeft(root)).toEqual([]);
|
||
unmount?.();
|
||
|
||
getCard.mockReturnValue(new Promise(() => {}));
|
||
await mount();
|
||
expect(q('[role="status"]')?.textContent).toBe('Loading card …');
|
||
unmount?.();
|
||
|
||
getCard.mockReset();
|
||
getCard.mockRejectedValueOnce(new TypeError('offline'));
|
||
await mount();
|
||
expect(errorText()).toBe('The card couldn’t be loaded right now.');
|
||
expect(buttonByText('Try again')).toBeDefined();
|
||
expect(germanLeft(root)).toEqual([]);
|
||
});
|
||
|
||
it('Karte eines Gastes: Datum, Verfasser, Alt-Text, Knöpfe, Bildfehler und Herunterladen', async () => {
|
||
getCard.mockResolvedValue(card({ displayName: '' }));
|
||
loadImage.mockReturnValueOnce(new Promise(() => {}));
|
||
await mount();
|
||
expect(q('.detail-card')?.textContent).toBe('Loading image …');
|
||
unmount?.();
|
||
|
||
loadImage.mockImplementation(async () => image());
|
||
await mount();
|
||
expect(q('.detail-meta')?.textContent).toBe('by Anonymous · Oct 4, 2026');
|
||
expect(q('.detail-card img')?.getAttribute('alt')).toBe('Community card “Mitternachtsblau”');
|
||
expect(q('.detail-tag')?.getAttribute('title')).toBe('Show cards with this tag');
|
||
expect(q('.detail-back')?.textContent).toBe('← Back to gallery');
|
||
expect(buttonTexts()).toEqual(['Download', 'Use as template', 'Report']);
|
||
expect(germanLeft(root)).toEqual([]);
|
||
|
||
// Bild nicht darstellbar, Herunterladen holt es nach: unterwegs „Downloading …“, danach der Fehler
|
||
q('.detail-card img')!.dispatchEvent(new Event('error'));
|
||
expect(q('.detail-card')?.textContent).toContain('The image couldn’t be loaded.');
|
||
loadImage.mockReturnValueOnce(new Promise(() => {}));
|
||
buttonByText('Download')!.click();
|
||
expect(buttonTexts()).toContain('Downloading …');
|
||
unmount?.();
|
||
|
||
await mount();
|
||
q('.detail-card img')!.dispatchEvent(new Event('error'));
|
||
loadImage.mockRejectedValueOnce(new TypeError('offline'));
|
||
buttonByText('Download')!.click();
|
||
await settle();
|
||
expect(errorText()).toBe('The image couldn’t be loaded.');
|
||
});
|
||
|
||
it('Melden-Dialog: Hinweis, wenn er sich nicht öffnen lässt, und der Dialog selbst auf Englisch', async () => {
|
||
reportCard.mockRejectedValue(new ApiError(429, 'Zu viele Meldungen – bitte später nochmal.', undefined, 'tooManyReports'));
|
||
await mount();
|
||
buttonByText('Report')!.click();
|
||
await settle();
|
||
expect([...reportEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
|
||
'Inappropriate',
|
||
'Real card data',
|
||
'Copyright/trademark',
|
||
'Spam',
|
||
'Other',
|
||
]);
|
||
const radio = reportEl()!.querySelector<HTMLInputElement>('input[value="spam"]')!;
|
||
radio.checked = true;
|
||
reportEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||
await settle();
|
||
expect(reportEl()!.querySelector('[role="alert"]')!.textContent).toBe('Too many reports – please try again later.');
|
||
unmount?.();
|
||
reportEl()?.remove();
|
||
|
||
const spy = vi.spyOn(HTMLDialogElement.prototype, 'showModal').mockImplementation(() => {
|
||
throw new Error('nicht darstellbar');
|
||
});
|
||
try {
|
||
await mount();
|
||
buttonByText('Report')!.click();
|
||
await settle();
|
||
expect(errorText()).toBe('The report dialog couldn’t be opened.');
|
||
} finally {
|
||
spy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it('Autor: Zurückziehen mit Rückfrage und die Fehler als Text der Seite (Code, Anmeldung, Netz)', async () => {
|
||
user = owner;
|
||
await mount();
|
||
expect(buttonTexts()).toEqual(['Download', 'Use as template', 'Report', 'Withdraw']);
|
||
buttonByText('Withdraw')!.click();
|
||
await settle();
|
||
expect(confirmEl()!.querySelector('h2')!.textContent).toBe('Withdraw card “Mitternachtsblau”?');
|
||
expect(confirmEl()!.querySelector('p')!.textContent).toBe(
|
||
'The card disappears from the gallery, and its image and reports are deleted. Your private card is kept.',
|
||
);
|
||
expect([...confirmEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Cancel', 'Withdraw']);
|
||
withdrawCard.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', undefined, 'unavailable'));
|
||
confirmButton('Withdraw').click();
|
||
await settle();
|
||
expect(errorText()).toBe('The service is currently unavailable.');
|
||
|
||
// ohne bekannten Code zeigt Englisch die allgemeine Meldung zum Status
|
||
withdrawCard.mockRejectedValueOnce(new ApiError(403, 'Nur für Administratoren.'));
|
||
buttonByText('Withdraw')!.click();
|
||
await settle();
|
||
confirmButton('Withdraw').click();
|
||
await settle();
|
||
expect(errorText()).toBe('You are not allowed to do that.');
|
||
|
||
withLogin.mockRejectedValueOnce(new LoginCancelled());
|
||
buttonByText('Withdraw')!.click();
|
||
await settle();
|
||
confirmButton('Withdraw').click();
|
||
await settle();
|
||
expect(errorText()).toBe('Please sign in to continue.');
|
||
|
||
withLogin.mockRejectedValueOnce(new TypeError('offline'));
|
||
buttonByText('Withdraw')!.click();
|
||
await settle();
|
||
confirmButton('Withdraw').click();
|
||
await settle();
|
||
expect(errorText()).toBe('Can’t reach the server.');
|
||
expect(germanLeft(root)).toEqual([]);
|
||
});
|
||
|
||
it('Admin: Moderation, Meldungen mit Grund und Datum, Ausblenden/Einblenden mit Hinweis, Löschen mit Rückfrage', async () => {
|
||
user = admin;
|
||
getCard.mockResolvedValue(card({ reportCount: 3 }));
|
||
listReports.mockResolvedValue([report(1, 'spam'), report(2, 'kartendaten'), report(3, 'unbekannt')]);
|
||
await mount();
|
||
expect(q('.detail-admin-title')?.textContent).toBe('Moderation');
|
||
expect(q('.detail-reports-count')?.textContent).toBe('3 reports');
|
||
expect(qa('.detail-reasons li').map((li) => li.textContent)).toEqual([
|
||
'Spam · Oct 5, 2026',
|
||
'Real card data · Oct 5, 2026',
|
||
'unbekannt · Oct 5, 2026', // unbekannte Werte der API bleiben unverändert
|
||
]);
|
||
expect(buttonTexts()).toEqual(['Download', 'Use as template', 'Report', 'Hide', 'Delete']);
|
||
expect(germanLeft(root)).toEqual([]);
|
||
|
||
buttonByText('Hide')!.click();
|
||
await settle();
|
||
expect(q('.detail-hidden')?.textContent).toBe('Hidden – This card isn’t visible to others.');
|
||
expect(statusText()).toBe('Card hidden.');
|
||
buttonByText('Unhide')!.click();
|
||
await settle();
|
||
expect(statusText()).toBe('Card unhidden.');
|
||
|
||
buttonByText('Delete')!.click();
|
||
await settle();
|
||
expect(confirmEl()!.querySelector('h2')!.textContent).toBe('Delete card “Mitternachtsblau”?');
|
||
expect(confirmEl()!.querySelector('p')!.textContent).toBe('The card, its image and all reports will be deleted permanently.');
|
||
expect([...confirmEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Cancel', 'Delete']);
|
||
confirmButton('Cancel').click();
|
||
await settle();
|
||
|
||
// die Gründe lassen sich nicht laden
|
||
unmount?.();
|
||
listReports.mockRejectedValue(new TypeError('offline'));
|
||
await mount();
|
||
expect(q('.detail-admin')?.textContent).toContain('The reasons couldn’t be loaded.');
|
||
});
|
||
});
|