feat(community): Datenschicht für Galerie, API und Veröffentlichen

- gallery.ts: Galerie aus Appwrite lesen (Abfragen für Liste, Suche, Cursor, Admin-Filter),
  getGalleryCard (404 und ungültige ID ergeben null), eigene Veröffentlichungen, Meldungen, Bilder
- api.ts: Aufrufe von aircard-api mit JWT (Cache je Nutzer, 10 Minuten, geleert bei Auth-Änderung,
  genau eine Wiederholung bei 401), ApiError mit code, Netz-/Proxy-Fehler als 503
- publish.ts: rendern, zwei Uploads in gallery-images, API; bei Fehler genau die zwei Dateien
  dieses Versuchs löschen; normalizeTags
- cards.ts: eigenständiges fetchFile(bucketId, fileId); CardsApi.fetchFile bleibt Hülle
- appwrite.ts: IDS für gallery, reports, gallery-images

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 14:39:30 +00:00
1 parent 7bbe0a9fe6
commit 89c86fd578
9 files changed
+1576 -7

No files matched your search

+249
View File
@@ -0,0 +1,249 @@
import { describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG } from '../card/config';
import type { SavedCard } from '../account/cards';
// Die Standard-Abhängigkeiten (Appwrite, Canvas) kommen in diesen Tests nie zum Einsatz.
vi.mock('../account/appwrite', () => ({
IDS: { galleryBucket: 'gallery-bucket' },
account: {},
client: {},
tables: {},
storage: {},
APPWRITE_ENDPOINT: 'https://aircard.test/v1',
}));
import { ApiError } from './api';
import { PNG_MAX_BYTES, normalizeTags, publishSavedCard, 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',
});
});
it('API-Fehler: beide hochgeladenen Dateien werden gelöscht, der Fehler kommt an', async () => {
const failure = new ApiError(400, 'Bitte gib einen Titel an.');
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('409 (schon veröffentlicht): Aufräumen wie bei jedem Fehler', async () => {
const failure = new ApiError(409, 'Diese Karte ist schon veröffentlicht.');
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('Netzfehler der API: Aufräumen, der Fehler kommt an', async () => {
const failure = new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.');
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(500, 'Interner Fehler.');
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('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']);
});
});