feat(logos): Datenschicht, API-Aufrufe und übersetzbare API-Fehler

- logos/logos.ts (Muster community/gallery.ts): CommunityLogo, toLogo, logoQueries (Beliebt/Neu, Admin-Filter Gemeldet und
  Ausgeblendet, Cursor), listLogos (nextCursor nur bei voller Seite von 24), getLogo (ungültige ID oder 404 -> null),
  myHearts (eigene Herzen einer Seite, ohne IDs keine Anfrage), listLogoReports (ohne Melder-Hash), loadLogoBlob.
- logos/logo-api.ts: uploadLogo, setHeart, reportLogo (ohne Anmeldung), deleteLogo, setLogoVisibility über apiPost.
  uploadLogo behandelt das Limit (429 tooManyLogos) wie publishCard als eindeutige Ablehnung (die hochgeladene Datei darf
  aufgeräumt werden); eine Antwort ohne id (und bei setHeart ohne Zustand und Zähler) ist 503 mit offenem Ausgang.
- account/appwrite.ts: IDS um logos, logoLikes, logoReports und logoBucket.
- community/api.ts: ApiError.serverCode (der Schlüssel aus {error, code}; code bleibt der HTTP-Status, message der deutsche
  Text). Client-Fehler tragen unavailable (auch 502/503/504 vom Proxy, vor dem Lesen des Bodys), generic, jwtUnsupported,
  alreadyPublished, tooManyPublications. Neu: unavailableError() und apiErrorText(err). Die deutschen Texte stehen einmal
  in api-messages.ts, die Vergleiche in publishCard laufen unverändert über err.message.
- i18n/api-messages.ts: API_MESSAGES (alle 33 Texte von MSG plus die Client-Codes generic und jwtUnsupported) und
  apiMessage: bekannter Code -> t(Text); unbekannt: Deutsch der Servertext, Englisch eine Meldung je Status (400, 403, 404,
  sonst allgemein). Der Code badRequest trägt auch die dynamischen Prüftexte des Dienstes, Deutsch zeigt dann den
  genaueren Servertext.
