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:
1 parent
c4a66f674f
commit
3cec7151a6
8 files changed
+2221
-15
No files matched your search
@@ -0,0 +1,241 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// api.ts hängt am Appwrite-Client; der Dialog bekommt `send` von außen, ApiError wird nur als Klasse gebraucht.
|
||||
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
||||
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
|
||||
|
||||
import { ApiError, type ReportReason } from './api';
|
||||
import { REPORT_REASONS, reportDialog, type ReportOutcome } from './report-dialog';
|
||||
|
||||
type Send = (reason: ReportReason) => Promise<{ schonGemeldet: boolean }>;
|
||||
|
||||
const send = vi.fn<Send>();
|
||||
|
||||
const dialogEl = () => document.querySelector<HTMLDialogElement>('dialog.report-dialog');
|
||||
const buttonByText = (text: string) =>
|
||||
[...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined;
|
||||
const radios = () => [...(dialogEl()?.querySelectorAll<HTMLInputElement>('input[type="radio"]') ?? [])];
|
||||
const choose = (value: string) => {
|
||||
const radio = radios().find((r) => r.value === value) as HTMLInputElement;
|
||||
radio.checked = true;
|
||||
radio.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
};
|
||||
const submit = () => dialogEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent;
|
||||
const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent;
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
function open(opts: { cardTitle?: string; signal?: AbortSignal } = {}): Promise<ReportOutcome> {
|
||||
return reportDialog({ send, ...opts });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
send.mockReset();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('Melden-Dialog', () => {
|
||||
it('zeigt die fünf Gründe in der Reihenfolge der Spec, keiner ist vorgewählt', () => {
|
||||
void open();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte melden');
|
||||
expect(REPORT_REASONS.map((r) => r.label)).toEqual([
|
||||
'Unangemessen',
|
||||
'Echte Kartendaten',
|
||||
'Urheberrecht/Marke',
|
||||
'Spam',
|
||||
'Sonstiges',
|
||||
]);
|
||||
expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
|
||||
'Unangemessen',
|
||||
'Echte Kartendaten',
|
||||
'Urheberrecht/Marke',
|
||||
'Spam',
|
||||
'Sonstiges',
|
||||
]);
|
||||
expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']);
|
||||
expect(radios().some((r) => r.checked)).toBe(false);
|
||||
expect(buttonByText('Abbrechen')).toBeTruthy();
|
||||
expect(buttonByText('Melden')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('nennt den Kartentitel als Text, nie als HTML', () => {
|
||||
void open({ cardTitle: '<img src=x onerror=alert(1)>' });
|
||||
expect(dialogEl()!.querySelector('img')).toBeNull();
|
||||
expect(dialogEl()!.textContent).toContain('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
|
||||
it('ohne Grund: Hinweis, nichts wird gesendet', async () => {
|
||||
void open();
|
||||
submit();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Bitte wähle einen Grund.');
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unangemessen', 'unangemessen'],
|
||||
['kartendaten', 'kartendaten'],
|
||||
['urheberrecht', 'urheberrecht'],
|
||||
['spam', 'spam'],
|
||||
['sonstiges', 'sonstiges'],
|
||||
])('Grund %s geht als %s an die API', async (value, expected) => {
|
||||
send.mockResolvedValue({ schonGemeldet: false });
|
||||
void open();
|
||||
choose(value);
|
||||
submit();
|
||||
await settle();
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send).toHaveBeenCalledWith(expected);
|
||||
});
|
||||
|
||||
it('neue Meldung: Dank im Dialog, „Schließen“ beendet mit „reported“', async () => {
|
||||
send.mockResolvedValue({ schonGemeldet: false });
|
||||
const result = open();
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
expect(statusText()).toBe('Danke für deine Meldung.');
|
||||
expect(errorText()).toBe('');
|
||||
expect(radios()).toHaveLength(0);
|
||||
expect(buttonByText('Melden')).toBeUndefined();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
buttonByText('Schließen')!.click();
|
||||
await expect(result).resolves.toBe('reported');
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('doppelte Meldung: „Danke, du hast diese Karte schon gemeldet.“ und „duplicate“', async () => {
|
||||
send.mockResolvedValue({ schonGemeldet: true });
|
||||
const result = open();
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
expect(statusText()).toBe('Danke, du hast diese Karte schon gemeldet.');
|
||||
buttonByText('Schließen')!.click();
|
||||
await expect(result).resolves.toBe('duplicate');
|
||||
});
|
||||
|
||||
it('Drosselung (429): der Servertext erscheint, der Dialog bleibt und lässt einen neuen Versuch zu', async () => {
|
||||
send
|
||||
.mockRejectedValueOnce(new ApiError(429, 'Zu viele Meldungen – bitte später nochmal.'))
|
||||
.mockResolvedValueOnce({ schonGemeldet: false });
|
||||
const result = open();
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Zu viele Meldungen – bitte später nochmal.');
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(buttonByText('Melden')!.disabled).toBe(false);
|
||||
expect(radios().every((r) => !r.disabled)).toBe(true);
|
||||
// Die Auswahl bleibt stehen.
|
||||
expect(radios().find((r) => r.checked)?.value).toBe('spam');
|
||||
|
||||
submit();
|
||||
await settle();
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(errorText()).toBe('');
|
||||
expect(statusText()).toBe('Danke für deine Meldung.');
|
||||
buttonByText('Schließen')!.click();
|
||||
await expect(result).resolves.toBe('reported');
|
||||
});
|
||||
|
||||
it('andere Fehler des Dienstes zeigen ihren Servertext (z. B. 404, 503)', async () => {
|
||||
send.mockRejectedValueOnce(new ApiError(404, 'Diese Karte gibt es nicht (mehr).'));
|
||||
void open();
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Diese Karte gibt es nicht (mehr).');
|
||||
|
||||
send.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
|
||||
submit();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.');
|
||||
});
|
||||
|
||||
it('ein unerwarteter Fehler (kein ApiError) bekommt einen allgemeinen deutschen Text', async () => {
|
||||
send.mockRejectedValueOnce(new TypeError('offline'));
|
||||
void open();
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Keine Verbindung zum Server.');
|
||||
});
|
||||
|
||||
it('während des Sendens ist alles gesperrt, ein zweites Absenden sendet nichts', async () => {
|
||||
let finish!: (v: { schonGemeldet: boolean }) => void;
|
||||
send.mockReturnValue(new Promise((r) => (finish = r)));
|
||||
void open();
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
expect(buttonByText('Melden')!.disabled).toBe(true);
|
||||
expect(radios().every((r) => r.disabled)).toBe(true);
|
||||
expect(dialogEl()!.querySelector('form')!.getAttribute('aria-busy')).toBe('true');
|
||||
submit();
|
||||
await settle();
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
finish({ schonGemeldet: false });
|
||||
await settle();
|
||||
expect(statusText()).toBe('Danke für deine Meldung.');
|
||||
});
|
||||
|
||||
it('Abbrechen schließt ohne zu senden: „cancelled“', async () => {
|
||||
const result = open();
|
||||
buttonByText('Abbrechen')!.click();
|
||||
await expect(result).resolves.toBe('cancelled');
|
||||
expect(dialogEl()).toBeNull();
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Esc (close-Ereignis) und Klick auf den Hintergrund schließen ebenfalls', async () => {
|
||||
const first = open();
|
||||
dialogEl()!.dispatchEvent(new Event('close'));
|
||||
await expect(first).resolves.toBe('cancelled');
|
||||
expect(dialogEl()).toBeNull();
|
||||
|
||||
const second = open();
|
||||
const dialog = dialogEl()!;
|
||||
dialog.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
dialog.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await expect(second).resolves.toBe('cancelled');
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('ein Klick im Inneren schließt nicht', () => {
|
||||
void open();
|
||||
dialogEl()!.querySelector('.report-card')!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
});
|
||||
|
||||
it('das Signal (Seite verlassen) schließt den Dialog; eine spät eintreffende Antwort ändert nichts', async () => {
|
||||
let finish!: (v: { schonGemeldet: boolean }) => void;
|
||||
send.mockReturnValue(new Promise((r) => (finish = r)));
|
||||
const controller = new AbortController();
|
||||
const result = open({ signal: controller.signal });
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
controller.abort();
|
||||
await expect(result).resolves.toBe('cancelled');
|
||||
expect(dialogEl()).toBeNull();
|
||||
finish({ schonGemeldet: false });
|
||||
await settle();
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('ein schon abgebrochenes Signal öffnet gar nicht erst', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(open({ signal: controller.signal })).resolves.toBe('cancelled');
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user