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

+12 -2
View File
@@ -5,8 +5,18 @@ import { Account, Client, Storage, TablesDB } from 'appwrite';
export const APPWRITE_ENDPOINT: string = import.meta.env.VITE_APPWRITE_ENDPOINT || `${location.origin}/v1`;
export const APPWRITE_PROJECT: string = import.meta.env.VITE_APPWRITE_PROJECT_ID || '6ac2130a00289343d6d5';
/** Datenbank, Tabelle und Bucket des Projekts (siehe web/scripts/appwrite-setup.mjs). */
export const IDS = { database: 'aircard', table: 'cards', bucket: 'card-images' } as const;
/**
* Datenbank, Tabellen und Buckets des Projekts (siehe web/scripts/appwrite-setup.mjs).
* `table`/`bucket` sind die privaten Karten; `gallery`/`reports`/`galleryBucket` gehören zur Community-Galerie.
*/
export const IDS = {
database: 'aircard',
table: 'cards',
bucket: 'card-images',
gallery: 'gallery',
reports: 'reports',
galleryBucket: 'gallery-images',
} as const;
export const client: Client = new Client().setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT);
export const account: Account = new Account(client);
+50
View File
@@ -32,6 +32,7 @@ vi.mock('./appwrite', () => ({
import {
appwriteCardsApi,
createCardsService,
fetchFile,
normalizeName,
ownerPermissions,
toUploadFile,
@@ -711,3 +712,52 @@ describe('appwriteCardsApi', () => {
});
});
});
describe('fetchFile (eigenständig, beliebiger Bucket)', () => {
beforeEach(() => {
for (const fn of Object.values(mocks)) fn.mockReset();
vi.unstubAllGlobals();
});
const prepare = () =>
mocks.prepareRequest.mockImplementation((method: string, url: URL) => ({
uri: url.toString(),
options: { method, headers: { 'X-Appwrite-Project': 'proj' }, credentials: 'include' },
}));
it('holt die Datei aus dem übergebenen Bucket, mit Projekt-Header und Cookie', async () => {
prepare();
const fetchMock = vi.fn(async () => new Response(png(), { status: 200, headers: { 'content-type': 'image/png' } }));
vi.stubGlobal('fetch', fetchMock);
const result = await fetchFile('gallery-images', 'f1');
expect(result.type).toBe('image/png');
expect(fetchMock).toHaveBeenCalledWith('https://aircard.test/v1/storage/buckets/gallery-images/files/f1/view', {
method: 'GET',
headers: { 'X-Appwrite-Project': 'proj' },
credentials: 'include',
});
});
it('maskiert Bucket- und Datei-ID im Pfad', async () => {
prepare();
vi.stubGlobal('fetch', vi.fn(async () => new Response(png(), { headers: { 'content-type': 'image/png' } })));
await fetchFile('a/b', '../x?y');
expect((mocks.prepareRequest.mock.calls[0][1] as URL).pathname).toBe('/v1/storage/buckets/a%2Fb/files/..%2Fx%3Fy/view');
});
it('Fehlerantwort: AppwriteException mit Status und Typ des Servers', async () => {
prepare();
const body = JSON.stringify({ message: 'File not found', type: 'storage_file_not_found', code: 404 });
vi.stubGlobal('fetch', vi.fn(async () => new Response(body, { status: 404, statusText: 'Not Found' })));
const err = await fetchFile('gallery-images', 'f1').catch((e: unknown) => e);
expect(err).toBeInstanceOf(AppwriteException);
expect(err).toMatchObject({ code: 404, type: 'storage_file_not_found' });
});
it('CardsApi.fetchFile bleibt die Hülle über den Karten-Bucket', async () => {
prepare();
vi.stubGlobal('fetch', vi.fn(async () => new Response(png(), { headers: { 'content-type': 'image/png' } })));
await appwriteCardsApi().fetchFile('f1');
expect((mocks.prepareRequest.mock.calls[0][1] as URL).pathname).toBe('/v1/storage/buckets/bucket/files/f1/view');
});
});
+8 -5
View File
@@ -47,7 +47,7 @@ export interface SaveInput {
export const DEFAULT_CARD_NAME = 'Meine Karte';
const NAME_MAX = 80;
/** Zeilen- und Datei-IDs von Appwrite: höchstens 36 Zeichen, kein Sonderzeichen am Anfang. */
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
export const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“. */
export function normalizeName(name: string): string {
@@ -193,11 +193,13 @@ export function createCardsService(api: CardsApi, userId: () => string) {
export type CardsService = ReturnType<typeof createCardsService>;
/** Lädt eine Bilddatei mit Projekt-Header und Cookie (siehe `client.prepareRequest`). */
async function fetchFile(fileId: string): Promise<Blob> {
/** Lädt eine Bilddatei aus einem Bucket mit Projekt-Header und Cookie (siehe `client.prepareRequest`). */
export async function fetchFile(bucketId: string, fileId: string): Promise<Blob> {
// Bewusst kein storage.getFileView(): das hängt das Projekt als Query-Parameter an die URL und schickt keinen
// Header; so trifft die Anfrage die Traefik-Route für /v1 mit Header nicht. prepareRequest setzt Header und Cookie.
const url = new URL(`${APPWRITE_ENDPOINT}/storage/buckets/${IDS.bucket}/files/${encodeURIComponent(fileId)}/view`);
const url = new URL(
`${APPWRITE_ENDPOINT}/storage/buckets/${encodeURIComponent(bucketId)}/files/${encodeURIComponent(fileId)}/view`,
);
const { uri, options } = client.prepareRequest('GET', url);
const res = await fetch(uri, options);
if (!res.ok) {
@@ -231,7 +233,8 @@ export function appwriteCardsApi(): CardsApi {
deleteFile: async (fileId) => {
await storage.deleteFile({ bucketId: IDS.bucket, fileId });
},
fetchFile,
// Die privaten Karten liegen im Karten-Bucket; die Galerie holt ihre Bilder über `fetchFile` mit eigenem Bucket.
fetchFile: (fileId) => fetchFile(IDS.bucket, fileId),
createRow: (data, permissions) =>
tables.createRow<TableRow>({ ...target, rowId: ID.unique(), data, permissions }),
// Beim Ändern bleiben die Rechte der Zeile unangetastet.
+418
View File
@@ -0,0 +1,418 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
createJWT: vi.fn(),
userId: null as string | null,
authListeners: [] as Array<(user: unknown) => void>,
logoutListeners: [] as Array<() => void>,
}));
vi.mock('../account/appwrite', () => ({ account: { createJWT: mocks.createJWT } }));
vi.mock('../account/auth', () => ({
currentUserId: () => mocks.userId,
onAuthChange: (fn: (user: unknown) => void) => {
mocks.authListeners.push(fn);
return () => undefined;
},
onLogout: (fn: () => void) => {
mocks.logoutListeners.push(fn);
return () => undefined;
},
}));
type Api = typeof import('./api');
const UNAVAILABLE = 'Der Dienst ist gerade nicht erreichbar.';
const MINUTE = 60_000;
// Der JWT-Cache lebt im Modul; jeder Test bekommt eine frische Instanz.
async function freshApi(): Promise<Api> {
vi.resetModules();
return import('./api');
}
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>>();
function sentHeaders(call = 0): Record<string, string> {
return fetchMock.mock.calls[call][1].headers as Record<string, string>;
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-10-04T10:00:00Z'));
mocks.createJWT.mockReset();
mocks.userId = 'u1';
mocks.authListeners.length = 0;
mocks.logoutListeners.length = 0;
let n = 0;
mocks.createJWT.mockImplementation(async () => ({ jwt: `jwt${++n}` }));
fetchMock.mockReset();
fetchMock.mockImplementation(async () => json(200, { ok: true }));
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('apiPost', () => {
it('sendet JSON per POST an den Pfad und liefert die JSON-Antwort', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(200, { schonGemeldet: true }));
await expect(api.apiPost('/api/melden', { id: 'c1', reason: 'spam' }, { auth: false })).resolves.toEqual({
schonGemeldet: true,
});
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/melden');
expect(init.method).toBe('POST');
expect(sentHeaders()['Content-Type']).toBe('application/json');
expect(init.body).toBe(JSON.stringify({ id: 'c1', reason: 'spam' }));
});
it('ohne auth: kein JWT, kein createJWT', async () => {
const api = await freshApi();
await api.apiPost('/api/melden', {}, { auth: false });
expect(mocks.createJWT).not.toHaveBeenCalled();
expect(sentHeaders()['X-Aircard-Jwt']).toBeUndefined();
});
it('mit auth: sendet den Header X-Aircard-Jwt mit dem JWT von createJWT (ohne duration)', async () => {
const api = await freshApi();
await api.apiPost('/api/zurueckziehen', { id: 'c1' }, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledOnce();
expect(mocks.createJWT.mock.calls[0]).toEqual([]);
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
});
describe('JWT-Cache', () => {
it('der zweite Aufruf innerhalb von 10 Minuten holt kein neues JWT', async () => {
const api = await freshApi();
await api.apiPost('/api/x', {}, { auth: true });
vi.setSystemTime(Date.now() + 9 * MINUTE + 59_000);
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledOnce();
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt1');
});
it('nach 10 Minuten wird ein neues JWT geholt', async () => {
const api = await freshApi();
await api.apiPost('/api/x', {}, { auth: true });
vi.setSystemTime(Date.now() + 10 * MINUTE);
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt2');
});
it('gilt je Nutzer-ID: ein anderer Nutzer bekommt nie das JWT des vorigen', async () => {
const api = await freshApi();
await api.apiPost('/api/x', {}, { auth: true });
mocks.userId = 'u2';
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt2');
});
it('wird bei einer Änderung des Nutzers (onAuthChange) geleert', async () => {
const api = await freshApi();
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.authListeners).toHaveLength(1);
mocks.authListeners[0](null);
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
});
it('wird beim Abmelden (onLogout) geleert', async () => {
const api = await freshApi();
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.logoutListeners).toHaveLength(1);
mocks.logoutListeners[0]();
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
});
it('ein JWT, das erst nach einem Nutzerwechsel eintrifft, wird nicht gemerkt', async () => {
const api = await freshApi();
let release: (value: { jwt: string }) => void = () => undefined;
mocks.createJWT.mockImplementationOnce(() => new Promise((resolve) => (release = resolve)));
const first = api.apiPost('/api/x', {}, { auth: true });
await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce());
mocks.authListeners[0](null); // Abmelden, während createJWT noch läuft
release({ jwt: 'late' });
await first;
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt1');
});
it('ohne bekannte Nutzer-ID wird nichts gemerkt', async () => {
const api = await freshApi();
mocks.userId = null;
await api.apiPost('/api/x', {}, { auth: true });
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
});
});
describe('401 von der API', () => {
it('holt ein neues JWT und wiederholt genau einmal', async () => {
const api = await freshApi();
fetchMock.mockResolvedValueOnce(json(401, { error: 'Bitte melde dich an.' }));
fetchMock.mockResolvedValueOnce(json(200, { ok: true }));
await expect(api.apiPost('/api/x', { a: 1 }, { auth: true })).resolves.toEqual({ ok: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(sentHeaders(0)['X-Aircard-Jwt']).toBe('jwt1');
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt2');
expect(fetchMock.mock.calls[1][1].body).toBe(JSON.stringify({ a: 1 }));
});
it('das neue JWT wird gemerkt, das alte nicht mehr', async () => {
const api = await freshApi();
fetchMock.mockResolvedValueOnce(json(401, { error: 'Bitte melde dich an.' }));
await api.apiPost('/api/x', {}, { auth: true });
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
expect(sentHeaders(2)['X-Aircard-Jwt']).toBe('jwt2');
});
it('bei einem zweiten 401 ist Schluss: ApiError mit code 401, keine dritte Anfrage', async () => {
const api = await freshApi();
fetchMock.mockImplementation(async () => json(401, { error: 'Bitte melde dich an.' }));
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 401, message: 'Bitte melde dich an.' });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
});
it('ohne auth wird nicht wiederholt', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(401, { error: 'Nein.' }));
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 401 });
expect(fetchMock).toHaveBeenCalledOnce();
});
it('andere Fehler (403) lösen keinen Wiederholungsversuch aus', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.' }));
await expect(api.apiPost('/api/x', {}, { auth: true })).rejects.toMatchObject({ code: 403 });
expect(fetchMock).toHaveBeenCalledOnce();
expect(mocks.createJWT).toHaveBeenCalledOnce();
});
});
describe('Fehler', () => {
it('ApiError trägt Statuscode (code = status) und den deutschen Servertext aus `message`', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(400, { message: 'Bitte gib einen Titel an.' }));
const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toBeInstanceOf(Error);
expect(err).toMatchObject({ code: 400, status: 400, message: 'Bitte gib einen Titel an.' });
});
it('liest den Text auch aus `error` (so antwortet aircard-api tatsächlich)', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Meldungen – bitte später nochmal.' }));
await expect(api.apiPost('/api/melden', {}, { auth: false })).rejects.toMatchObject({
code: 429,
message: 'Zu viele Meldungen – bitte später nochmal.',
});
});
it('JSON ohne Text: allgemeine Meldung mit dem Statuscode', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(500, {}));
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({
code: 500,
message: 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.',
});
});
it('der Statuscode lässt isUnauthorized aus errors.ts greifen', async () => {
const api = await freshApi();
const { isUnauthorized } = await import('../account/errors');
fetchMock.mockResolvedValue(json(401, { error: 'x' }));
const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e);
expect(isUnauthorized(err)).toBe(true);
});
it('Netzfehler: code 503 und „Der Dienst ist gerade nicht erreichbar.“', async () => {
const api = await freshApi();
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 503, message: UNAVAILABLE });
});
it.each([502, 503, 504])('Status %i (auch mit JSON) gilt als nicht erreichbar: code 503', async (status) => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(status, { error: 'irgendwas' }));
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503, message: UNAVAILABLE });
});
it('eine Fehlerseite ohne JSON (z. B. Proxy) gilt als nicht erreichbar', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(new Response('<html>404 page not found</html>', { status: 404 }));
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503, message: UNAVAILABLE });
});
it('eine HTML-Antwort mit Status 200 (Seitenfallback ohne API-Route) gilt als nicht erreichbar', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(new Response('<!doctype html><title>AirCard</title>', { status: 200 }));
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503, message: UNAVAILABLE });
});
it('JSON, das kein Objekt ist, gilt als nicht erreichbar', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(new Response('"ok"', { status: 200 }));
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503 });
});
it('createJWT mit user_auth_method_unsupported: Hinweis an den Betreiber, keine Anfrage', async () => {
const api = await freshApi();
mocks.createJWT.mockRejectedValue(
Object.assign(new Error('JWT unsupported'), { code: 501, type: 'user_auth_method_unsupported' }),
);
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({
code: 501,
message: 'Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.',
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('createJWT mit 401 (keine Sitzung) geht unverändert durch, damit withLogin greift', async () => {
const api = await freshApi();
const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' });
mocks.createJWT.mockRejectedValue(failure);
await expect(api.apiPost('/api/x', {}, { auth: true })).rejects.toBe(failure);
expect(fetchMock).not.toHaveBeenCalled();
});
});
});
describe('publishCard', () => {
const input = {
title: 'Meine Karte',
issuer: 'Beispielbank',
tags: ['visa', 'gold'],
displayName: 'Kenso',
imageFileId: 'img1',
previewFileId: 'pre1',
sourceCardId: 'card1',
};
it('POST /api/veroeffentlichen mit JWT, Tags als Liste; liefert { id }', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(201, { id: 'g1' }));
await expect(api.publishCard(input)).resolves.toEqual({ id: 'g1' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/veroeffentlichen');
expect(JSON.parse(init.body as string)).toEqual(input);
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
});
it('409: „Diese Karte ist schon veröffentlicht.“', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(409, { error: 'Diese Karte ist schon veröffentlicht.' }));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 409, message: 'Diese Karte ist schon veröffentlicht.' });
expect(api.isAlreadyPublished(err)).toBe(true);
});
it('403 „bereits veröffentlicht oder gesperrt“ (Dateien belegt) gilt ebenfalls als schon veröffentlicht', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' }));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 409, message: 'Diese Karte ist schon veröffentlicht.' });
expect(api.isAlreadyPublished(err)).toBe(true);
});
it('ein anderes 403 bleibt ein 403 mit dem Servertext', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Diese Datei gehört dir nicht.' }));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 403, message: 'Diese Datei gehört dir nicht.' });
expect(api.isAlreadyPublished(err)).toBe(false);
});
it('400 (Validierung) behält den Servertext', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(400, { error: 'Bitte gib höchstens 5 Tags an.' }));
await expect(api.publishCard(input)).rejects.toMatchObject({ code: 400, message: 'Bitte gib höchstens 5 Tags an.' });
});
it('eine Antwort ohne id ist ein Fehler', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(201, {}));
await expect(api.publishCard(input)).rejects.toMatchObject({ code: 503, message: UNAVAILABLE });
});
it('isAlreadyPublished: nur ApiError mit code 409', async () => {
const api = await freshApi();
expect(api.isAlreadyPublished(new api.ApiError(409, 'x'))).toBe(true);
expect(api.isAlreadyPublished(new api.ApiError(400, 'x'))).toBe(false);
expect(api.isAlreadyPublished(Object.assign(new Error('x'), { code: 409 }))).toBe(false);
expect(api.isAlreadyPublished(null)).toBe(false);
});
});
describe('withdrawCard, reportCard, setVisibility', () => {
it('withdrawCard: POST /api/zurueckziehen { id } mit JWT', async () => {
const api = await freshApi();
await expect(api.withdrawCard('g1')).resolves.toBeUndefined();
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/zurueckziehen');
expect(JSON.parse(init.body as string)).toEqual({ id: 'g1' });
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
});
it('reportCard: POST /api/melden { id, reason } ohne JWT; { schonGemeldet: false } bei neuer Meldung', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(200, { ok: true }));
await expect(api.reportCard('g1', 'spam')).resolves.toEqual({ schonGemeldet: false });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/melden');
expect(JSON.parse(init.body as string)).toEqual({ id: 'g1', reason: 'spam' });
expect(mocks.createJWT).not.toHaveBeenCalled();
});
it('reportCard: { schonGemeldet: true } bei doppelter Meldung', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(200, { schonGemeldet: true }));
await expect(api.reportCard('g1', 'urheberrecht')).resolves.toEqual({ schonGemeldet: true });
});
it('reportCard: 429 mit dem Servertext', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Meldungen – bitte später nochmal.' }));
await expect(api.reportCard('g1', 'spam')).rejects.toMatchObject({
code: 429,
message: 'Zu viele Meldungen – bitte später nochmal.',
});
});
it('setVisibility: POST /api/admin/sichtbarkeit { id, sichtbar } mit JWT', async () => {
const api = await freshApi();
await expect(api.setVisibility('g1', false)).resolves.toBeUndefined();
await api.setVisibility('g1', true);
expect(fetchMock.mock.calls[0][0]).toBe('/api/admin/sichtbarkeit');
expect(JSON.parse(fetchMock.mock.calls[0][1].body as string)).toEqual({ id: 'g1', sichtbar: false });
expect(JSON.parse(fetchMock.mock.calls[1][1].body as string)).toEqual({ id: 'g1', sichtbar: true });
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
});
it('setVisibility: 403 für Nicht-Admins mit dem Servertext', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.' }));
await expect(api.setVisibility('g1', true)).rejects.toMatchObject({ code: 403, message: 'Nur für Admins.' });
});
});
+181
View File
@@ -0,0 +1,181 @@
// Schreibzugriffe der Community-Galerie: Aufrufe des Dienstes `aircard-api` (deploy/api) mit dem Nutzer-JWT.
// Lesen läuft direkt über Appwrite (gallery.ts). Fehler sind `ApiError` mit HTTP-Status als `code`, damit
// `isUnauthorized`/`withLogin`/`errorMessage` aus account/ sie wie Appwrite-Fehler behandeln.
import { account } from '../account/appwrite';
import { currentUserId, onAuthChange, onLogout } from '../account/auth';
import { errorType } from '../account/errors';
export type ReportReason = 'unangemessen' | 'kartendaten' | 'urheberrecht' | 'spam' | 'sonstiges';
export interface PublishInput {
title: string;
issuer: string;
tags: string[];
displayName: string;
imageFileId: string;
previewFileId: string;
sourceCardId: string;
}
const UNAVAILABLE = 'Der Dienst ist gerade nicht erreichbar.';
const GENERIC = 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.';
const JWT_UNSUPPORTED = 'Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.';
const ALREADY_PUBLISHED = 'Diese Karte ist schon veröffentlicht.';
/** Wortlaut der API (deploy/api/app.js, MSG.filesTaken): die hochgeladenen Dateien sind schon belegt oder gesperrt. */
const FILES_TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
/** Appwrite-JWTs gelten 15 Minuten; wir verwenden eines höchstens 10 Minuten. */
const JWT_TTL_MS = 10 * 60 * 1000;
/** Fehler des API-Dienstes. `code` ist der HTTP-Status (`status` dasselbe), `message` der deutsche Servertext. */
export class ApiError extends Error {
readonly code: number;
constructor(code: number, message: string) {
super(message);
this.name = 'ApiError';
this.code = code;
}
get status(): number {
return this.code;
}
}
// ---- JWT (je Nutzer-ID gemerkt) ----------------------------------------------------------------------
let cached: { userId: string; jwt: string; expires: number } | null = null;
/** Zählt Leerungen: ein JWT, das erst nach einem Nutzerwechsel eintrifft, gehört dem alten Nutzer und wird nicht gemerkt. */
let generation = 0;
function clearJwt(): void {
cached = null;
generation++;
}
let subscribed = false;
/** Erst beim ersten JWT: so lässt sich das Modul importieren, ohne den Anmeldedienst zu berühren. */
function subscribe(): void {
if (subscribed) return;
subscribed = true;
onAuthChange(clearJwt);
onLogout(clearJwt);
}
async function getJwt(): Promise<string> {
subscribe();
const userId = currentUserId();
if (userId && cached && cached.userId === userId && Date.now() < cached.expires) return cached.jwt;
const started = generation;
let jwt: string;
try {
// Ohne `duration`: die Standarddauer (15 Minuten) genügt.
jwt = (await account.createJWT()).jwt;
} catch (err) {
if (errorType(err) === 'user_auth_method_unsupported') {
const code = (err as { code?: unknown }).code;
throw new ApiError(typeof code === 'number' ? code : 501, JWT_UNSUPPORTED);
}
throw err; // z. B. 401 ohne Sitzung: withLogin erkennt es am Statuscode
}
if (userId && started === generation) cached = { userId, jwt, expires: Date.now() + JWT_TTL_MS };
return jwt;
}
// ---- Anfrage ------------------------------------------------------------------------------------------
type Json = Record<string, unknown>;
function isRecord(value: unknown): value is Json {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** Der deutsche Text einer Fehlerantwort: aircard-api sendet `{error}`, `{message}` wird ebenso akzeptiert. */
function serverMessage(body: Json): string | null {
for (const key of ['message', 'error']) {
const value = body[key];
if (typeof value === 'string' && value.trim() !== '') return value;
}
return null;
}
async function send(path: string, body: unknown, jwt: string | null): Promise<Json> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (jwt) headers['X-Aircard-Jwt'] = jwt;
let res: Response;
let text: string;
try {
res = await fetch(path, { method: 'POST', headers, body: JSON.stringify(body), credentials: 'omit' });
text = await res.text();
} catch {
throw new ApiError(503, UNAVAILABLE);
}
// Der Proxy antwortet, wenn der Dienst fehlt oder steht (502/503/504, Fehlerseite, Seitenfallback als HTML-200).
if (res.status === 502 || res.status === 503 || res.status === 504) throw new ApiError(503, UNAVAILABLE);
let data: unknown;
try {
data = JSON.parse(text);
} catch {
throw new ApiError(503, UNAVAILABLE);
}
if (!isRecord(data)) throw new ApiError(503, UNAVAILABLE);
if (!res.ok) throw new ApiError(res.status, serverMessage(data) ?? GENERIC);
return data;
}
/**
* POST mit JSON-Body an den API-Dienst. Mit `auth` geht das Nutzer-JWT im Header `X-Aircard-Jwt` mit;
* antwortet der Dienst mit 401, wird genau einmal mit einem frischen JWT wiederholt.
*/
export async function apiPost(path: string, body: unknown, opts: { auth: boolean }): Promise<Json> {
if (!opts.auth) return send(path, body, null);
try {
return await send(path, body, await getJwt());
} catch (err) {
if (!(err instanceof ApiError) || err.code !== 401) throw err;
clearJwt();
return send(path, body, await getJwt());
}
}
// ---- Endpunkte ----------------------------------------------------------------------------------------
/** Wahr, wenn die Karte schon veröffentlicht ist (`publishCard` meldet das immer als 409). */
export function isAlreadyPublished(err: unknown): boolean {
return err instanceof ApiError && err.code === 409;
}
/**
* Veröffentlicht eine Karte. Doppelt veröffentlicht (409) und „Dateien schon belegt oder gesperrt“ (403) kommen
* beide als ApiError 409 „Diese Karte ist schon veröffentlicht.“ an; alle anderen Fehler unverändert.
*/
export async function publishCard(input: PublishInput): Promise<{ id: string }> {
let res: Json;
try {
res = await apiPost('/api/veroeffentlichen', input, { auth: true });
} catch (err) {
if (err instanceof ApiError && err.code === 403 && err.message === FILES_TAKEN) {
throw new ApiError(409, ALREADY_PUBLISHED);
}
throw err;
}
if (typeof res.id !== 'string' || res.id === '') throw new ApiError(503, UNAVAILABLE);
return { id: res.id };
}
/** Zieht eine Veröffentlichung zurück (Autor oder Admin): löscht Zeile, Dateien und Meldungen. */
export async function withdrawCard(id: string): Promise<void> {
await apiPost('/api/zurueckziehen', { id }, { auth: true });
}
/** Meldet eine Karte (ohne Anmeldung). `schonGemeldet`: dieser Melder hatte sie schon gemeldet. */
export async function reportCard(id: string, reason: ReportReason): Promise<{ schonGemeldet: boolean }> {
const res = await apiPost('/api/melden', { id, reason }, { auth: false });
return { schonGemeldet: res.schonGemeldet === true };
}
/** Admin: blendet eine Karte ein oder aus. */
export async function setVisibility(id: string, visible: boolean): Promise<void> {
await apiPost('/api/admin/sichtbarkeit', { id, sichtbar: visible }, { auth: true });
}
+380
View File
@@ -0,0 +1,380 @@
import { Query } from 'appwrite';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
listRows: vi.fn(),
getRow: vi.fn(),
fetchFile: vi.fn(),
}));
vi.mock('../account/appwrite', () => ({
IDS: {
database: 'db',
table: 'cards-table',
bucket: 'cards-bucket',
gallery: 'gallery-table',
reports: 'reports-table',
galleryBucket: 'gallery-bucket',
},
account: {},
client: {},
storage: {},
APPWRITE_ENDPOINT: 'https://aircard.test/v1',
tables: { listRows: mocks.listRows, getRow: mocks.getRow },
}));
// ID_PATTERN & Co. bleiben echt; nur das Laden der Datei geht nicht ins Netz.
vi.mock('../account/cards', async (importOriginal) => ({
...(await importOriginal<typeof import('../account/cards')>()),
fetchFile: mocks.fetchFile,
}));
import {
PAGE_SIZE,
galleryQueries,
getGalleryCard,
listGallery,
listReports,
loadGalleryImage,
myPublications,
toGalleryCard,
type GalleryCard,
} from './gallery';
const row = (n: number, patch: Record<string, unknown> = {}) => ({
$id: `g${n}`,
$createdAt: `2026-10-0${(n % 9) + 1}T10:00:00.000Z`,
title: `Karte ${n}`,
issuer: 'Beispielbank',
tags: 'visa,gold',
displayName: 'Kenso',
imageFileId: `img${n}`,
previewFileId: `pre${n}`,
sourceCardId: `src${n}`,
ownerId: 'u1',
hidden: false,
reportCount: 0,
searchText: 'karte beispielbank visa gold',
...patch,
});
const notFound = (type = 'row_not_found') => Object.assign(new Error('Row not found'), { code: 404, type });
beforeEach(() => {
mocks.listRows.mockReset();
mocks.getRow.mockReset();
mocks.fetchFile.mockReset();
});
describe('galleryQueries', () => {
it('öffentlich: nur sichtbare Karten, neueste zuerst, 24 pro Seite', () => {
expect(galleryQueries({})).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
]);
});
it('die Abfragen sind genau die Appwrite-JSON-Strings', () => {
expect(galleryQueries({})).toEqual([
JSON.stringify({ method: 'equal', attribute: 'hidden', values: [false] }),
JSON.stringify({ method: 'orderDesc', attribute: '$createdAt' }),
JSON.stringify({ method: 'limit', values: [24] }),
]);
expect(PAGE_SIZE).toBe(24);
});
it('mit Cursor: cursorAfter(letzte ID)', () => {
expect(galleryQueries({ cursor: 'g24' })).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.cursorAfter('g24'),
]);
});
it('mit Suche: Volltext auf searchText, Leerraum zusammengefasst', () => {
expect(galleryQueries({ q: ' Visa Gold ' })).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.search('searchText', 'Visa Gold'),
]);
});
it('Suche und Cursor zusammen', () => {
expect(galleryQueries({ q: 'visa', cursor: 'g24' })).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.cursorAfter('g24'),
Query.search('searchText', 'visa'),
]);
});
it('eine leere oder nur aus Leerraum bestehende Suche wird ignoriert', () => {
expect(galleryQueries({ q: '' })).toEqual(galleryQueries({}));
expect(galleryQueries({ q: ' \t ' })).toEqual(galleryQueries({}));
});
it("Admin-Filter 'all' ist die öffentliche Ansicht", () => {
expect(galleryQueries({ filter: 'all' })).toEqual(galleryQueries({}));
});
it("Admin-Filter 'reported': reportCount > 0, ohne hidden-Bedingung (gemeldet und schon ausgeblendet)", () => {
const queries = galleryQueries({ filter: 'reported' });
expect(queries).toEqual([Query.greaterThan('reportCount', 0), Query.orderDesc('$createdAt'), Query.limit(24)]);
expect(queries.join('')).not.toContain('hidden');
});
it("Admin-Filter 'hidden': nur ausgeblendete Karten", () => {
expect(galleryQueries({ filter: 'hidden' })).toEqual([
Query.equal('hidden', true),
Query.orderDesc('$createdAt'),
Query.limit(24),
]);
});
it('Admin-Filter lassen sich mit Suche und Cursor kombinieren', () => {
expect(galleryQueries({ filter: 'reported', q: 'visa', cursor: 'g5' })).toEqual([
Query.greaterThan('reportCount', 0),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.cursorAfter('g5'),
Query.search('searchText', 'visa'),
]);
});
});
describe('toGalleryCard', () => {
it('übernimmt alle Felder; Tags werden zur Liste', () => {
expect(toGalleryCard(row(1, { hidden: true, reportCount: 3 }))).toEqual({
id: 'g1',
title: 'Karte 1',
issuer: 'Beispielbank',
tags: ['visa', 'gold'],
displayName: 'Kenso',
imageFileId: 'img1',
previewFileId: 'pre1',
sourceCardId: 'src1',
ownerId: 'u1',
hidden: true,
reportCount: 3,
createdAt: row(1).$createdAt,
} satisfies GalleryCard);
});
it('fehlende Felder bekommen leere Standardwerte', () => {
expect(toGalleryCard({ $id: 'g9' })).toEqual({
id: 'g9',
title: '',
issuer: '',
tags: [],
displayName: '',
imageFileId: '',
previewFileId: '',
sourceCardId: '',
ownerId: '',
hidden: false,
reportCount: 0,
createdAt: '',
});
});
it('Felder mit falschem Typ gelten als fehlend', () => {
const card = toGalleryCard({
$id: 'g9',
title: 42,
tags: ['x'],
hidden: 'ja',
reportCount: 'viele',
displayName: null,
$createdAt: 5,
});
expect(card).toMatchObject({ title: '', tags: [], hidden: false, reportCount: 0, displayName: '', createdAt: '' });
});
it('Tags: getrimmt, Leere raus', () => {
expect(toGalleryCard(row(1, { tags: ' visa , ,gold,, ' })).tags).toEqual(['visa', 'gold']);
expect(toGalleryCard(row(1, { tags: '' })).tags).toEqual([]);
});
it('reportCount: nur endliche, nicht negative Zahlen', () => {
expect(toGalleryCard(row(1, { reportCount: Number.NaN })).reportCount).toBe(0);
expect(toGalleryCard(row(1, { reportCount: -2 })).reportCount).toBe(0);
expect(toGalleryCard(row(1, { reportCount: 2 })).reportCount).toBe(2);
});
});
describe('listGallery', () => {
it('fragt die Galerie-Tabelle mit den Abfragen ab und liefert Karten', async () => {
mocks.listRows.mockResolvedValue({ total: 2, rows: [row(1), row(2)] });
const page = await listGallery({ q: 'visa' });
expect(mocks.listRows).toHaveBeenCalledOnce();
expect(mocks.listRows.mock.calls[0][0]).toEqual({
databaseId: 'db',
tableId: 'gallery-table',
queries: galleryQueries({ q: 'visa' }),
});
expect(page.cards.map((c) => c.id)).toEqual(['g1', 'g2']);
expect(page.cards[0].tags).toEqual(['visa', 'gold']);
});
it('weniger als 24 Zeilen: keine weitere Seite (nextCursor null)', async () => {
mocks.listRows.mockResolvedValue({ total: 23, rows: Array.from({ length: 23 }, (_, i) => row(i + 1)) });
expect((await listGallery({})).nextCursor).toBeNull();
});
it('keine Zeilen: leere Liste, nextCursor null', async () => {
mocks.listRows.mockResolvedValue({ total: 0, rows: [] });
await expect(listGallery({})).resolves.toEqual({ cards: [], nextCursor: null });
});
it('genau 24 Zeilen: nextCursor ist die ID der letzten Karte', async () => {
mocks.listRows.mockResolvedValue({ total: 60, rows: Array.from({ length: 24 }, (_, i) => row(i + 1)) });
const page = await listGallery({});
expect(page.cards).toHaveLength(24);
expect(page.nextCursor).toBe('g24');
});
it('die nächste Seite schickt den Cursor mit', async () => {
mocks.listRows.mockResolvedValue({ total: 0, rows: [] });
await listGallery({ cursor: 'g24', filter: 'hidden' });
expect(mocks.listRows.mock.calls[0][0].queries).toEqual(galleryQueries({ cursor: 'g24', filter: 'hidden' }));
});
it('Fehler gehen durch', async () => {
const failure = Object.assign(new Error('down'), { code: 503 });
mocks.listRows.mockRejectedValue(failure);
await expect(listGallery({})).rejects.toBe(failure);
});
});
describe('getGalleryCard', () => {
it('liefert die Karte', async () => {
mocks.getRow.mockResolvedValue(row(7));
const card = await getGalleryCard('g7');
expect(card?.id).toBe('g7');
expect(mocks.getRow).toHaveBeenCalledWith({ databaseId: 'db', tableId: 'gallery-table', rowId: 'g7' });
});
it('404 (nicht vorhanden oder ausgeblendet für mich) ergibt null', async () => {
mocks.getRow.mockRejectedValue(notFound());
await expect(getGalleryCard('g7')).resolves.toBeNull();
});
it('404 ohne Typ (z. B. Proxy) ergibt ebenfalls null', async () => {
mocks.getRow.mockRejectedValue(Object.assign(new Error('not found'), { code: 404 }));
await expect(getGalleryCard('g7')).resolves.toBeNull();
});
it('eine fehlende Tabelle (404 table_not_found) ist ein Konfigurationsfehler, kein „Karte weg“', async () => {
const failure = notFound('table_not_found');
mocks.getRow.mockRejectedValue(failure);
await expect(getGalleryCard('g7')).rejects.toBe(failure);
});
it.each([500, 503, 401, 429])('Fehler %i geht durch', async (code) => {
const failure = Object.assign(new Error('x'), { code, type: 'general_server_error' });
mocks.getRow.mockRejectedValue(failure);
await expect(getGalleryCard('g7')).rejects.toBe(failure);
});
it('Netzfehler gehen durch', async () => {
const failure = new TypeError('Failed to fetch');
mocks.getRow.mockRejectedValue(failure);
await expect(getGalleryCard('g7')).rejects.toBe(failure);
});
it.each(['', '../x', 'a/b', 'a?b', '.hidden', '-x', 'x'.repeat(37), 'ä'])(
'ungültige ID %j: null, ohne Anfrage',
async (id) => {
await expect(getGalleryCard(id)).resolves.toBeNull();
expect(mocks.getRow).not.toHaveBeenCalled();
},
);
});
describe('myPublications', () => {
it('liest die Zeilen des Nutzers (höchstens 100) und ordnet sie nach sourceCardId', async () => {
mocks.listRows.mockResolvedValue({
total: 2,
rows: [row(1, { sourceCardId: 'cardA' }), row(2, { sourceCardId: 'cardB', hidden: true, reportCount: 3 })],
});
const map = await myPublications('u1');
expect(mocks.listRows).toHaveBeenCalledOnce();
expect(mocks.listRows.mock.calls[0][0]).toEqual({
databaseId: 'db',
tableId: 'gallery-table',
queries: [Query.equal('ownerId', 'u1'), Query.limit(100)],
});
expect(map).toBeInstanceOf(Map);
expect([...map.keys()]).toEqual(['cardA', 'cardB']);
expect(map.get('cardA')?.id).toBe('g1');
expect(map.get('cardB')).toMatchObject({ id: 'g2', hidden: true, reportCount: 3 });
});
it('ohne Veröffentlichungen: leere Map', async () => {
mocks.listRows.mockResolvedValue({ total: 0, rows: [] });
expect((await myPublications('u1')).size).toBe(0);
});
it('Zeilen ohne sourceCardId (Altdaten) werden übersprungen', async () => {
mocks.listRows.mockResolvedValue({ total: 2, rows: [row(1, { sourceCardId: '' }), row(2, { sourceCardId: 'cardB' })] });
expect([...(await myPublications('u1')).keys()]).toEqual(['cardB']);
});
it('ohne Nutzer-ID: leere Map, ohne Anfrage', async () => {
expect((await myPublications('')).size).toBe(0);
expect(mocks.listRows).not.toHaveBeenCalled();
});
});
describe('listReports', () => {
it('liest die Meldungen einer Karte (höchstens 100), neueste zuerst', async () => {
mocks.listRows.mockResolvedValue({
total: 2,
rows: [
{ $id: 'r2', $createdAt: '2026-10-04T11:00:00.000Z', galleryId: 'g1', reason: 'spam', reporterHash: 'abc' },
{ $id: 'r1', $createdAt: '2026-10-04T10:00:00.000Z', galleryId: 'g1', reason: 'urheberrecht', reporterHash: 'def' },
],
});
const reports = await listReports('g1');
expect(mocks.listRows.mock.calls[0][0]).toEqual({
databaseId: 'db',
tableId: 'reports-table',
queries: [Query.equal('galleryId', 'g1'), Query.orderDesc('$createdAt'), Query.limit(100)],
});
expect(reports).toEqual([
{ id: 'r2', galleryId: 'g1', reason: 'spam', createdAt: '2026-10-04T11:00:00.000Z' },
{ id: 'r1', galleryId: 'g1', reason: 'urheberrecht', createdAt: '2026-10-04T10:00:00.000Z' },
]);
});
it('der Melder-Hash wird nicht weitergereicht', async () => {
mocks.listRows.mockResolvedValue({ total: 1, rows: [{ $id: 'r1', galleryId: 'g1', reason: 'spam', reporterHash: 'abc' }] });
expect(JSON.stringify(await listReports('g1'))).not.toContain('abc');
});
it('ungültige ID: leere Liste, ohne Anfrage', async () => {
await expect(listReports('../x')).resolves.toEqual([]);
expect(mocks.listRows).not.toHaveBeenCalled();
});
});
describe('loadGalleryImage', () => {
const card = toGalleryCard(row(3));
const blob = new Blob(['x'], { type: 'image/png' });
it("'full' lädt das Bild aus dem Galerie-Bucket", async () => {
mocks.fetchFile.mockResolvedValue(blob);
await expect(loadGalleryImage(card, 'full')).resolves.toBe(blob);
expect(mocks.fetchFile).toHaveBeenCalledWith('gallery-bucket', 'img3');
});
it("'preview' lädt die Vorschau aus dem Galerie-Bucket", async () => {
mocks.fetchFile.mockResolvedValue(blob);
await loadGalleryImage(card, 'preview');
expect(mocks.fetchFile).toHaveBeenCalledWith('gallery-bucket', 'pre3');
});
});
+157
View File
@@ -0,0 +1,157 @@
// Die öffentliche Galerie lesen: Tabelle `gallery` direkt aus Appwrite (Gäste dürfen sichtbare Zeilen lesen).
// Schreiben (Veröffentlichen, Zurückziehen, Melden, Ausblenden) läuft über den API-Dienst, siehe api.ts.
import { Query, type Models } from 'appwrite';
import { ID_PATTERN, fetchFile } from '../account/cards';
import { IDS, tables } from '../account/appwrite';
export interface GalleryCard {
id: string;
title: string;
issuer: string;
tags: string[];
displayName: string;
imageFileId: string;
previewFileId: string;
sourceCardId: string;
ownerId: string;
hidden: boolean;
reportCount: number;
createdAt: string;
}
export interface GalleryReport {
id: string;
galleryId: string;
reason: string;
createdAt: string;
}
/** Ansicht der Galerie: öffentlich `all`; `reported`/`hidden` zeigen nur Admins etwas (die Rechte prüft der Server). */
export type GalleryFilter = 'all' | 'reported' | 'hidden';
export interface GalleryOptions {
q?: string;
cursor?: string;
filter?: GalleryFilter;
}
export const PAGE_SIZE = 24;
/** Obergrenze für eigene Veröffentlichungen und Meldungen einer Karte (eine Seite genügt). */
const LIST_MAX = 100;
type Row = Record<string, unknown>;
/** Eine Zeile, wie Appwrite sie liefert: Systemfelder (`$id`, …) plus die Spalten der Tabelle. */
type TableRow = Models.Row & Row;
function text(value: unknown): string {
return typeof value === 'string' ? value : '';
}
/** Eine Zeile der Tabelle `gallery` als Karte; fehlende oder falsch typisierte Felder werden leer. */
export function toGalleryCard(row: Row): GalleryCard {
const count = row.reportCount;
return {
id: text(row.$id),
title: text(row.title),
issuer: text(row.issuer),
tags: text(row.tags)
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag !== ''),
displayName: text(row.displayName),
imageFileId: text(row.imageFileId),
previewFileId: text(row.previewFileId),
sourceCardId: text(row.sourceCardId),
ownerId: text(row.ownerId),
hidden: row.hidden === true,
reportCount: typeof count === 'number' && Number.isFinite(count) && count > 0 ? count : 0,
createdAt: text(row.$createdAt),
};
}
/**
* Abfragen für eine Seite der Galerie, neueste zuerst. Öffentlich immer nur sichtbare Karten;
* Admin-Filter: „Gemeldet“ = reportCount > 0 (auch schon ausgeblendete), „Ausgeblendet“ = hidden.
*/
export function galleryQueries(opts: GalleryOptions): string[] {
const base =
opts.filter === 'reported'
? Query.greaterThan('reportCount', 0)
: opts.filter === 'hidden'
? Query.equal('hidden', true)
: Query.equal('hidden', false);
const queries = [base, Query.orderDesc('$createdAt'), Query.limit(PAGE_SIZE)];
if (opts.cursor) queries.push(Query.cursorAfter(opts.cursor));
const q = (opts.q ?? '').replace(/\s+/g, ' ').trim();
if (q) queries.push(Query.search('searchText', q));
return queries;
}
/** Eine Seite der Galerie. `nextCursor` ist die ID der letzten Karte, solange die Seite voll war, sonst null. */
export async function listGallery(opts: GalleryOptions): Promise<{ cards: GalleryCard[]; nextCursor: string | null }> {
const list = await tables.listRows<TableRow>({
databaseId: IDS.database,
tableId: IDS.gallery,
queries: galleryQueries(opts),
});
const cards = list.rows.map((row) => toGalleryCard(row));
return { cards, nextCursor: cards.length === PAGE_SIZE ? cards[cards.length - 1].id : null };
}
/** „Die Zeile gibt es nicht (oder nicht für mich)“; fehlende Tabelle oder Datenbank ist dagegen ein echter Fehler. */
function isRowMissing(err: unknown): boolean {
if (typeof err !== 'object' || err === null) return false;
const { code, type } = err as { code?: unknown; type?: unknown };
return code === 404 && (type === 'row_not_found' || type === undefined || type === '');
}
/**
* Eine Karte per ID. Gibt es sie nicht oder ist sie für mich ausgeblendet (Appwrite antwortet beides mit 404), ist das
* Ergebnis null. Eine ID, die keine Appwrite-ID sein kann, ergibt null ohne Anfrage.
*/
export async function getGalleryCard(id: string): Promise<GalleryCard | null> {
if (!ID_PATTERN.test(id)) return null;
try {
return toGalleryCard(await tables.getRow<TableRow>({ databaseId: IDS.database, tableId: IDS.gallery, rowId: id }));
} catch (err) {
if (isRowMissing(err)) return null;
throw err;
}
}
/** Die eigenen Veröffentlichungen (auch ausgeblendete), nach privater Karte (`sourceCardId`) geordnet. */
export async function myPublications(userId: string): Promise<Map<string, GalleryCard>> {
const map = new Map<string, GalleryCard>();
if (!userId) return map;
const list = await tables.listRows<TableRow>({
databaseId: IDS.database,
tableId: IDS.gallery,
queries: [Query.equal('ownerId', userId), Query.limit(LIST_MAX)],
});
for (const row of list.rows) {
const card = toGalleryCard(row);
if (card.sourceCardId) map.set(card.sourceCardId, card);
}
return map;
}
/** Admin: die Meldungen einer Karte, neueste zuerst. Der Melder-Hash bleibt außen vor. */
export async function listReports(galleryId: string): Promise<GalleryReport[]> {
if (!ID_PATTERN.test(galleryId)) return [];
const list = await tables.listRows<TableRow>({
databaseId: IDS.database,
tableId: IDS.reports,
queries: [Query.equal('galleryId', galleryId), Query.orderDesc('$createdAt'), Query.limit(LIST_MAX)],
});
return list.rows.map((row) => ({
id: text(row.$id),
galleryId: text(row.galleryId),
reason: text(row.reason),
createdAt: text(row.$createdAt),
}));
}
/** Lädt das Kartenbild (PNG 1536 px) oder die Vorschau (WebP 480 px) aus dem Galerie-Bucket. */
export function loadGalleryImage(card: GalleryCard, size: 'full' | 'preview'): Promise<Blob> {
return fetchFile(IDS.galleryBucket, size === 'full' ? card.imageFileId : card.previewFileId);
}
+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']);
});
});
+121
View File
@@ -0,0 +1,121 @@
// Eine gespeicherte Karte in der Community-Galerie veröffentlichen: rendern, zwei Dateien hochladen, API rufen.
// Scheitert danach irgendetwas, löscht der Client genau die zwei Dateien dieses Versuchs wieder.
import { ID, Permission, Role } from 'appwrite';
import { IDS, storage } from '../account/appwrite';
import { cardsService, requireUserId, toUploadFile, type SavedCard } from '../account/cards';
import { CARD_W } from '../card/geometry';
import { clampCrop } from '../card/crop';
import { exportCard, loadCardAssets } from '../card/render';
import { decodeImage } from '../editor/upload';
import { publishCard, type PublishInput } from './api';
/** Der Galerie-Bucket nimmt Dateien bis 5 MiB an (wie der Server prüft). */
export const PNG_MAX_BYTES = 5_242_880;
const PREVIEW_WIDTH = 480;
const PREVIEW_QUALITY = 0.85;
const TOO_LARGE = 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).';
const NOT_RENDERED = 'Das Kartenbild konnte nicht erzeugt werden.';
const GALLERY_TYPES = ['image/png', 'image/webp'];
export interface PublishForm {
title: string;
issuer: string;
/** Rohe Eingabe, Komma-getrennt; wird mit `normalizeTags` zur Liste. */
tags: string;
displayName: string;
}
/** Alles, was `publishSavedCard` von außen braucht (im Test ersetzbar, ohne Netz und Canvas). */
export interface PublishDeps {
/** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler mit `code` 401. */
userId(): string;
/** Das Originalbild der privaten Karte. */
loadImage(card: SavedCard): Promise<Blob>;
/** Rendert Kartenbild (PNG, 1536 px) und Vorschau (WebP, 480 px). */
render(card: SavedCard, image: Blob): Promise<{ png: Blob; preview: Blob }>;
createFile(file: File, permissions: string[]): Promise<{ $id: string }>;
deleteFile(fileId: string): Promise<void>;
publishCard(input: PublishInput): Promise<{ id: string }>;
}
/** Wie der Server: an Kommas trennen, trimmen, klein schreiben, Leere und Doppelte entfernen. Grenzen prüft der Server. */
export function normalizeTags(input: string): string[] {
const tags: string[] = [];
for (const part of input.split(',')) {
const tag = part.trim().toLowerCase();
if (tag !== '' && !tags.includes(tag)) tags.push(tag);
}
return tags;
}
async function renderForGallery(card: SavedCard, imageBlob: Blob): Promise<{ png: Blob; preview: Blob }> {
const image = await decodeImage(imageBlob);
const config = { ...card.config, crop: clampCrop(image, card.config.crop) };
const assets = await loadCardAssets(config, image);
const png = await exportCard(config, assets, { width: CARD_W, type: 'image/png' });
const preview = await exportCard(config, assets, { width: PREVIEW_WIDTH, type: 'image/webp', quality: PREVIEW_QUALITY });
return { png, preview };
}
function defaultDeps(): PublishDeps {
const service = cardsService();
return {
userId: requireUserId,
loadImage: (card) => service.loadImage(card),
render: renderForGallery,
// Immer eine frische ID: aufgeräumt wird nur, was dieser Versuch selbst angelegt hat.
createFile: (file, permissions) =>
storage.createFile({ bucketId: IDS.galleryBucket, fileId: ID.unique(), file, permissions }),
deleteFile: async (fileId) => {
await storage.deleteFile({ bucketId: IDS.galleryBucket, fileId });
},
publishCard,
};
}
/** Räumt auf, soweit es geht; ein Fehler (auch 404) darf den eigentlichen Fehler nie überdecken. */
async function deleteQuietly(deps: PublishDeps, fileIds: string[]): Promise<void> {
await Promise.allSettled(fileIds.map((id) => deps.deleteFile(id)));
}
/**
* Veröffentlicht die Karte: Bild laden, rendern, PNG und Vorschau in den Galerie-Bucket laden, API rufen.
* Schlägt nach dem ersten Upload etwas fehl (zweiter Upload, API, auch 409), werden die in diesem Versuch
* hochgeladenen Dateien (und nur diese) gelöscht und der Fehler weitergeworfen.
*/
export async function publishSavedCard(
card: SavedCard,
form: PublishForm,
deps: PublishDeps = defaultDeps(),
): Promise<{ id: string }> {
// Ohne Nutzer wirft das hier, bevor etwas geladen oder gerendert wird.
const userId = deps.userId();
const permissions = [Permission.read(Role.any()), Permission.delete(Role.user(userId))];
const source = await deps.loadImage(card);
const { png, preview } = await deps.render(card, source);
// Alles vorab prüfen: ein unzulässiges oder zu großes Bild darf keine halben Uploads hinterlassen.
if (png.type !== 'image/png' || !GALLERY_TYPES.includes(preview.type)) throw new Error(NOT_RENDERED);
if (png.size > PNG_MAX_BYTES || preview.size > PNG_MAX_BYTES) throw new Error(TOO_LARGE);
const imageFile = toUploadFile(png, 'image');
// Der Name folgt dem echten Typ: Browser ohne WebP-Export liefern für „webp“ ein PNG.
const previewFile = toUploadFile(preview, 'preview');
const uploaded: string[] = [];
try {
uploaded.push((await deps.createFile(imageFile, permissions)).$id);
uploaded.push((await deps.createFile(previewFile, permissions)).$id);
return await deps.publishCard({
title: form.title.trim(),
issuer: form.issuer.trim(),
tags: normalizeTags(form.tags),
displayName: form.displayName.trim(),
imageFileId: uploaded[0],
previewFileId: uploaded[1],
sourceCardId: card.id,
});
} catch (err) {
await deleteQuietly(deps, uploaded);
throw err;
}
}