- i18n/en.ts: englische Texte zu allen API_MESSAGES.
- Tests: api-messages.test.ts lädt MSG aus deploy/api/common.js (createRequire) und prüft den Gleichlauf in beide
  Richtungen; logos.test.ts, logo-api.test.ts und neue Fälle in api.test.ts (der Helfer freshApi stellt die frische
  i18n-Instanz auf Deutsch). 156 neue Tests, die bisherigen unverändert grün (1285 gesamt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 19:59:08 +00:00
1 parent fc9e5f5577
commit 3051b3b3f9
10 files changed
+1491 -21

No files matched your search

+260
View File
@@ -0,0 +1,260 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({ createJWT: vi.fn() }));
vi.mock('../account/appwrite', () => ({ account: { createJWT: mocks.createJWT } }));
vi.mock('../account/auth', () => ({
currentUserId: () => 'u1',
onAuthChange: () => () => undefined,
onLogout: () => () => undefined,
}));
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>>();
/** Eine frische Kopie von logo-api.ts und community/api.ts (der JWT-Cache lebt im Modul); beide aus derselben Ladung. */
async function fresh() {
vi.resetModules();
const logoApi = await import('./logo-api');
const api = await import('../community/api');
return { logoApi, api };
}
type ApiErr = InstanceType<Awaited<ReturnType<typeof fresh>>['api']['ApiError']>;
/** Der ApiError, mit dem `promise` scheitert. */
async function rejection(promise: Promise<unknown>): Promise<ApiErr> {
try {
await promise;
} catch (err) {
return err as ApiErr;
}
throw new Error('Die Anfrage ist nicht gescheitert.');
}
const sentBody = (call = 0) => JSON.parse(fetchMock.mock.calls[call][1].body as string) as unknown;
const sentHeaders = (call = 0) => fetchMock.mock.calls[call][1].headers as Record<string, string>;
beforeEach(() => {
mocks.createJWT.mockReset();
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.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('uploadLogo', () => {
const input = { name: 'Beispielbank', displayName: 'Kenso', mono: true, fileId: 'file1' };
it('POST /api/logos/hochladen mit JWT und genau den vier Feldern; liefert { id }', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(201, { id: 'file1' }));
await expect(logoApi.uploadLogo(input)).resolves.toEqual({ id: 'file1' });
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls[0][0]).toBe('/api/logos/hochladen');
expect(fetchMock.mock.calls[0][1].method).toBe('POST');
expect(sentBody()).toEqual(input);
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
});
it('weitere Felder des Aufrufers gehen nicht mit', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(201, { id: 'file1' }));
const wider = { ...input, ownerId: 'u2', hidden: false };
await logoApi.uploadLogo(wider);
expect(sentBody()).toEqual(input);
});
it.each([[{}], [{ id: '' }], [{ id: 42 }], [{ id: null }], [{ ok: true }]])(
'eine Antwort ohne brauchbare id (%j): ApiError 503, Ausgang offen (die Zeile kann angelegt sein)',
async (body) => {
const { logoApi, api } = await fresh();
fetchMock.mockResolvedValue(json(201, body));
const err = await rejection(logoApi.uploadLogo(input));
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 503, message: 'Der Dienst ist gerade nicht erreichbar.', serverCode: 'unavailable' });
expect(err.definitive).toBe(false);
expect(api.isDefinitiveFailure(err)).toBe(false);
},
);
it('429 „schon 30 Logos“ (Limit des Dienstes) ist eindeutig: es wurde nichts angelegt, die Datei darf aufgeräumt werden', async () => {
const { logoApi, api } = await fresh();
const text = 'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.';
fetchMock.mockResolvedValue(json(429, { error: text, code: 'tooManyLogos' }));
const err = await rejection(logoApi.uploadLogo(input));
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 429, message: text, serverCode: 'tooManyLogos', definitive: true });
expect(api.isDefinitiveFailure(err)).toBe(true);
});
it('ein anderes 429 (etwa vom Proxy) bleibt ein Fehler mit offenem Ausgang', async () => {
const { logoApi, api } = await fresh();
fetchMock.mockResolvedValueOnce(json(429, { error: 'Zu viele Anfragen.' }));
expect(api.isDefinitiveFailure(await rejection(logoApi.uploadLogo(input)))).toBe(false);
fetchMock.mockResolvedValueOnce(json(429, { error: 'Zu viele Meldungen – bitte später nochmal.', code: 'tooManyReports' }));
expect(api.isDefinitiveFailure(await rejection(logoApi.uploadLogo(input)))).toBe(false);
});
it.each([
[400, 'logoFormat', 'Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).', true],
[400, 'badRequest', 'Der Name ist zu lang (höchstens 40 Zeichen).', true],
[401, 'unauthorized', 'Bitte melde dich an.', true],
[403, 'foreignFile', 'Diese Datei gehört dir nicht.', true],
[409, 'logoTaken', 'Diese Datei wird schon für ein Logo verwendet.', true],
[500, 'internal', 'Interner Fehler.', false],
] as const)('Fehler %i %s: Status, Text und Schlüssel des Dienstes bleiben, eindeutig: %s', async (status, code, error, definitive) => {
const { logoApi, api } = await fresh();
fetchMock.mockImplementation(async () => json(status, { error, code }));
const err = await rejection(logoApi.uploadLogo(input));
expect(err).toMatchObject({ code: status, message: error, serverCode: code });
expect(api.isDefinitiveFailure(err)).toBe(definitive);
});
it('der Dienst steht (502 vom Proxy, Netzfehler): nicht erreichbar, Ausgang offen', async () => {
const { logoApi, api } = await fresh();
fetchMock.mockResolvedValueOnce(new Response('<html>Bad Gateway</html>', { status: 502 }));
const proxy = await rejection(logoApi.uploadLogo(input));
expect(proxy).toMatchObject({ code: 503, serverCode: 'unavailable' });
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
const network = await rejection(logoApi.uploadLogo(input));
expect(network).toMatchObject({ code: 503, serverCode: 'unavailable' });
expect(api.isDefinitiveFailure(proxy) || api.isDefinitiveFailure(network)).toBe(false);
});
});
describe('setHeart', () => {
it('POST /api/logos/herz { id, herz } mit JWT; liefert Zustand und Zähler des Dienstes', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(200, { herz: true, likeCount: 3 }));
await expect(logoApi.setHeart('l1', true)).resolves.toEqual({ herz: true, likeCount: 3 });
expect(fetchMock.mock.calls[0][0]).toBe('/api/logos/herz');
expect(sentBody()).toEqual({ id: 'l1', herz: true });
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
});
it('herz: false nimmt das Herz zurück', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(200, { herz: false, likeCount: 0 }));
await expect(logoApi.setHeart('l1', false)).resolves.toEqual({ herz: false, likeCount: 0 });
expect(sentBody()).toEqual({ id: 'l1', herz: false });
});
it.each([
[{}],
[{ ok: true }],
[{ herz: true }],
[{ likeCount: 3 }],
[{ herz: 'ja', likeCount: 3 }],
[{ herz: true, likeCount: -1 }],
[{ herz: true, likeCount: 2.5 }],
[{ herz: true, likeCount: '3' }],
])(
'eine Antwort ohne brauchbaren Zustand oder Zähler (%j): ApiError 503, Ausgang offen',
async (body) => {
const { logoApi, api } = await fresh();
fetchMock.mockResolvedValue(json(200, body));
const err = await rejection(logoApi.setHeart('l1', true));
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 503, serverCode: 'unavailable' });
expect(api.isDefinitiveFailure(err)).toBe(false);
},
);
it('404 bei fehlendem oder ausgeblendetem Logo: logoGone', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(404, { error: 'Dieses Logo ist nicht (mehr) verfügbar.', code: 'logoGone' }));
expect(await rejection(logoApi.setHeart('l1', true))).toMatchObject({
code: 404,
message: 'Dieses Logo ist nicht (mehr) verfügbar.',
serverCode: 'logoGone',
});
});
it('ohne Sitzung (401 von der API): ein Versuch mit neuem JWT, dann der Fehler', async () => {
const { logoApi } = await fresh();
fetchMock.mockImplementation(async () => json(401, { error: 'Bitte melde dich an.', code: 'unauthorized' }));
expect(await rejection(logoApi.setHeart('l1', true))).toMatchObject({ code: 401, serverCode: 'unauthorized' });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe('reportLogo', () => {
it('POST /api/logos/melden { id, reason } ohne JWT; { schonGemeldet: false } bei neuer Meldung', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(200, { ok: true }));
await expect(logoApi.reportLogo('l1', 'spam')).resolves.toEqual({ schonGemeldet: false });
expect(fetchMock.mock.calls[0][0]).toBe('/api/logos/melden');
expect(sentBody()).toEqual({ id: 'l1', reason: 'spam' });
expect(sentHeaders()['X-Aircard-Jwt']).toBeUndefined();
expect(mocks.createJWT).not.toHaveBeenCalled();
});
it('{ schonGemeldet: true } bei doppelter Meldung', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(200, { schonGemeldet: true }));
await expect(logoApi.reportLogo('l1', 'urheberrecht')).resolves.toEqual({ schonGemeldet: true });
});
it('429 Rate-Limit: Text und Schlüssel des Dienstes', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Meldungen – bitte später nochmal.', code: 'tooManyReports' }));
expect(await rejection(logoApi.reportLogo('l1', 'spam'))).toMatchObject({
code: 429,
message: 'Zu viele Meldungen – bitte später nochmal.',
serverCode: 'tooManyReports',
});
});
it('404 bei fehlendem oder ausgeblendetem Logo: logoGone', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(404, { error: 'Dieses Logo ist nicht (mehr) verfügbar.', code: 'logoGone' }));
expect(await rejection(logoApi.reportLogo('l1', 'spam'))).toMatchObject({ code: 404, serverCode: 'logoGone' });
});
});
describe('deleteLogo', () => {
it('POST /api/logos/loeschen { id } mit JWT; liefert nichts', async () => {
const { logoApi } = await fresh();
await expect(logoApi.deleteLogo('l1')).resolves.toBeUndefined();
expect(fetchMock.mock.calls[0][0]).toBe('/api/logos/loeschen');
expect(sentBody()).toEqual({ id: 'l1' });
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
});
it('403 bei fremdem Logo: Text und Schlüssel des Dienstes', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(403, { error: 'Dazu hast du keine Berechtigung.', code: 'forbidden' }));
expect(await rejection(logoApi.deleteLogo('l1'))).toMatchObject({ code: 403, serverCode: 'forbidden' });
});
});
describe('setLogoVisibility', () => {
it('POST /api/admin/logo-sichtbarkeit { id, sichtbar } mit JWT; liefert nichts', async () => {
const { logoApi } = await fresh();
await expect(logoApi.setLogoVisibility('l1', false)).resolves.toBeUndefined();
await logoApi.setLogoVisibility('l1', true);
expect(fetchMock.mock.calls[0][0]).toBe('/api/admin/logo-sichtbarkeit');
expect(sentBody(0)).toEqual({ id: 'l1', sichtbar: false });
expect(sentBody(1)).toEqual({ id: 'l1', sichtbar: true });
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
});
it('403 für Nicht-Admins: Text und Schlüssel des Dienstes', async () => {
const { logoApi } = await fresh();
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.', code: 'adminOnly' }));
expect(await rejection(logoApi.setLogoVisibility('l1', true))).toMatchObject({
code: 403,
message: 'Nur für Admins.',
serverCode: 'adminOnly',
});
});
});
+61
View File
@@ -0,0 +1,61 @@
// Schreibzugriffe der Community-Logos: Aufrufe des Dienstes `aircard-api` (deploy/api) mit dem Nutzer-JWT, über `apiPost` und
// mit denselben Fehlern (`ApiError`) wie community/api.ts. Lesen läuft direkt über Appwrite (logos.ts).
import { ApiError, apiPost, unavailableError, type ReportReason } from '../community/api';
export interface LogoUploadInput {
name: string;
displayName: string;
mono: boolean;
/** Die Datei im Logo-Bucket (frisch hochgeladen, mit `delete("user:<ich>")`); ihre ID wird auch die ID des Logos. */
fileId: string;
}
/**
* Legt aus der hochgeladenen Datei ein Logo an (der Dienst prüft Felder, Datei, Maße und das Limit von 30 je Konto).
* Das Limit (429 `tooManyLogos`) ist eine eindeutige Ablehnung: es wurde nichts angelegt, die Datei darf aufgeräumt werden
* (wie beim Limit der Karten in `publishCard`); jedes andere 429 und alle 5xx lassen den Ausgang offen. Eine Antwort ohne
* `id` ist ein Fehler mit offenem Ausgang (die Zeile kann angelegt sein).
*/
export async function uploadLogo(input: LogoUploadInput): Promise<{ id: string }> {
const body: LogoUploadInput = { name: input.name, displayName: input.displayName, mono: input.mono, fileId: input.fileId };
let res: Record<string, unknown>;
try {
res = await apiPost('/api/logos/hochladen', body, { auth: true });
} catch (err) {
if (err instanceof ApiError && err.code === 429 && err.serverCode === 'tooManyLogos') {
throw new ApiError(429, err.message, true, err.serverCode);
}
throw err;
}
if (typeof res.id !== 'string' || res.id === '') throw unavailableError();
return { id: res.id };
}
/**
* Setzt oder nimmt mein Herz für ein Logo und liefert Zustand und Zähler, wie der Dienst sie nach dem Zählen der Zeilen kennt.
* Eine Antwort ohne beides ist ein Fehler mit offenem Ausgang.
*/
export async function setHeart(id: string, herz: boolean): Promise<{ herz: boolean; likeCount: number }> {
const res = await apiPost('/api/logos/herz', { id, herz }, { auth: true });
const { likeCount } = res;
if (typeof res.herz !== 'boolean' || typeof likeCount !== 'number' || !Number.isInteger(likeCount) || likeCount < 0) {
throw unavailableError();
}
return { herz: res.herz, likeCount };
}
/** Meldet ein Logo (ohne Anmeldung). `schonGemeldet`: dieser Melder hatte es schon gemeldet. */
export async function reportLogo(id: string, reason: ReportReason): Promise<{ schonGemeldet: boolean }> {
const res = await apiPost('/api/logos/melden', { id, reason }, { auth: false });
return { schonGemeldet: res.schonGemeldet === true };
}
/** Löscht ein Logo samt Datei, Herzen und Meldungen (Hochlader oder Admin). */
export async function deleteLogo(id: string): Promise<void> {
await apiPost('/api/logos/loeschen', { id }, { auth: true });
}
/** Admin: blendet ein Logo ein oder aus. */
export async function setLogoVisibility(id: string, visible: boolean): Promise<void> {
await apiPost('/api/admin/logo-sichtbarkeit', { id, sichtbar: visible }, { auth: true });
}
+465
View File
@@ -0,0 +1,465 @@
import { AppwriteException, 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',
logos: 'logos-table',
logoLikes: 'likes-table',
logoReports: 'logo-reports-table',
logoBucket: 'logo-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 {
LOGO_PAGE_SIZE,
getLogo,
listLogoReports,
listLogos,
loadLogoBlob,
logoQueries,
myHearts,
toLogo,
type CommunityLogo,
} from './logos';
const row = (n: number, patch: Record<string, unknown> = {}) => ({
$id: `l${n}`,
$createdAt: `2026-10-0${(n % 9) + 1}T10:00:00.000Z`,
name: `Logo ${n}`,
displayName: 'Kenso',
fileId: `l${n}`,
ownerId: 'u1',
mono: false,
likeCount: n,
hidden: false,
reportCount: 0,
...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('IDS', () => {
it('kennt die Logo-Tabellen und den Logo-Bucket (Namen wie im Setup-Skript)', async () => {
vi.stubEnv('VITE_APPWRITE_ENDPOINT', 'https://aircard.test/v1');
try {
const { IDS } = await vi.importActual<typeof import('../account/appwrite')>('../account/appwrite');
expect(IDS).toMatchObject({
logos: 'logos',
logoLikes: 'logo_likes',
logoReports: 'logo_reports',
logoBucket: 'logo-images',
});
expect(IDS.database).toBe('aircard'); // und das Bestehende bleibt
expect(IDS.gallery).toBe('gallery');
} finally {
vi.unstubAllEnvs();
}
});
});
describe('logoQueries', () => {
it('Beliebt: nur sichtbare Logos, meiste Herzen zuerst, bei Gleichstand die neuesten, 24 pro Seite', () => {
expect(logoQueries({ sort: 'popular' })).toEqual([
Query.equal('hidden', false),
Query.orderDesc('likeCount'),
Query.orderDesc('$createdAt'),
Query.limit(24),
]);
expect(LOGO_PAGE_SIZE).toBe(24);
});
it('die Abfragen sind genau die Appwrite-JSON-Strings', () => {
expect(logoQueries({ sort: 'popular' })).toEqual([
JSON.stringify({ method: 'equal', attribute: 'hidden', values: [false] }),
JSON.stringify({ method: 'orderDesc', attribute: 'likeCount' }),
JSON.stringify({ method: 'orderDesc', attribute: '$createdAt' }),
JSON.stringify({ method: 'limit', values: [24] }),
]);
});
it('Neu: neueste zuerst, ohne Sortierung nach likeCount', () => {
const queries = logoQueries({ sort: 'new' });
expect(queries).toEqual([Query.equal('hidden', false), Query.orderDesc('$createdAt'), Query.limit(24)]);
expect(queries.join('')).not.toContain('likeCount');
});
it("Admin-Filter 'all' ist die öffentliche Ansicht", () => {
expect(logoQueries({ sort: 'popular', filter: 'all' })).toEqual(logoQueries({ sort: 'popular' }));
expect(logoQueries({ sort: 'new', filter: 'all' })).toEqual(logoQueries({ sort: 'new' }));
});
it("Admin-Filter 'reported': reportCount > 0, ohne hidden-Bedingung (gemeldet und schon ausgeblendet)", () => {
const queries = logoQueries({ sort: 'new', 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 Logos", () => {
expect(logoQueries({ sort: 'new', filter: 'hidden' })).toEqual([
Query.equal('hidden', true),
Query.orderDesc('$createdAt'),
Query.limit(24),
]);
});
it('Filter und Sortierung sind unabhängig: Gemeldet nach Beliebtheit', () => {
expect(logoQueries({ sort: 'popular', filter: 'reported' })).toEqual([
Query.greaterThan('reportCount', 0),
Query.orderDesc('likeCount'),
Query.orderDesc('$createdAt'),
Query.limit(24),
]);
});
it('mit Cursor: zusätzlich cursorAfter(letzte ID), als letzte Abfrage', () => {
expect(logoQueries({ sort: 'popular', cursor: 'l24' })).toEqual([
Query.equal('hidden', false),
Query.orderDesc('likeCount'),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.cursorAfter('l24'),
]);
expect(logoQueries({ sort: 'new', filter: 'hidden', cursor: 'l5' })).toEqual([
Query.equal('hidden', true),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.cursorAfter('l5'),
]);
});
it('ein leerer Cursor wird ignoriert', () => {
expect(logoQueries({ sort: 'new', cursor: '' })).toEqual(logoQueries({ sort: 'new' }));
});
});
describe('toLogo', () => {
it('übernimmt alle Felder', () => {
expect(toLogo(row(1, { mono: true, hidden: true, reportCount: 3, likeCount: 7 }))).toEqual({
id: 'l1',
name: 'Logo 1',
displayName: 'Kenso',
fileId: 'l1',
ownerId: 'u1',
mono: true,
likeCount: 7,
hidden: true,
reportCount: 3,
createdAt: row(1).$createdAt,
} satisfies CommunityLogo);
});
it('fehlende Felder bekommen leere Standardwerte', () => {
expect(toLogo({ $id: 'l9' })).toEqual({
id: 'l9',
name: '',
displayName: '',
fileId: '',
ownerId: '',
mono: false,
likeCount: 0,
hidden: false,
reportCount: 0,
createdAt: '',
});
});
it('Felder mit falschem Typ gelten als fehlend', () => {
expect(
toLogo({
$id: 'l9',
name: 42,
displayName: null,
fileId: ['x'],
ownerId: { id: 'u1' },
mono: 'ja',
likeCount: 'viele',
hidden: 1,
reportCount: '3',
$createdAt: 5,
}),
).toEqual({
id: 'l9',
name: '',
displayName: '',
fileId: '',
ownerId: '',
mono: false,
likeCount: 0,
hidden: false,
reportCount: 0,
createdAt: '',
});
});
it('eine fehlende oder falsch typisierte $id wird leer', () => {
expect(toLogo({}).id).toBe('');
expect(toLogo({ $id: 7 }).id).toBe('');
});
it('mono und hidden sind nur bei true wahr', () => {
for (const value of [false, 'true', 1, null, undefined, 'ja']) {
const logo = toLogo({ $id: 'l1', mono: value, hidden: value });
expect(logo.mono).toBe(false);
expect(logo.hidden).toBe(false);
}
expect(toLogo({ $id: 'l1', mono: true, hidden: true })).toMatchObject({ mono: true, hidden: true });
});
it('likeCount und reportCount: nur endliche Zahlen größer als 0, sonst 0', () => {
for (const field of ['likeCount', 'reportCount'] as const) {
expect(toLogo({ $id: 'l1', [field]: 5 })[field]).toBe(5);
expect(toLogo({ $id: 'l1', [field]: 0 })[field]).toBe(0);
expect(toLogo({ $id: 'l1', [field]: -1 })[field]).toBe(0);
expect(toLogo({ $id: 'l1', [field]: -0.5 })[field]).toBe(0);
expect(toLogo({ $id: 'l1', [field]: Number.NaN })[field]).toBe(0);
expect(toLogo({ $id: 'l1', [field]: Number.POSITIVE_INFINITY })[field]).toBe(0);
expect(toLogo({ $id: 'l1', [field]: '5' })[field]).toBe(0);
expect(toLogo({ $id: 'l1', [field]: null })[field]).toBe(0);
}
});
});
describe('listLogos', () => {
it('fragt die Logo-Tabelle mit den Abfragen ab und liefert Logos', async () => {
mocks.listRows.mockResolvedValue({ total: 2, rows: [row(1), row(2, { mono: true })] });
const page = await listLogos({ sort: 'popular' });
expect(mocks.listRows).toHaveBeenCalledOnce();
expect(mocks.listRows.mock.calls[0][0]).toEqual({
databaseId: 'db',
tableId: 'logos-table',
queries: logoQueries({ sort: 'popular' }),
});
expect(page.logos.map((logo) => logo.id)).toEqual(['l1', 'l2']);
expect(page.logos[1]).toMatchObject({ name: 'Logo 2', mono: true, likeCount: 2 });
});
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 listLogos({ sort: 'new' })).nextCursor).toBeNull();
});
it('keine Zeilen: leere Liste, nextCursor null', async () => {
mocks.listRows.mockResolvedValue({ total: 0, rows: [] });
await expect(listLogos({ sort: 'new' })).resolves.toEqual({ logos: [], nextCursor: null });
});
it('genau 24 Zeilen: nextCursor ist die ID des letzten Logos', async () => {
mocks.listRows.mockResolvedValue({ total: 60, rows: Array.from({ length: 24 }, (_, i) => row(i + 1)) });
const page = await listLogos({ sort: 'popular' });
expect(page.logos).toHaveLength(24);
expect(page.nextCursor).toBe('l24');
});
it('die nächste Seite schickt den Cursor, die Admin-Ansicht den Filter mit', async () => {
mocks.listRows.mockResolvedValue({ total: 0, rows: [] });
await listLogos({ sort: 'new', cursor: 'l24', filter: 'reported' });
expect(mocks.listRows.mock.calls[0][0].queries).toEqual(logoQueries({ sort: 'new', cursor: 'l24', filter: 'reported' }));
expect(mocks.listRows.mock.calls[0][0].queries).toContain(Query.cursorAfter('l24'));
});
it('Fehler gehen durch', async () => {
const failure = Object.assign(new Error('down'), { code: 503 });
mocks.listRows.mockRejectedValue(failure);
await expect(listLogos({ sort: 'new' })).rejects.toBe(failure);
});
});
describe('getLogo', () => {
it('liefert das Logo', async () => {
mocks.getRow.mockResolvedValue(row(7, { mono: true }));
const logo = await getLogo('l7');
expect(logo).toMatchObject({ id: 'l7', mono: true });
expect(mocks.getRow).toHaveBeenCalledWith({ databaseId: 'db', tableId: 'logos-table', rowId: 'l7' });
});
it('404 (nicht vorhanden oder ausgeblendet für mich) ergibt null', async () => {
mocks.getRow.mockRejectedValue(notFound());
await expect(getLogo('l7')).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(getLogo('l7')).resolves.toBeNull();
});
it('eine fehlende Tabelle (404 table_not_found) ist ein Konfigurationsfehler, kein „Logo weg“', async () => {
const failure = notFound('table_not_found');
mocks.getRow.mockRejectedValue(failure);
await expect(getLogo('l7')).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(getLogo('l7')).rejects.toBe(failure);
});
it('Netzfehler gehen durch', async () => {
const failure = new TypeError('Failed to fetch');
mocks.getRow.mockRejectedValue(failure);
await expect(getLogo('l7')).rejects.toBe(failure);
});
it.each(['', 'kaputt id', '../x', 'a/b', 'a?b', '.hidden', '-x', 'x'.repeat(37), 'ä'])(
'ungültige ID %j: null, ohne Anfrage',
async (id) => {
await expect(getLogo(id)).resolves.toBeNull();
expect(mocks.getRow).not.toHaveBeenCalled();
},
);
});
describe('myHearts', () => {
it('ohne Logo-IDs: leere Menge, ohne Anfrage', async () => {
const hearts = await myHearts('u', []);
expect(hearts).toBeInstanceOf(Set);
expect(hearts.size).toBe(0);
expect(mocks.listRows).not.toHaveBeenCalled();
});
it('ohne Nutzer-ID: leere Menge, ohne Anfrage', async () => {
expect((await myHearts('', ['l1'])).size).toBe(0);
expect(mocks.listRows).not.toHaveBeenCalled();
});
it('fragt die eigenen Herzen für genau diese Logos ab (höchstens 100)', async () => {
mocks.listRows.mockResolvedValue({ total: 2, rows: [{ $id: 'h1', logoId: 'l1', userId: 'u' }, { $id: 'h3', logoId: 'l3', userId: 'u' }] });
const hearts = await myHearts('u', ['l1', 'l2', 'l3']);
expect(mocks.listRows).toHaveBeenCalledOnce();
expect(mocks.listRows.mock.calls[0][0]).toEqual({
databaseId: 'db',
tableId: 'likes-table',
queries: [Query.equal('userId', 'u'), Query.equal('logoId', ['l1', 'l2', 'l3']), Query.limit(100)],
});
expect([...hearts].sort()).toEqual(['l1', 'l3']);
});
it('die Abfragen sind genau die Appwrite-JSON-Strings', async () => {
mocks.listRows.mockResolvedValue({ total: 0, rows: [] });
await myHearts('u', ['l1', 'l2']);
expect(mocks.listRows.mock.calls[0][0].queries).toEqual([
JSON.stringify({ method: 'equal', attribute: 'userId', values: ['u'] }),
JSON.stringify({ method: 'equal', attribute: 'logoId', values: ['l1', 'l2'] }),
JSON.stringify({ method: 'limit', values: [100] }),
]);
});
it('ohne Herzen: leere Menge', async () => {
mocks.listRows.mockResolvedValue({ total: 0, rows: [] });
expect((await myHearts('u', ['l1'])).size).toBe(0);
});
it('Zeilen ohne brauchbare logoId werden übersprungen', async () => {
mocks.listRows.mockResolvedValue({
total: 3,
rows: [{ $id: 'h1', logoId: 'l1' }, { $id: 'h2' }, { $id: 'h3', logoId: 5 }],
});
expect([...(await myHearts('u', ['l1', 'l2']))]).toEqual(['l1']);
});
it('Fehler gehen durch', async () => {
const failure = Object.assign(new Error('down'), { code: 503 });
mocks.listRows.mockRejectedValue(failure);
await expect(myHearts('u', ['l1'])).rejects.toBe(failure);
});
});
describe('listLogoReports', () => {
it('liest die Meldungen eines Logos (höchstens 100), neueste zuerst', async () => {
mocks.listRows.mockResolvedValue({
total: 2,
rows: [
{ $id: 'r2', $createdAt: '2026-10-04T11:00:00.000Z', logoId: 'l1', reason: 'spam', reporterHash: 'abc' },
{ $id: 'r1', $createdAt: '2026-10-04T10:00:00.000Z', logoId: 'l1', reason: 'urheberrecht', reporterHash: 'def' },
],
});
const reports = await listLogoReports('l1');
expect(mocks.listRows.mock.calls[0][0]).toEqual({
databaseId: 'db',
tableId: 'logo-reports-table',
queries: [Query.equal('logoId', 'l1'), Query.orderDesc('$createdAt'), Query.limit(100)],
});
expect(reports).toEqual([
{ id: 'r2', logoId: 'l1', reason: 'spam', createdAt: '2026-10-04T11:00:00.000Z' },
{ id: 'r1', logoId: 'l1', 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', logoId: 'l1', reason: 'spam', reporterHash: 'abc' }] });
expect(JSON.stringify(await listLogoReports('l1'))).not.toContain('abc');
});
it('fehlende Felder werden leer', async () => {
mocks.listRows.mockResolvedValue({ total: 1, rows: [{ $id: 'r1' }] });
await expect(listLogoReports('l1')).resolves.toEqual([{ id: 'r1', logoId: '', reason: '', createdAt: '' }]);
});
it('ungültige ID: leere Liste, ohne Anfrage', async () => {
await expect(listLogoReports('../x')).resolves.toEqual([]);
await expect(listLogoReports('')).resolves.toEqual([]);
expect(mocks.listRows).not.toHaveBeenCalled();
});
it('Fehler gehen durch', async () => {
const failure = Object.assign(new Error('forbidden'), { code: 401 });
mocks.listRows.mockRejectedValue(failure);
await expect(listLogoReports('l1')).rejects.toBe(failure);
});
});
describe('loadLogoBlob', () => {
const logo = toLogo(row(3));
const blob = new Blob(['x'], { type: 'image/png' });
it('lädt die PNG-Datei aus dem Logo-Bucket', async () => {
mocks.fetchFile.mockResolvedValue(blob);
await expect(loadLogoBlob(logo)).resolves.toBe(blob);
expect(mocks.fetchFile).toHaveBeenCalledWith('logo-bucket', 'l3');
});
it('Fehler beim Laden gehen durch (404: Datei fehlt)', async () => {
const failure = notFound('storage_file_not_found');
mocks.fetchFile.mockRejectedValue(failure);
await expect(loadLogoBlob(logo)).rejects.toBe(failure);
});
it.each(['', '../x', 'a/b', 'a?b', '.hidden', 'x'.repeat(37)])(
'eine Datei-ID, die keine Appwrite-ID sein kann (%j): 404 „Datei fehlt“, ohne Anfrage',
async (fileId) => {
const err = await loadLogoBlob({ ...logo, fileId }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(AppwriteException);
expect(err).toMatchObject({ code: 404, type: 'storage_file_not_found' });
expect(mocks.fetchFile).not.toHaveBeenCalled();
},
);
});
+156
View File
@@ -0,0 +1,156 @@
// Die Community-Logos lesen: Tabelle `logos` direkt aus Appwrite (Gäste dürfen sichtbare Zeilen lesen), dazu die eigenen Herzen
// (`logo_likes`, jeder liest nur seine) und die Meldungen (`logo_reports`, nur Admins). Schreiben (Hochladen, Herz, Melden,
// Löschen, Ausblenden) läuft über den API-Dienst, siehe logo-api.ts. Aufbau wie community/gallery.ts.
import { AppwriteException, Query, type Models } from 'appwrite';
import { ID_PATTERN, fetchFile } from '../account/cards';
import { IDS, tables } from '../account/appwrite';
export interface CommunityLogo {
id: string;
name: string;
displayName: string;
fileId: string;
ownerId: string;
/** Einfarbig: nur die Deckkraft zählt, das Logo nimmt die Farbe der Karte an (hell/dunkel). */
mono: boolean;
likeCount: number;
hidden: boolean;
reportCount: number;
createdAt: string;
}
export interface LogoReport {
id: string;
logoId: string;
reason: string;
createdAt: string;
}
/** Beliebt: meiste Herzen zuerst, bei Gleichstand die neuesten; Neu: neueste zuerst. */
export type LogoSort = 'popular' | 'new';
/** Ansicht der Logos: öffentlich `all`; `reported`/`hidden` zeigen nur Admins etwas (die Rechte prüft der Server). */
export type LogoFilter = 'all' | 'reported' | 'hidden';
export interface LogoListOptions {
sort: LogoSort;
filter?: LogoFilter;
cursor?: string;
}
export const LOGO_PAGE_SIZE = 24;
/** Obergrenze für die Meldungen eines Logos und die Herzen einer Seite (eine Seite hat 24 Logos). */
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 : '';
}
function count(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
}
/** Eine Zeile der Tabelle `logos` als Logo; fehlende oder falsch typisierte Felder werden leer. */
export function toLogo(row: Row): CommunityLogo {
return {
id: text(row.$id),
name: text(row.name),
displayName: text(row.displayName),
fileId: text(row.fileId),
ownerId: text(row.ownerId),
mono: row.mono === true,
likeCount: count(row.likeCount),
hidden: row.hidden === true,
reportCount: count(row.reportCount),
createdAt: text(row.$createdAt),
};
}
/**
* Abfragen für eine Seite der Logos. Öffentlich immer nur sichtbare; Admin-Filter: „Gemeldet“ = reportCount > 0 (auch schon
* ausgeblendete), „Ausgeblendet“ = hidden. Beliebt sortiert nach Herzen, dann nach Alter (beides absteigend), Neu nur nach Alter.
* Optional: Cursor (die ID des letzten Logos der Seite davor).
*/
export function logoQueries(opts: LogoListOptions): string[] {
const base =
opts.filter === 'reported'
? Query.greaterThan('reportCount', 0)
: opts.filter === 'hidden'
? Query.equal('hidden', true)
: Query.equal('hidden', false);
const order = opts.sort === 'popular' ? [Query.orderDesc('likeCount'), Query.orderDesc('$createdAt')] : [Query.orderDesc('$createdAt')];
const queries = [base, ...order, Query.limit(LOGO_PAGE_SIZE)];
if (opts.cursor) queries.push(Query.cursorAfter(opts.cursor));
return queries;
}
/** Eine Seite der Logos. `nextCursor` ist die ID des letzten Logos, solange die Seite voll war, sonst null. */
export async function listLogos(opts: LogoListOptions): Promise<{ logos: CommunityLogo[]; nextCursor: string | null }> {
const list = await tables.listRows<TableRow>({ databaseId: IDS.database, tableId: IDS.logos, queries: logoQueries(opts) });
const logos = list.rows.map((row) => toLogo(row));
return { logos, nextCursor: logos.length === LOGO_PAGE_SIZE ? logos[logos.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 === '');
}
/**
* Ein Logo per ID. Gibt es das Logo nicht oder ist es für mich ausgeblendet oder gelöscht (Appwrite antwortet beides mit 404),
* ist das Ergebnis null. Eine ID, die keine Appwrite-ID sein kann, ergibt null ohne Anfrage.
*/
export async function getLogo(id: string): Promise<CommunityLogo | null> {
if (!ID_PATTERN.test(id)) return null;
try {
return toLogo(await tables.getRow<TableRow>({ databaseId: IDS.database, tableId: IDS.logos, rowId: id }));
} catch (err) {
if (isRowMissing(err)) return null;
throw err;
}
}
/** Von diesen Logos (einer Seite, höchstens 100) die, die ich mit einem Herz markiert habe; Gäste und Nutzer ohne Herzen: leer. */
export async function myHearts(userId: string, logoIds: readonly string[]): Promise<Set<string>> {
const hearts = new Set<string>();
if (!userId || logoIds.length === 0) return hearts;
const list = await tables.listRows<TableRow>({
databaseId: IDS.database,
tableId: IDS.logoLikes,
queries: [Query.equal('userId', userId), Query.equal('logoId', [...logoIds]), Query.limit(LIST_MAX)],
});
for (const row of list.rows) {
const logoId = text(row.logoId);
if (logoId) hearts.add(logoId);
}
return hearts;
}
/** Admin: die Meldungen eines Logos, neueste zuerst. Der Melder-Hash bleibt außen vor. */
export async function listLogoReports(logoId: string): Promise<LogoReport[]> {
if (!ID_PATTERN.test(logoId)) return [];
const list = await tables.listRows<TableRow>({
databaseId: IDS.database,
tableId: IDS.logoReports,
queries: [Query.equal('logoId', logoId), Query.orderDesc('$createdAt'), Query.limit(LIST_MAX)],
});
return list.rows.map((row) => ({
id: text(row.$id),
logoId: text(row.logoId),
reason: text(row.reason),
createdAt: text(row.$createdAt),
}));
}
/** Lädt die PNG-Datei eines Logos aus dem Logo-Bucket. */
export async function loadLogoBlob(logo: CommunityLogo): Promise<Blob> {
// Die ID landet im Pfad der Anfrage; was keine Appwrite-ID sein kann, gibt es nicht.
if (!ID_PATTERN.test(logo.fileId)) throw new AppwriteException('File not found', 404, 'storage_file_not_found', '');
return fetchFile(IDS.logoBucket, logo.fileId);
}