Schlussreview F4. Zeilen und Bilder der Community-Logos (logo-asset.ts) bleiben bis zum Neuladen der Seite im Speicher. Wurde ein Logo danach ausgeblendet oder gelöscht, konnte es aus diesem Zwischenspeicher in ein veröffentlichtes Kartenbild oder in ein heruntergeladenes PNG gelangen. - renderForGallery (Veröffentlichen), der Download im Editor und der Download in „Meine Karten“ rufen vor dem Laden der Bilder forgetLogo(config.logo) (falls eines gewählt ist): Zeile und Bild werden neu vom Server geholt, hidden/fehlt wird also neu geprüft. Veröffentlichen bricht bei einem fehlenden Logo wie bisher mit LOGO_MISSING_PUBLISH ab, „Meine Karten“ meldet es an der Kachel. - Der Download im Editor berücksichtigt assets.logoMissing wie der Hinweis im Editor: das PNG entsteht ohne das fehlende Logo (die Vorschau zeigte es bis dahin noch), der Hinweis „Das gewählte Logo ist nicht mehr verfügbar.“ erscheint und die Vorschau zeichnet ohne Logo, damit PNG und Vorschau dasselbe zeigen. Ist ein Logo wieder da, folgt die Vorschau ebenso. Das gilt nur, solange die Auswahl noch dieselbe ist. - Tests mit dem echten Zwischenspeicher (nur Zeile und Datei aus logos.ts ersetzt): erst geladen, dann ausgeblendet bzw. gelöscht → neu geprüft, Veröffentlichen bricht ab / „Meine Karten“ meldet / der Editor lädt ohne Logo herunter, zeigt den Hinweis (auch zusammen mit dem Größenhinweis) und zeichnet neu; sichtbares Logo wird frisch geholt; Karte ohne Logo holt nichts; „Nur Bild“ bleibt unberührt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
574 lines
27 KiB
TypeScript
574 lines
27 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import { DEFAULT_CONFIG, type TemplateId } from '../card/config';
|
||
import type { SavedCard } from '../account/cards';
|
||
|
||
const mocks = vi.hoisted(() => ({
|
||
createJWT: vi.fn(),
|
||
decodeImage: vi.fn(),
|
||
loadCardAssets: vi.fn(),
|
||
exportCard: vi.fn(),
|
||
getLogo: vi.fn(),
|
||
loadLogoBlob: vi.fn(),
|
||
}));
|
||
|
||
// Die Standard-Abhängigkeiten (Appwrite, Canvas) kommen in diesen Tests nie zum Einsatz;
|
||
// nur createJWT wird für den Test mit der echten API-Schicht gebraucht.
|
||
vi.mock('../account/appwrite', () => ({
|
||
IDS: { galleryBucket: 'gallery-bucket' },
|
||
account: { createJWT: mocks.createJWT },
|
||
client: {},
|
||
tables: {},
|
||
storage: {},
|
||
APPWRITE_ENDPOINT: 'https://aircard.test/v1',
|
||
}));
|
||
// Rendern (Bild dekodieren, Logos laden, Canvas) ist für renderForGallery ersetzt.
|
||
vi.mock('../card/render', () => ({ loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard }));
|
||
// Zeile und Datei eines Community-Logos kommen aus der Datenschicht; nur der Zwischenspeicher darüber (logo-asset.ts) ist echt.
|
||
vi.mock('../logos/logos', () => ({ getLogo: mocks.getLogo, loadLogoBlob: mocks.loadLogoBlob }));
|
||
vi.mock('../editor/upload', async (importOriginal) => ({
|
||
...(await importOriginal<typeof import('../editor/upload')>()),
|
||
decodeImage: mocks.decodeImage,
|
||
}));
|
||
|
||
import { forgetLogo, loadLogoAsset } from '../logos/logo-asset';
|
||
import type { CommunityLogo } from '../logos/logos';
|
||
import { ApiError, publishCard } from './api';
|
||
import {
|
||
LOGO_MISSING_PUBLISH,
|
||
PNG_MAX_BYTES,
|
||
assertLogoAvailable,
|
||
normalizeTags,
|
||
publishSavedCard,
|
||
renderForGallery,
|
||
type PublishDeps,
|
||
} from './publish';
|
||
|
||
const card: SavedCard = {
|
||
id: 'card1',
|
||
name: 'Meine Karte',
|
||
imageFileId: 'privImage',
|
||
previewFileId: 'privPreview',
|
||
updatedAt: '2026-10-04T10:00:00.000Z',
|
||
config: DEFAULT_CONFIG,
|
||
};
|
||
|
||
const form = { title: 'Meine Karte', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' };
|
||
|
||
const png = () => new Blob(['png'], { type: 'image/png' });
|
||
const webp = () => new Blob(['webp'], { type: 'image/webp' });
|
||
|
||
type Call = { name: string; args: unknown[] };
|
||
|
||
/** Fake-Abhängigkeiten, die jeden Aufruf in Reihenfolge aufzeichnen; Dateien bekommen die IDs f1, f2, … */
|
||
function fakeDeps(overrides: Partial<PublishDeps> = {}) {
|
||
const calls: Call[] = [];
|
||
let files = 0;
|
||
const log = <T>(name: string, args: unknown[], result: T): T => {
|
||
calls.push({ name, args });
|
||
return result;
|
||
};
|
||
const deps: PublishDeps = {
|
||
userId: () => log('userId', [], 'u1'),
|
||
loadImage: async (c) => log('loadImage', [c], new Blob(['src'], { type: 'image/jpeg' })),
|
||
render: async (c, image) => log('render', [c, image], { png: png(), preview: webp() }),
|
||
createFile: async (file, permissions) => log('createFile', [file, permissions], { $id: `f${++files}` }),
|
||
deleteFile: async (id) => log('deleteFile', [id], undefined),
|
||
publishCard: async (input) => log('publishCard', [input], { id: 'g1' }),
|
||
...overrides,
|
||
};
|
||
return {
|
||
deps,
|
||
calls,
|
||
names: () => calls.map((c) => c.name),
|
||
of: (name: string) => calls.filter((c) => c.name === name),
|
||
};
|
||
}
|
||
|
||
describe('publishSavedCard', () => {
|
||
it('Reihenfolge: Nutzer, Bild laden, rendern, Upload, Upload, API; liefert { id }', async () => {
|
||
const fake = fakeDeps();
|
||
await expect(publishSavedCard(card, form, fake.deps)).resolves.toEqual({ id: 'g1' });
|
||
expect(fake.names()).toEqual(['userId', 'loadImage', 'render', 'createFile', 'createFile', 'publishCard']);
|
||
expect(fake.of('loadImage')[0].args[0]).toBe(card);
|
||
expect(fake.of('render')[0].args[0]).toBe(card);
|
||
});
|
||
|
||
it('lädt zuerst das PNG, dann die Vorschau hoch; beide mit read(any) und delete(user:<ich>)', async () => {
|
||
const fake = fakeDeps();
|
||
await publishSavedCard(card, form, fake.deps);
|
||
const [first, second] = fake.of('createFile');
|
||
expect((first.args[0] as File).name).toBe('image.png');
|
||
expect((first.args[0] as File).type).toBe('image/png');
|
||
expect((second.args[0] as File).name).toBe('preview.webp');
|
||
expect((second.args[0] as File).type).toBe('image/webp');
|
||
for (const call of [first, second]) {
|
||
expect(call.args[1]).toEqual(['read("any")', 'delete("user:u1")']);
|
||
}
|
||
});
|
||
|
||
it('benennt die Vorschau nach dem echten Blob-Typ (Browser ohne WebP-Export liefern PNG)', async () => {
|
||
const fake = fakeDeps({ render: async () => ({ png: png(), preview: png() }) });
|
||
await publishSavedCard(card, form, fake.deps);
|
||
const preview = fake.of('createFile')[1].args[0] as File;
|
||
expect(preview.name).toBe('preview.png');
|
||
expect(preview.type).toBe('image/png');
|
||
});
|
||
|
||
it('schickt der API beide neuen Datei-IDs, die Karten-ID und die bereinigten Felder (Tags als Liste)', async () => {
|
||
const fake = fakeDeps();
|
||
await publishSavedCard(card, { title: ' Meine Karte ', issuer: ' Bank ', tags: 'Visa, Gold , visa,,', displayName: ' K ' }, fake.deps);
|
||
expect(fake.of('publishCard')[0].args[0]).toEqual({
|
||
title: 'Meine Karte',
|
||
issuer: 'Bank',
|
||
tags: ['visa', 'gold'],
|
||
displayName: 'K',
|
||
imageFileId: 'f1',
|
||
previewFileId: 'f2',
|
||
sourceCardId: 'card1',
|
||
});
|
||
});
|
||
|
||
describe('Fehler der API: gelöscht wird nur bei eindeutiger Ablehnung', () => {
|
||
it.each([400, 401, 403, 404, 409])(
|
||
'eindeutig abgelehnt (%i): beide Dateien dieses Versuchs werden gelöscht, der Fehler kommt an',
|
||
async (code) => {
|
||
const failure = new ApiError(code, 'Abgelehnt.');
|
||
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
|
||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
||
expect(fake.of('deleteFile').map((c) => c.args[0]).sort()).toEqual(['f1', 'f2']);
|
||
},
|
||
);
|
||
|
||
it.each([408, 429, 500, 502, 503, 504])(
|
||
'unklarer Ausgang (%i): die Zeile kann angelegt sein, die Dateien bleiben liegen, der Fehler kommt an',
|
||
async (code) => {
|
||
const failure = new ApiError(code, 'Unklar.');
|
||
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
|
||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
||
expect(fake.of('deleteFile')).toHaveLength(0);
|
||
},
|
||
);
|
||
|
||
it('JWT-Methode am Server aus (ApiError mit definitive-Flag, es ging keine Anfrage raus): gelöscht', async () => {
|
||
const failure = new ApiError(501, 'Anmeldung per Token ist auf dem Server ausgeschaltet.', true);
|
||
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
|
||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
||
expect(fake.of('deleteFile')).toHaveLength(2);
|
||
});
|
||
|
||
it('ein anderer Fehler vor der Anfrage (z. B. createJWT mit 401): gelöscht', async () => {
|
||
const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' });
|
||
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
|
||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
||
expect(fake.of('deleteFile')).toHaveLength(2);
|
||
});
|
||
});
|
||
|
||
it('zweiter Upload scheitert: nur der erste wird gelöscht, die API wird nicht gerufen', async () => {
|
||
const failure = new Error('upload kaputt');
|
||
let uploads = 0;
|
||
const fake = fakeDeps({
|
||
createFile: async (file, permissions) => {
|
||
fake.calls.push({ name: 'createFile', args: [file, permissions] });
|
||
if (++uploads === 2) throw failure;
|
||
return { $id: 'f1' };
|
||
},
|
||
});
|
||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
||
expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['f1']);
|
||
expect(fake.of('publishCard')).toHaveLength(0);
|
||
});
|
||
|
||
it('erster Upload scheitert: nichts zu löschen, kein zweiter Upload', async () => {
|
||
const failure = new Error('upload kaputt');
|
||
const fake = fakeDeps({ createFile: async () => Promise.reject(failure) });
|
||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
||
expect(fake.of('deleteFile')).toHaveLength(0);
|
||
expect(fake.of('publishCard')).toHaveLength(0);
|
||
});
|
||
|
||
it('löscht nie etwas anderes als die zwei Dateien dieses Versuchs (nicht die privaten Dateien der Karte)', async () => {
|
||
const fake = fakeDeps({ publishCard: async () => Promise.reject(new ApiError(403, 'Dazu hast du keine Berechtigung.')) });
|
||
await publishSavedCard(card, form, fake.deps).catch(() => undefined);
|
||
const deleted = fake.of('deleteFile').map((c) => c.args[0]);
|
||
expect(deleted).not.toContain('privImage');
|
||
expect(deleted).not.toContain('privPreview');
|
||
expect(deleted.every((id) => id === 'f1' || id === 'f2')).toBe(true);
|
||
});
|
||
|
||
it('ein Fehler beim Aufräumen überdeckt den ursprünglichen Fehler nicht und stoppt das Aufräumen nicht', async () => {
|
||
const failure = new ApiError(400, 'Bitte gib einen Titel an.');
|
||
const attempted: string[] = [];
|
||
const fake = fakeDeps({
|
||
publishCard: async () => Promise.reject(failure),
|
||
deleteFile: async (id) => {
|
||
attempted.push(id);
|
||
throw new Error('delete kaputt');
|
||
},
|
||
});
|
||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
||
expect(attempted.sort()).toEqual(['f1', 'f2']);
|
||
});
|
||
|
||
it('bei Erfolg wird nichts gelöscht', async () => {
|
||
const fake = fakeDeps();
|
||
await publishSavedCard(card, form, fake.deps);
|
||
expect(fake.of('deleteFile')).toHaveLength(0);
|
||
});
|
||
|
||
describe('vor dem Hochladen', () => {
|
||
it('ein PNG über 5 MiB wird nicht hochgeladen: „Das Kartenbild ist zu groß für die Galerie (max. 5 MB).“', async () => {
|
||
const big = new Blob([new Uint8Array(PNG_MAX_BYTES + 1)], { type: 'image/png' });
|
||
const fake = fakeDeps({ render: async () => ({ png: big, preview: webp() }) });
|
||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toThrow(
|
||
'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).',
|
||
);
|
||
expect(fake.of('createFile')).toHaveLength(0);
|
||
expect(fake.of('deleteFile')).toHaveLength(0);
|
||
expect(fake.of('publishCard')).toHaveLength(0);
|
||
});
|
||
|
||
it('genau 5 MiB sind erlaubt', async () => {
|
||
const exact = new Blob([new Uint8Array(PNG_MAX_BYTES)], { type: 'image/png' });
|
||
const fake = fakeDeps({ render: async () => ({ png: exact, preview: webp() }) });
|
||
await expect(publishSavedCard(card, form, fake.deps)).resolves.toEqual({ id: 'g1' });
|
||
});
|
||
|
||
it('ein Bild mit unzulässigem Typ wird nicht hochgeladen', async () => {
|
||
const fake = fakeDeps({ render: async () => ({ png: new Blob(['x'], { type: 'image/jpeg' }), preview: webp() }) });
|
||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toThrow('Das Kartenbild konnte nicht erzeugt werden.');
|
||
expect(fake.of('createFile')).toHaveLength(0);
|
||
});
|
||
|
||
it('scheitert das Rendern, wird nichts hochgeladen oder gelöscht', async () => {
|
||
const failure = new Error('Das Bild konnte nicht gelesen werden.');
|
||
const fake = fakeDeps({ render: async () => Promise.reject(failure) });
|
||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
||
expect(fake.names()).toEqual(['userId', 'loadImage']);
|
||
});
|
||
|
||
it('scheitert das Laden des Bildes, passiert nichts weiter', async () => {
|
||
const failure = Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' });
|
||
const fake = fakeDeps({ loadImage: async () => Promise.reject(failure) });
|
||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
||
expect(fake.names()).toEqual(['userId']);
|
||
});
|
||
|
||
it('ohne angemeldeten Nutzer wird weder geladen noch gerendert', async () => {
|
||
const failure = Object.assign(new Error('unauthorized'), { code: 401 });
|
||
const fake = fakeDeps({
|
||
userId: () => {
|
||
throw failure;
|
||
},
|
||
});
|
||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
||
expect(fake.names()).toEqual([]);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('Logo der Karte beim Veröffentlichen', () => {
|
||
const photo = { source: {}, w: 3000, h: 2000 };
|
||
const source = new Blob(['src'], { type: 'image/jpeg' });
|
||
const withLogo = (logo: string | null, template: TemplateId = DEFAULT_CONFIG.template): SavedCard => ({
|
||
...card,
|
||
config: { ...DEFAULT_CONFIG, template, logo },
|
||
});
|
||
/** Die Vorlagen, die das Community-Logo zeichnen (alle außer „Nur Bild“). */
|
||
const DRAWING = ['minimal', 'classic', 'signature'] as const;
|
||
const assetsFor = (patch: Record<string, unknown> = {}) => ({ image: photo, logo: null, brand: null, logoMissing: false, ...patch });
|
||
|
||
beforeEach(() => {
|
||
mocks.decodeImage.mockReset().mockResolvedValue(photo);
|
||
mocks.loadCardAssets.mockReset().mockResolvedValue(assetsFor());
|
||
mocks.exportCard
|
||
.mockReset()
|
||
.mockImplementation(async (_config: unknown, _assets: unknown, opts: { type: string }) => new Blob(['x'], { type: opts.type }));
|
||
});
|
||
|
||
it('LOGO_MISSING_PUBLISH: der Wortlaut aus der Spec, auf Englisch übersetzt', async () => {
|
||
const { t, setLang } = await import('../i18n/i18n');
|
||
expect(LOGO_MISSING_PUBLISH).toBe(
|
||
'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
|
||
);
|
||
try {
|
||
setLang('en', { persist: false });
|
||
expect(t(LOGO_MISSING_PUBLISH)).toBe('This card’s logo is no longer available. Open the card in the editor and choose another one.');
|
||
} finally {
|
||
setLang('de', { persist: false });
|
||
}
|
||
});
|
||
|
||
it('renderForGallery: rendert mit dem Logo der Karte als PNG (1536 px) und WebP-Vorschau (480 px)', async () => {
|
||
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logo: { source: {}, w: 200, h: 100 } }));
|
||
const out = await renderForGallery(withLogo('abc123'), source);
|
||
expect(mocks.decodeImage).toHaveBeenCalledWith(source);
|
||
expect(mocks.loadCardAssets).toHaveBeenCalledWith(expect.objectContaining({ logo: 'abc123' }), photo);
|
||
expect(mocks.exportCard).toHaveBeenCalledTimes(2);
|
||
expect(mocks.exportCard.mock.calls[0][2]).toEqual({ width: 1536, type: 'image/png' });
|
||
expect(mocks.exportCard.mock.calls[1][2]).toEqual({ width: 480, type: 'image/webp', quality: 0.85 });
|
||
expect([out.png.type, out.preview.type]).toEqual(['image/png', 'image/webp']);
|
||
});
|
||
|
||
it('eine Karte ohne Logo wird ganz normal gerendert', async () => {
|
||
const out = await renderForGallery(withLogo(null), source);
|
||
expect(mocks.loadCardAssets).toHaveBeenCalledWith(expect.objectContaining({ logo: null }), photo);
|
||
expect(out.png.type).toBe('image/png');
|
||
});
|
||
|
||
it.each(DRAWING)(
|
||
'fehlendes Logo (gelöscht oder ausgeblendet), Vorlage %s zeichnet es: Abbruch mit LOGO_MISSING_PUBLISH, es wird nichts gerendert',
|
||
async (template) => {
|
||
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
|
||
await expect(renderForGallery(withLogo('gone', template), source)).rejects.toThrow(LOGO_MISSING_PUBLISH);
|
||
expect(mocks.exportCard).not.toHaveBeenCalled();
|
||
},
|
||
);
|
||
|
||
it('fehlendes Logo bei „Nur Bild“ (image): die Vorlage zeichnet es nie, es wird ganz normal gerendert', async () => {
|
||
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
|
||
const out = await renderForGallery(withLogo('gone', 'image'), source);
|
||
expect(mocks.exportCard).toHaveBeenCalledTimes(2);
|
||
expect(mocks.exportCard.mock.calls[0][2]).toEqual({ width: 1536, type: 'image/png' });
|
||
expect([out.png.type, out.preview.type]).toEqual(['image/png', 'image/webp']);
|
||
});
|
||
|
||
it('ein Ladefehler der Logos (Netz) ist kein fehlendes Logo: der Fehler kommt unverändert an', async () => {
|
||
const failure = new TypeError('Failed to fetch');
|
||
mocks.loadCardAssets.mockRejectedValue(failure);
|
||
await expect(renderForGallery(withLogo('abc123'), source)).rejects.toBe(failure);
|
||
});
|
||
|
||
it('publishSavedCard: classic mit fehlendem Logo – nichts wird hochgeladen, nichts gelöscht, die API nicht gerufen', async () => {
|
||
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
|
||
const fake = fakeDeps({ render: renderForGallery });
|
||
await expect(publishSavedCard(withLogo('gone', 'classic'), form, fake.deps)).rejects.toThrow(LOGO_MISSING_PUBLISH);
|
||
expect(fake.names()).toEqual(['userId', 'loadImage']);
|
||
expect(fake.of('createFile')).toHaveLength(0);
|
||
expect(fake.of('deleteFile')).toHaveLength(0);
|
||
expect(fake.of('publishCard')).toHaveLength(0);
|
||
});
|
||
|
||
it('publishSavedCard: „Nur Bild“ (image) mit fehlendem Logo – wird veröffentlicht, als wäre nichts', async () => {
|
||
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
|
||
const fake = fakeDeps({ render: renderForGallery });
|
||
await expect(publishSavedCard(withLogo('gone', 'image'), form, fake.deps)).resolves.toEqual({ id: 'g1' });
|
||
expect(fake.names()).toEqual(['userId', 'loadImage', 'createFile', 'createFile', 'publishCard']);
|
||
expect(fake.of('deleteFile')).toHaveLength(0);
|
||
});
|
||
|
||
it('publishSavedCard: mit vorhandenem Logo läuft alles wie sonst', async () => {
|
||
const fake = fakeDeps({ render: renderForGallery });
|
||
await expect(publishSavedCard(withLogo('abc123'), form, fake.deps)).resolves.toEqual({ id: 'g1' });
|
||
expect(fake.names()).toEqual(['userId', 'loadImage', 'createFile', 'createFile', 'publishCard']);
|
||
});
|
||
});
|
||
|
||
describe('Zwischenspeicher der Logos beim Veröffentlichen (ein ausgeblendetes Logo darf nicht in die Galerie)', () => {
|
||
const photo = { source: {}, w: 3000, h: 2000 };
|
||
const source = new Blob(['src'], { type: 'image/jpeg' });
|
||
const logoRow = (patch: Partial<CommunityLogo> = {}): CommunityLogo => ({
|
||
id: 'zw1',
|
||
name: 'Mein Logo',
|
||
displayName: 'Kenso',
|
||
fileId: 'zw1',
|
||
ownerId: 'u1',
|
||
mono: false,
|
||
likeCount: 0,
|
||
hidden: false,
|
||
reportCount: 0,
|
||
createdAt: '2026-10-04T10:00:00.000Z',
|
||
...patch,
|
||
});
|
||
const withLogo = (id: string, template: TemplateId = 'classic'): SavedCard => ({ ...card, config: { ...DEFAULT_CONFIG, template, logo: id } });
|
||
|
||
beforeEach(() => {
|
||
mocks.decodeImage.mockReset().mockResolvedValue(photo);
|
||
mocks.exportCard
|
||
.mockReset()
|
||
.mockImplementation(async (_config: unknown, _assets: unknown, opts: { type: string }) => new Blob(['x'], { type: opts.type }));
|
||
mocks.getLogo.mockReset().mockResolvedValue(logoRow());
|
||
mocks.loadLogoBlob.mockReset().mockResolvedValue(new Blob(['png'], { type: 'image/png' }));
|
||
// Wie der echte Lader (card/render.ts: loadCardAssets), mit dem echten Zwischenspeicher der Logos darunter.
|
||
mocks.loadCardAssets.mockReset().mockImplementation(async (config: { logo: string | null; details: 'light' | 'dark' }, image: unknown) => {
|
||
const logo = config.logo ? await loadLogoAsset(config.logo, config.details) : null;
|
||
return { image, logo, brand: null, logoMissing: Boolean(config.logo) && logo === null };
|
||
});
|
||
vi.stubGlobal('createImageBitmap', async () => ({ width: 200, height: 100 }));
|
||
});
|
||
|
||
afterEach(() => {
|
||
forgetLogo('zw1');
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('ein Logo, das nach dem ersten Laden (Editor, Zwischenspeicher) ausgeblendet wurde, wird neu geprüft: Abbruch, nichts wird hochgeladen', async () => {
|
||
await expect(loadLogoAsset('zw1', 'light')).resolves.not.toBeNull(); // der Editor hat es geladen: Zeile und Bild stecken im Zwischenspeicher
|
||
mocks.getLogo.mockResolvedValue(logoRow({ hidden: true })); // dann hat ein Admin oder die Melder es ausgeblendet
|
||
const fake = fakeDeps({ render: renderForGallery });
|
||
await expect(publishSavedCard(withLogo('zw1'), form, fake.deps)).rejects.toThrow(LOGO_MISSING_PUBLISH);
|
||
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
|
||
expect(mocks.exportCard).not.toHaveBeenCalled();
|
||
expect(fake.of('createFile')).toHaveLength(0);
|
||
expect(fake.of('publishCard')).toHaveLength(0);
|
||
});
|
||
|
||
it('ein Logo, das nach dem ersten Laden gelöscht wurde, ebenso', async () => {
|
||
await expect(loadLogoAsset('zw1', 'light')).resolves.not.toBeNull();
|
||
mocks.getLogo.mockResolvedValue(null);
|
||
await expect(renderForGallery(withLogo('zw1'), source)).rejects.toThrow(LOGO_MISSING_PUBLISH);
|
||
expect(mocks.exportCard).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('ein weiterhin sichtbares Logo wird mit frisch geholter Zeile und Datei gerendert (kein Zwischenspeicher-Treffer)', async () => {
|
||
await expect(loadLogoAsset('zw1', 'light')).resolves.not.toBeNull();
|
||
const out = await renderForGallery(withLogo('zw1'), source);
|
||
expect(out.png.type).toBe('image/png');
|
||
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
|
||
expect(mocks.loadLogoBlob).toHaveBeenCalledTimes(2);
|
||
expect(mocks.exportCard.mock.calls[0][1]).toMatchObject({ logo: { w: 200, h: 100 } });
|
||
});
|
||
|
||
it('beide Tonarten des Logos werden vergessen, nicht nur die der Karte', async () => {
|
||
await loadLogoAsset('zw1', 'light');
|
||
await loadLogoAsset('zw1', 'dark');
|
||
mocks.getLogo.mockResolvedValue(logoRow({ hidden: true }));
|
||
await expect(renderForGallery({ ...withLogo('zw1'), config: { ...withLogo('zw1').config, details: 'dark' } }, source)).rejects.toThrow(
|
||
LOGO_MISSING_PUBLISH,
|
||
);
|
||
await expect(loadLogoAsset('zw1', 'light')).resolves.toBeNull(); // auch das helle Bild ist weg: es wird neu geprüft
|
||
});
|
||
|
||
it('eine Karte ohne Logo vergisst und holt nichts', async () => {
|
||
const out = await renderForGallery({ ...card, config: { ...DEFAULT_CONFIG, logo: null } }, source);
|
||
expect(out.png.type).toBe('image/png');
|
||
expect(mocks.getLogo).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('„Nur Bild“ (image) zeichnet nie ein Logo: auch ein inzwischen ausgeblendetes Logo hindert das Veröffentlichen nicht', async () => {
|
||
await loadLogoAsset('zw1', 'light');
|
||
mocks.getLogo.mockResolvedValue(logoRow({ hidden: true }));
|
||
const out = await renderForGallery(withLogo('zw1', 'image'), source);
|
||
expect(out.png.type).toBe('image/png');
|
||
expect(mocks.exportCard).toHaveBeenCalledTimes(2);
|
||
});
|
||
});
|
||
|
||
describe('assertLogoAvailable', () => {
|
||
const config = (template: TemplateId) => ({ ...DEFAULT_CONFIG, template, logo: 'abc123' });
|
||
const assets = (logoMissing: boolean) => ({ image: null, logo: null, brand: null, logoMissing });
|
||
|
||
it.each(['minimal', 'classic', 'signature'] as const)('Logo fehlt und Vorlage %s zeichnet es: wirft LOGO_MISSING_PUBLISH', (template) => {
|
||
expect(() => assertLogoAvailable(config(template), assets(true))).toThrow(LOGO_MISSING_PUBLISH);
|
||
});
|
||
|
||
it('Logo fehlt, aber „Nur Bild“ zeichnet nie ein Logo: wirft nicht', () => {
|
||
expect(() => assertLogoAvailable(config('image'), assets(true))).not.toThrow();
|
||
});
|
||
|
||
it.each(['image', 'minimal', 'classic', 'signature'] as const)('Logo ist da (oder keins gewählt): Vorlage %s wirft nicht', (template) => {
|
||
expect(() => assertLogoAvailable(config(template), assets(false))).not.toThrow();
|
||
});
|
||
});
|
||
|
||
describe('mit der echten API-Schicht (publishCard, apiPost) und gestubbtem fetch', () => {
|
||
const json = (status: number, body: unknown) =>
|
||
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||
|
||
const fetchMock = vi.fn<(url: string, init: RequestInit) => Promise<Response>>();
|
||
|
||
beforeEach(() => {
|
||
mocks.createJWT.mockReset();
|
||
mocks.createJWT.mockResolvedValue({ jwt: 'jwt' });
|
||
fetchMock.mockReset();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
/** Läuft den ganzen Ablauf mit der echten publishCard; `respond` erzeugt bei jedem Aufruf eine neue Antwort. */
|
||
async function run(respond: () => Promise<Response>) {
|
||
fetchMock.mockImplementation(respond);
|
||
const fake = fakeDeps({ publishCard });
|
||
const result = await publishSavedCard(card, form, fake.deps).catch((e: unknown) => e);
|
||
return { fake, result, deleted: fake.of('deleteFile').map((c) => c.args[0]).sort() };
|
||
}
|
||
|
||
it('201 mit id: Erfolg, nichts gelöscht', async () => {
|
||
const { result, deleted } = await run(async () => json(201, { id: 'g1' }));
|
||
expect(result).toEqual({ id: 'g1' });
|
||
expect(deleted).toEqual([]);
|
||
});
|
||
|
||
it.each([
|
||
['400 Validierung', async () => json(400, { error: 'Bitte gib einen Titel an.' })],
|
||
['401 (auch nach der Wiederholung)', async () => json(401, { error: 'Bitte melde dich an.' })],
|
||
['403 fremde Datei', async () => json(403, { error: 'Diese Datei gehört dir nicht.' })],
|
||
['403 Dateien belegt oder gesperrt', async () => json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' })],
|
||
['409 schon veröffentlicht', async () => json(409, { error: 'Diese Karte ist schon veröffentlicht.' })],
|
||
])('%s: beide Dateien werden gelöscht', async (_name, respond) => {
|
||
const { result, deleted } = await run(respond);
|
||
expect(result).toBeInstanceOf(ApiError);
|
||
expect(deleted).toEqual(['f1', 'f2']);
|
||
});
|
||
|
||
it.each([
|
||
['429 Drosselung', async () => json(429, { error: 'Zu viele Anfragen.' })],
|
||
['408 Zeitüberschreitung', async () => json(408, { error: 'Zeit abgelaufen.' })],
|
||
['500 Serverfehler', async () => json(500, { error: 'Interner Fehler.' })],
|
||
['502 Dienst nicht erreichbar', async () => json(502, { error: 'Der Dienst ist gerade nicht erreichbar.' })],
|
||
['503 vom Proxy', async () => new Response('<html>503</html>', { status: 503 })],
|
||
['504 vom Proxy', async () => new Response('<html>504</html>', { status: 504 })],
|
||
['Netzfehler (Antwort verloren)', async () => Promise.reject(new TypeError('Failed to fetch'))],
|
||
['Zeitüberschreitung des Clients', async () => Promise.reject(new DOMException('timed out', 'TimeoutError'))],
|
||
['HTML mit Status 200', async () => new Response('<!doctype html>', { status: 200 })],
|
||
['201 ohne id', async () => json(201, {})],
|
||
])('%s: Ausgang offen, die Dateien bleiben liegen', async (_name, respond) => {
|
||
const { result, deleted } = await run(respond);
|
||
expect(result).toBeInstanceOf(ApiError);
|
||
expect(deleted).toEqual([]);
|
||
});
|
||
|
||
it('createJWT scheitert mit 401 (keine Sitzung): es ging nichts raus, die Dateien werden gelöscht', async () => {
|
||
mocks.createJWT.mockRejectedValue(Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' }));
|
||
const { result, deleted } = await run(async () => json(201, { id: 'g1' }));
|
||
expect(result).toMatchObject({ code: 401 });
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
expect(deleted).toEqual(['f1', 'f2']);
|
||
});
|
||
|
||
it('JWT-Methode am Server aus: es ging nichts raus, die Dateien werden gelöscht', async () => {
|
||
mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 501, type: 'user_auth_method_unsupported' }));
|
||
const { result, deleted } = await run(async () => json(201, { id: 'g1' }));
|
||
expect(result).toBeInstanceOf(ApiError);
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
expect(deleted).toEqual(['f1', 'f2']);
|
||
});
|
||
});
|
||
|
||
describe('normalizeTags', () => {
|
||
it('trennt an Kommas, trimmt, schreibt klein und entfernt Leere und Doppelte', () => {
|
||
expect(normalizeTags(' Visa, Gold ,, visa ,GOLD, Karte ')).toEqual(['visa', 'gold', 'karte']);
|
||
});
|
||
|
||
it('leere Eingabe ergibt eine leere Liste', () => {
|
||
expect(normalizeTags('')).toEqual([]);
|
||
expect(normalizeTags(' , , ')).toEqual([]);
|
||
});
|
||
|
||
it('behält die Reihenfolge der ersten Nennung', () => {
|
||
expect(normalizeTags('b, a, b, c, a')).toEqual(['b', 'a', 'c']);
|
||
});
|
||
|
||
it('kürzt nichts: Grenzen (5 Tags, 20 Zeichen) prüft der Server mit deutscher Meldung', () => {
|
||
const long = 'x'.repeat(30);
|
||
expect(normalizeTags(`a,b,c,d,e,f,${long}`)).toEqual(['a', 'b', 'c', 'd', 'e', 'f', long]);
|
||
});
|
||
|
||
it('Umlaute werden mit klein geschrieben', () => {
|
||
expect(normalizeTags('ÄÖÜ, Straße')).toEqual(['äöü', 'straße']);
|
||
});
|
||
});
|