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:
1 parent
7bbe0a9fe6
commit
89c86fd578
9 files changed
+1576
-7
No files matched your search
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user