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
@@ -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_ENDPOINT: string = import.meta.env.VITE_APPWRITE_ENDPOINT || `${location.origin}/v1`;
|
||||||
export const APPWRITE_PROJECT: string = import.meta.env.VITE_APPWRITE_PROJECT_ID || '6ac2130a00289343d6d5';
|
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 client: Client = new Client().setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT);
|
||||||
export const account: Account = new Account(client);
|
export const account: Account = new Account(client);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ vi.mock('./appwrite', () => ({
|
|||||||
import {
|
import {
|
||||||
appwriteCardsApi,
|
appwriteCardsApi,
|
||||||
createCardsService,
|
createCardsService,
|
||||||
|
fetchFile,
|
||||||
normalizeName,
|
normalizeName,
|
||||||
ownerPermissions,
|
ownerPermissions,
|
||||||
toUploadFile,
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,7 +47,7 @@ export interface SaveInput {
|
|||||||
export const DEFAULT_CARD_NAME = 'Meine Karte';
|
export const DEFAULT_CARD_NAME = 'Meine Karte';
|
||||||
const NAME_MAX = 80;
|
const NAME_MAX = 80;
|
||||||
/** Zeilen- und Datei-IDs von Appwrite: höchstens 36 Zeichen, kein Sonderzeichen am Anfang. */
|
/** 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“. */
|
/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“. */
|
||||||
export function normalizeName(name: string): string {
|
export function normalizeName(name: string): string {
|
||||||
@@ -193,11 +193,13 @@ export function createCardsService(api: CardsApi, userId: () => string) {
|
|||||||
|
|
||||||
export type CardsService = ReturnType<typeof createCardsService>;
|
export type CardsService = ReturnType<typeof createCardsService>;
|
||||||
|
|
||||||
/** Lädt eine Bilddatei mit Projekt-Header und Cookie (siehe `client.prepareRequest`). */
|
/** Lädt eine Bilddatei aus einem Bucket mit Projekt-Header und Cookie (siehe `client.prepareRequest`). */
|
||||||
async function fetchFile(fileId: string): Promise<Blob> {
|
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
|
// 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.
|
// 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 { uri, options } = client.prepareRequest('GET', url);
|
||||||
const res = await fetch(uri, options);
|
const res = await fetch(uri, options);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -231,7 +233,8 @@ export function appwriteCardsApi(): CardsApi {
|
|||||||
deleteFile: async (fileId) => {
|
deleteFile: async (fileId) => {
|
||||||
await storage.deleteFile({ bucketId: IDS.bucket, 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) =>
|
createRow: (data, permissions) =>
|
||||||
tables.createRow<TableRow>({ ...target, rowId: ID.unique(), data, permissions }),
|
tables.createRow<TableRow>({ ...target, rowId: ID.unique(), data, permissions }),
|
||||||
// Beim Ändern bleiben die Rechte der Zeile unangetastet.
|
// Beim Ändern bleiben die Rechte der Zeile unangetastet.
|
||||||
|
|||||||
@@ -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.' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user