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:
1 parent
fc9e5f5577
commit
3051b3b3f9
10 files changed
+1491
-21
No files matched your search
@@ -7,7 +7,8 @@ export const APPWRITE_PROJECT: string = import.meta.env.VITE_APPWRITE_PROJECT_ID
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Datenbank, Tabellen und Buckets des Projekts (siehe web/scripts/appwrite-setup.mjs).
|
* 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.
|
* `table`/`bucket` sind die privaten Karten; `gallery`/`reports`/`galleryBucket` gehören zur Community-Galerie,
|
||||||
|
* `logos`/`logoLikes`/`logoReports`/`logoBucket` zu den Community-Logos (Logos, Herzen, Meldungen, Logo-Dateien).
|
||||||
*/
|
*/
|
||||||
export const IDS = {
|
export const IDS = {
|
||||||
database: 'aircard',
|
database: 'aircard',
|
||||||
@@ -16,6 +17,10 @@ export const IDS = {
|
|||||||
gallery: 'gallery',
|
gallery: 'gallery',
|
||||||
reports: 'reports',
|
reports: 'reports',
|
||||||
galleryBucket: 'gallery-images',
|
galleryBucket: 'gallery-images',
|
||||||
|
logos: 'logos',
|
||||||
|
logoLikes: 'logo_likes',
|
||||||
|
logoReports: 'logo_reports',
|
||||||
|
logoBucket: 'logo-images',
|
||||||
} as const;
|
} 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);
|
||||||
|
|||||||
@@ -25,10 +25,18 @@ type Api = typeof import('./api');
|
|||||||
const UNAVAILABLE = 'Der Dienst ist gerade nicht erreichbar.';
|
const UNAVAILABLE = 'Der Dienst ist gerade nicht erreichbar.';
|
||||||
const MINUTE = 60_000;
|
const MINUTE = 60_000;
|
||||||
|
|
||||||
// Der JWT-Cache lebt im Modul; jeder Test bekommt eine frische Instanz.
|
// Der JWT-Cache lebt im Modul; jeder Test bekommt eine frische Instanz. Das gilt auch für i18n: die frische Instanz
|
||||||
|
// beginnt mit der Browsersprache (im Test Englisch) und wird wie im Test-Setup auf Deutsch gestellt.
|
||||||
async function freshApi(): Promise<Api> {
|
async function freshApi(): Promise<Api> {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
return import('./api');
|
const api = await import('./api');
|
||||||
|
(await import('../i18n/i18n')).setLang('de', { persist: false });
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stellt die Sprache der frischen Instanz um (dieselbe, die `freshApi` geladen hat). */
|
||||||
|
async function setLanguage(lang: 'de' | 'en'): Promise<void> {
|
||||||
|
(await import('../i18n/i18n')).setLang(lang, { persist: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = (status: number, body: unknown) =>
|
const json = (status: number, body: unknown) =>
|
||||||
@@ -593,3 +601,256 @@ describe('withdrawCard, reportCard, setVisibility', () => {
|
|||||||
await expect(api.setVisibility('g1', true)).rejects.toMatchObject({ code: 403, message: 'Nur für Admins.' });
|
await expect(api.setVisibility('g1', true)).rejects.toMatchObject({ code: 403, message: 'Nur für Admins.' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('serverCode: der Schlüssel der Fehlerantwort', () => {
|
||||||
|
const fail = (api: Api) => api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e);
|
||||||
|
|
||||||
|
it('ApiError trägt ohne Angabe serverCode null; das Feld lässt sich setzen, ohne code und definitive zu ändern', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
expect(new api.ApiError(400, 'x').serverCode).toBeNull();
|
||||||
|
expect(new api.ApiError(400, 'x', undefined, null).serverCode).toBeNull();
|
||||||
|
const err = new api.ApiError(409, 'x', undefined, 'alreadyPublished');
|
||||||
|
expect(err).toMatchObject({ code: 409, status: 409, message: 'x', definitive: true, serverCode: 'alreadyPublished' });
|
||||||
|
expect(new api.ApiError(429, 'x', true, 'tooManyPublications')).toMatchObject({ definitive: true, serverCode: 'tooManyPublications' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wird aus {error, code} der Antwort gelesen; code bleibt der Status, message der deutsche Text', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.', code: 'adminOnly' }));
|
||||||
|
const err = await fail(api);
|
||||||
|
expect(err).toBeInstanceOf(api.ApiError);
|
||||||
|
expect(err).toMatchObject({ code: 403, status: 403, message: 'Nur für Admins.', serverCode: 'adminOnly' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne code in der Antwort (älterer Dienst, Proxy) bleibt serverCode null', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.' }));
|
||||||
|
expect(await fail(api)).toMatchObject({ code: 403, message: 'Nur für Admins.', serverCode: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([[42], [''], [null], [true], [['adminOnly']], [{ a: 1 }]])('ein code, der kein nichtleerer Text ist (%j), zählt nicht', async (code) => {
|
||||||
|
const api = await freshApi();
|
||||||
|
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.', code }));
|
||||||
|
expect(await fail(api)).toMatchObject({ message: 'Nur für Admins.', serverCode: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Dienst sendet Dienst-Fehler mit ihrem Schlüssel: 429, 404, 409', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
for (const [status, error, code] of [
|
||||||
|
[429, 'Zu viele Meldungen – bitte später nochmal.', 'tooManyReports'],
|
||||||
|
[404, 'Dieses Logo ist nicht (mehr) verfügbar.', 'logoGone'],
|
||||||
|
[409, 'Diese Datei wird schon für ein Logo verwendet.', 'logoTaken'],
|
||||||
|
] as const) {
|
||||||
|
fetchMock.mockResolvedValueOnce(json(status, { error, code }));
|
||||||
|
expect(await fail(api)).toMatchObject({ code: status, message: error, serverCode: code });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Netzfehler und Zeitlimit: unavailable', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||||
|
expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' });
|
||||||
|
vi.spyOn(AbortSignal, 'timeout').mockReturnValue(AbortSignal.abort(new DOMException('The operation timed out.', 'TimeoutError')));
|
||||||
|
fetchMock.mockImplementation(async (_url, init) => {
|
||||||
|
throw init.signal?.reason;
|
||||||
|
});
|
||||||
|
expect(await fail(api)).toMatchObject({ code: 503, serverCode: 'unavailable' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([502, 503, 504])('Status %i vom Proxy (ohne code im Body, auch mit fremdem code): unavailable', async (status) => {
|
||||||
|
const api = await freshApi();
|
||||||
|
fetchMock.mockResolvedValueOnce(json(status, { error: 'irgendwas' }));
|
||||||
|
expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' });
|
||||||
|
fetchMock.mockResolvedValueOnce(json(status, { error: 'irgendwas', code: 'internal' }));
|
||||||
|
expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehlerseite ohne JSON, HTML mit Status 200 und JSON, das kein Objekt ist: unavailable', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
for (const response of [
|
||||||
|
new Response('<html>404 page not found</html>', { status: 404 }),
|
||||||
|
new Response('<!doctype html><title>AirCard</title>', { status: 200 }),
|
||||||
|
new Response('"ok"', { status: 200 }),
|
||||||
|
]) {
|
||||||
|
fetchMock.mockResolvedValueOnce(response);
|
||||||
|
expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine abgebrochene Antwort (res.text() scheitert): unavailable', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
fetchMock.mockResolvedValue({ ok: true, status: 200, text: () => Promise.reject(new TypeError('network error')) } as unknown as Response);
|
||||||
|
expect(await fail(api)).toMatchObject({ code: 503, serverCode: 'unavailable' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JSON-Fehler ohne Text und ohne code: generic', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
fetchMock.mockResolvedValue(json(500, {}));
|
||||||
|
expect(await fail(api)).toMatchObject({ code: 500, message: 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.', serverCode: 'generic' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JSON-Fehler ohne Text, aber mit code: der code des Dienstes', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
fetchMock.mockResolvedValue(json(404, { code: 'logoGone' }));
|
||||||
|
expect(await fail(api)).toMatchObject({ code: 404, message: 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.', serverCode: 'logoGone' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createJWT mit user_auth_method_unsupported: jwtUnsupported (und eindeutig)', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 501, type: 'user_auth_method_unsupported' }));
|
||||||
|
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
|
||||||
|
expect(err).toMatchObject({ code: 501, serverCode: 'jwtUnsupported', definitive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der zweite Versuch nach einem 401 liefert den Schlüssel der zweiten Antwort', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
fetchMock.mockImplementation(async () => json(401, { error: 'Bitte melde dich an.', code: 'unauthorized' }));
|
||||||
|
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
|
||||||
|
expect(err).toMatchObject({ code: 401, message: 'Bitte melde dich an.', serverCode: 'unauthorized' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unavailableError(): 503, Ausgang offen, Schlüssel unavailable', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
const err = api.unavailableError();
|
||||||
|
expect(err).toBeInstanceOf(api.ApiError);
|
||||||
|
expect(err).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable', definitive: false });
|
||||||
|
expect(api.isDefinitiveFailure(err)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('beim Veröffentlichen', () => {
|
||||||
|
const input = {
|
||||||
|
title: 'Meine Karte',
|
||||||
|
issuer: 'Beispielbank',
|
||||||
|
tags: ['visa'],
|
||||||
|
displayName: 'Kenso',
|
||||||
|
imageFileId: 'img1',
|
||||||
|
previewFileId: 'pre1',
|
||||||
|
sourceCardId: 'card1',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('403 „Dateien belegt“: der Dienst sendet filesTaken, publishCard macht daraus 409 alreadyPublished', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
const text = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
|
||||||
|
fetchMock.mockImplementation(async () => json(403, { error: text, code: 'filesTaken' })); // je Aufruf eine neue Response
|
||||||
|
expect(await api.apiPost('/api/veroeffentlichen', input, { auth: true }).catch((e: unknown) => e)).toMatchObject({
|
||||||
|
code: 403,
|
||||||
|
message: text,
|
||||||
|
serverCode: 'filesTaken',
|
||||||
|
});
|
||||||
|
const err = await api.publishCard(input).catch((e: unknown) => e);
|
||||||
|
expect(err).toMatchObject({ code: 409, message: 'Diese Karte ist schon veröffentlicht.', serverCode: 'alreadyPublished' });
|
||||||
|
expect(api.isAlreadyPublished(err)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('409 vom Dienst: alreadyPublished', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
fetchMock.mockResolvedValue(json(409, { error: 'Diese Karte ist schon veröffentlicht.', code: 'alreadyPublished' }));
|
||||||
|
expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 409, serverCode: 'alreadyPublished' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('429 „schon 20 Karten“: tooManyPublications, eindeutig', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
const text = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.';
|
||||||
|
fetchMock.mockResolvedValue(json(429, { error: text, code: 'tooManyPublications' }));
|
||||||
|
const err = await api.publishCard(input).catch((e: unknown) => e);
|
||||||
|
expect(err).toMatchObject({ code: 429, message: text, serverCode: 'tooManyPublications', definitive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne die Schlüssel (Dienst von vorher) klappt die Umdeutung weiter über den deutschen Text', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
fetchMock.mockResolvedValueOnce(json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' }));
|
||||||
|
expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 409, serverCode: 'alreadyPublished' });
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
json(429, { error: 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.' }),
|
||||||
|
);
|
||||||
|
expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 429, serverCode: 'tooManyPublications', definitive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine Antwort ohne id: unavailable, Ausgang offen', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
fetchMock.mockResolvedValue(json(201, {}));
|
||||||
|
const err = await api.publishCard(input).catch((e: unknown) => e);
|
||||||
|
expect(err).toMatchObject({ code: 503, serverCode: 'unavailable', definitive: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein anderes 403 behält seinen Schlüssel', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
fetchMock.mockResolvedValue(json(403, { error: 'Diese Datei gehört dir nicht.', code: 'foreignFile' }));
|
||||||
|
expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 403, serverCode: 'foreignFile' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type ApiErr = InstanceType<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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('apiErrorText: die Fehlermeldung zum Anzeigen', () => {
|
||||||
|
it('Deutsch: der Text des Dienstes, auch bei Fehlern ohne bekannten Schlüssel', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
expect(api.apiErrorText(new api.ApiError(409, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished'))).toBe(
|
||||||
|
'Diese Karte ist schon veröffentlicht.',
|
||||||
|
);
|
||||||
|
expect(api.apiErrorText(new api.ApiError(400, 'Der Titel ist zu lang (höchstens 60 Zeichen).', undefined, 'badRequest'))).toBe(
|
||||||
|
'Der Titel ist zu lang (höchstens 60 Zeichen).',
|
||||||
|
);
|
||||||
|
expect(api.apiErrorText(new api.ApiError(418, 'Ich bin eine Teekanne.'))).toBe('Ich bin eine Teekanne.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Englisch: übersetzt über den Schlüssel', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
await setLanguage('en');
|
||||||
|
expect(api.apiErrorText(new api.ApiError(409, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished'))).toBe(
|
||||||
|
'This card is already published.',
|
||||||
|
);
|
||||||
|
expect(api.apiErrorText(new api.ApiError(403, 'Nur für Admins.', undefined, 'adminOnly'))).toBe('Admins only.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Englisch: Fehler, die der Client selbst erzeugt (Netz, JWT, Veröffentlichen), sind übersetzt', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
await setLanguage('en');
|
||||||
|
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||||
|
const network = await rejection(api.apiPost('/api/x', {}, { auth: false }));
|
||||||
|
expect(api.apiErrorText(network)).toBe('The service is currently unavailable.');
|
||||||
|
|
||||||
|
mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 501, type: 'user_auth_method_unsupported' }));
|
||||||
|
const jwt = await rejection(api.apiPost('/api/x', {}, { auth: true }));
|
||||||
|
expect(api.apiErrorText(jwt)).toBe('Token sign-in is turned off on the server – please let the site owner know.');
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue(json(500, {}));
|
||||||
|
const generic = await rejection(api.apiPost('/api/x', {}, { auth: false }));
|
||||||
|
expect(api.apiErrorText(generic)).toBe('Something went wrong. Please try again.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Englisch: unbekannter oder fehlender Schlüssel, allgemeine Meldung je Status (die ApiError.code)', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
await setLanguage('en');
|
||||||
|
expect(api.apiErrorText(new api.ApiError(400, 'Das gibt es nur auf Deutsch.'))).toBe('Invalid request.');
|
||||||
|
expect(api.apiErrorText(new api.ApiError(403, 'Das gibt es nur auf Deutsch.', undefined, 'neuerCode'))).toBe(
|
||||||
|
'You are not allowed to do that.',
|
||||||
|
);
|
||||||
|
expect(api.apiErrorText(new api.ApiError(404, 'Das gibt es nur auf Deutsch.'))).toBe('Not found.');
|
||||||
|
expect(api.apiErrorText(new api.ApiError(500, 'Das gibt es nur auf Deutsch.'))).toBe('Something went wrong. Please try again.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('übergibt Schlüssel, Status und Text an apiMessage', async () => {
|
||||||
|
const api = await freshApi();
|
||||||
|
const { apiMessage } = await import('../i18n/api-messages');
|
||||||
|
await setLanguage('en');
|
||||||
|
for (const err of [
|
||||||
|
new api.ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', undefined, 'logoGone'),
|
||||||
|
new api.ApiError(400, 'Der Name ist ungültig.', undefined, 'badRequest'),
|
||||||
|
new api.ApiError(403, 'Text', undefined, null),
|
||||||
|
]) {
|
||||||
|
expect(api.apiErrorText(err)).toBe(apiMessage(err.serverCode, err.code, err.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+47
-18
@@ -4,6 +4,7 @@
|
|||||||
import { account } from '../account/appwrite';
|
import { account } from '../account/appwrite';
|
||||||
import { currentUserId, onAuthChange, onLogout } from '../account/auth';
|
import { currentUserId, onAuthChange, onLogout } from '../account/auth';
|
||||||
import { errorType } from '../account/errors';
|
import { errorType } from '../account/errors';
|
||||||
|
import { API_MESSAGES, apiMessage } from '../i18n/api-messages';
|
||||||
|
|
||||||
export type ReportReason = 'unangemessen' | 'kartendaten' | 'urheberrecht' | 'spam' | 'sonstiges';
|
export type ReportReason = 'unangemessen' | 'kartendaten' | 'urheberrecht' | 'spam' | 'sonstiges';
|
||||||
|
|
||||||
@@ -17,14 +18,16 @@ export interface PublishInput {
|
|||||||
sourceCardId: string;
|
sourceCardId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UNAVAILABLE = 'Der Dienst ist gerade nicht erreichbar.';
|
// Die deutschen Texte stehen einmal in i18n/api-messages.ts (dort mit MSG des Dienstes abgeglichen); `ApiError.message` bleibt
|
||||||
const GENERIC = 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.';
|
// Deutsch, damit der Vergleich mit ihnen weiter geht. Angezeigt wird über `apiErrorText`.
|
||||||
const JWT_UNSUPPORTED = 'Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.';
|
const UNAVAILABLE = API_MESSAGES.unavailable;
|
||||||
const ALREADY_PUBLISHED = 'Diese Karte ist schon veröffentlicht.';
|
const GENERIC = API_MESSAGES.generic;
|
||||||
/** Wortlaut der API (deploy/api/app.js, MSG.filesTaken): die hochgeladenen Dateien sind schon belegt oder gesperrt. */
|
const JWT_UNSUPPORTED = API_MESSAGES.jwtUnsupported;
|
||||||
const FILES_TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
|
const ALREADY_PUBLISHED = API_MESSAGES.alreadyPublished;
|
||||||
|
/** Wortlaut der API (deploy/api/common.js, MSG.filesTaken): die hochgeladenen Dateien sind schon belegt oder gesperrt. */
|
||||||
|
const FILES_TAKEN = API_MESSAGES.filesTaken;
|
||||||
/** Wortlaut der API (MSG.tooManyPublications): das Limit von 20 Veröffentlichungen je Konto ist erreicht. */
|
/** Wortlaut der API (MSG.tooManyPublications): das Limit von 20 Veröffentlichungen je Konto ist erreicht. */
|
||||||
const TOO_MANY_PUBLICATIONS = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.';
|
const TOO_MANY_PUBLICATIONS = API_MESSAGES.tooManyPublications;
|
||||||
/** Appwrite-JWTs gelten 15 Minuten; wir verwenden eines höchstens 10 Minuten. */
|
/** Appwrite-JWTs gelten 15 Minuten; wir verwenden eines höchstens 10 Minuten. */
|
||||||
const JWT_TTL_MS = 10 * 60 * 1000;
|
const JWT_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
@@ -33,6 +36,9 @@ const REQUEST_TIMEOUT_MS = 15_000;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fehler des API-Dienstes. `code` ist der HTTP-Status (`status` dasselbe), `message` der deutsche Servertext.
|
* Fehler des API-Dienstes. `code` ist der HTTP-Status (`status` dasselbe), `message` der deutsche Servertext.
|
||||||
|
* `serverCode` ist der Schlüssel der Fehlerantwort (`code` in `{error, code}`, siehe MSG in deploy/api/common.js; Fehler, die
|
||||||
|
* der Client selbst erzeugt, tragen die passenden Schlüssel `unavailable`, `generic`, `jwtUnsupported`, …), null ohne Schlüssel.
|
||||||
|
* Übersetzt wird der Text nach `serverCode` (`apiErrorText`), nie nach `message`.
|
||||||
* `definitive`: der Dienst hat die Anfrage eindeutig abgelehnt oder es ging gar keine raus, also wurde nichts angelegt.
|
* `definitive`: der Dienst hat die Anfrage eindeutig abgelehnt oder es ging gar keine raus, also wurde nichts angelegt.
|
||||||
* Ohne Angabe gilt das für 4xx außer Zeitüberschreitung (408) und Drosselung (429); 5xx, 408, 429 und alles, was
|
* Ohne Angabe gilt das für 4xx außer Zeitüberschreitung (408) und Drosselung (429); 5xx, 408, 429 und alles, was
|
||||||
* keine verwertbare Antwort ergab (Netzfehler, Zeitlimit, Fehlerseite, fehlende `id`), lassen offen, ob die
|
* keine verwertbare Antwort ergab (Netzfehler, Zeitlimit, Fehlerseite, fehlende `id`), lassen offen, ob die
|
||||||
@@ -41,12 +47,14 @@ const REQUEST_TIMEOUT_MS = 15_000;
|
|||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
readonly code: number;
|
readonly code: number;
|
||||||
readonly definitive: boolean;
|
readonly definitive: boolean;
|
||||||
|
readonly serverCode: string | null;
|
||||||
|
|
||||||
constructor(code: number, message: string, definitive?: boolean) {
|
constructor(code: number, message: string, definitive?: boolean, serverCode?: string | null) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'ApiError';
|
this.name = 'ApiError';
|
||||||
this.code = code;
|
this.code = code;
|
||||||
this.definitive = definitive ?? (code >= 400 && code < 500 && code !== 408 && code !== 429);
|
this.definitive = definitive ?? (code >= 400 && code < 500 && code !== 408 && code !== 429);
|
||||||
|
this.serverCode = serverCode ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get status(): number {
|
get status(): number {
|
||||||
@@ -54,6 +62,16 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Der Dienst gab keine verwertbare Antwort: 503, Ausgang offen (die Anfrage kann trotzdem gewirkt haben). */
|
||||||
|
export function unavailableError(): ApiError {
|
||||||
|
return new ApiError(503, UNAVAILABLE, false, 'unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Text eines Fehlers zum Anzeigen, in der Sprache der Seite (bekannter `serverCode` übersetzt, siehe `apiMessage`). */
|
||||||
|
export function apiErrorText(err: ApiError): string {
|
||||||
|
return apiMessage(err.serverCode, err.code, err.message);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wahr, wenn eine fehlgeschlagene `apiPost`-Anfrage sicher nichts bewirkt hat: eindeutige Ablehnung des Dienstes,
|
* Wahr, wenn eine fehlgeschlagene `apiPost`-Anfrage sicher nichts bewirkt hat: eindeutige Ablehnung des Dienstes,
|
||||||
* oder ein Fehler vor der Anfrage (JWT holen). Falsch bei offenem Ausgang (siehe `ApiError.definitive`).
|
* oder ein Fehler vor der Anfrage (JWT holen). Falsch bei offenem Ausgang (siehe `ApiError.definitive`).
|
||||||
@@ -96,7 +114,7 @@ async function mintJwt(userId: string | null, started: number): Promise<string>
|
|||||||
if (errorType(err) === 'user_auth_method_unsupported') {
|
if (errorType(err) === 'user_auth_method_unsupported') {
|
||||||
const code = (err as { code?: unknown }).code;
|
const code = (err as { code?: unknown }).code;
|
||||||
// Es ging keine Anfrage an den API-Dienst raus: eindeutig.
|
// Es ging keine Anfrage an den API-Dienst raus: eindeutig.
|
||||||
throw new ApiError(typeof code === 'number' ? code : 501, JWT_UNSUPPORTED, true);
|
throw new ApiError(typeof code === 'number' ? code : 501, JWT_UNSUPPORTED, true, 'jwtUnsupported');
|
||||||
}
|
}
|
||||||
throw err; // z. B. 401 ohne Sitzung: withLogin erkennt es am Statuscode
|
throw err; // z. B. 401 ohne Sitzung: withLogin erkennt es am Statuscode
|
||||||
}
|
}
|
||||||
@@ -134,6 +152,12 @@ function serverMessage(body: Json): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Der Schlüssel einer Fehlerantwort (`code` neben `error`), sonst null. */
|
||||||
|
function serverCodeOf(body: Json): string | null {
|
||||||
|
const value = body.code;
|
||||||
|
return typeof value === 'string' && value !== '' ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
async function send(path: string, body: unknown, jwt: string | null): Promise<Json> {
|
async function send(path: string, body: unknown, jwt: string | null): Promise<Json> {
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
if (jwt) headers['X-Aircard-Jwt'] = jwt;
|
if (jwt) headers['X-Aircard-Jwt'] = jwt;
|
||||||
@@ -152,18 +176,23 @@ async function send(path: string, body: unknown, jwt: string | null): Promise<Js
|
|||||||
text = await res.text();
|
text = await res.text();
|
||||||
} catch {
|
} catch {
|
||||||
// Netzfehler, Zeitlimit oder abgebrochene Antwort: die Anfrage kann trotzdem angekommen sein.
|
// Netzfehler, Zeitlimit oder abgebrochene Antwort: die Anfrage kann trotzdem angekommen sein.
|
||||||
throw new ApiError(503, UNAVAILABLE);
|
throw unavailableError();
|
||||||
}
|
}
|
||||||
// Der Proxy antwortet, wenn der Dienst fehlt oder steht (502/503/504, Fehlerseite, Seitenfallback als HTML-200).
|
// Der Proxy antwortet, wenn der Dienst fehlt oder steht (502/503/504, Fehlerseite, Seitenfallback als HTML-200). Sein Body
|
||||||
if (res.status === 502 || res.status === 503 || res.status === 504) throw new ApiError(503, UNAVAILABLE);
|
// trägt keinen `code`; darum wird schon der Status (vor dem Lesen des Bodys) zu `unavailable`.
|
||||||
|
if (res.status === 502 || res.status === 503 || res.status === 504) throw unavailableError();
|
||||||
let data: unknown;
|
let data: unknown;
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(text);
|
data = JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
throw new ApiError(503, UNAVAILABLE);
|
throw unavailableError();
|
||||||
|
}
|
||||||
|
if (!isRecord(data)) throw unavailableError();
|
||||||
|
if (!res.ok) {
|
||||||
|
const message = serverMessage(data);
|
||||||
|
// Ohne Text und ohne Schlüssel: der allgemeine Fehler.
|
||||||
|
throw new ApiError(res.status, message ?? GENERIC, undefined, serverCodeOf(data) ?? (message === null ? 'generic' : null));
|
||||||
}
|
}
|
||||||
if (!isRecord(data)) throw new ApiError(503, UNAVAILABLE);
|
|
||||||
if (!res.ok) throw new ApiError(res.status, serverMessage(data) ?? GENERIC);
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,14 +229,14 @@ export async function publishCard(input: PublishInput): Promise<{ id: string }>
|
|||||||
res = await apiPost('/api/veroeffentlichen', input, { auth: true });
|
res = await apiPost('/api/veroeffentlichen', input, { auth: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.code === 403 && err.message === FILES_TAKEN) {
|
if (err instanceof ApiError && err.code === 403 && err.message === FILES_TAKEN) {
|
||||||
throw new ApiError(409, ALREADY_PUBLISHED);
|
throw new ApiError(409, ALREADY_PUBLISHED, undefined, 'alreadyPublished');
|
||||||
}
|
}
|
||||||
if (err instanceof ApiError && err.code === 429 && err.message === TOO_MANY_PUBLICATIONS) {
|
if (err instanceof ApiError && err.code === 429 && err.message === TOO_MANY_PUBLICATIONS) {
|
||||||
throw new ApiError(429, TOO_MANY_PUBLICATIONS, true);
|
throw new ApiError(429, TOO_MANY_PUBLICATIONS, true, 'tooManyPublications');
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
if (typeof res.id !== 'string' || res.id === '') throw new ApiError(503, UNAVAILABLE);
|
if (typeof res.id !== 'string' || res.id === '') throw unavailableError();
|
||||||
return { id: res.id };
|
return { id: res.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { API_MESSAGES, apiMessage } from './api-messages';
|
||||||
|
import { EN } from './en';
|
||||||
|
import { setLang } from './i18n';
|
||||||
|
|
||||||
|
// MSG, alle Fehlertexte der API, liegt im Dienst (deploy/api/common.js, CommonJS ohne Abhängigkeiten); der Test lädt es von dort.
|
||||||
|
const { MSG } = createRequire(import.meta.url)('../../../deploy/api/common.js') as { MSG: Record<string, string> };
|
||||||
|
|
||||||
|
const GENERIC_EN = 'Something went wrong. Please try again.';
|
||||||
|
|
||||||
|
describe('Gleichlauf mit MSG im API-Dienst (deploy/api/common.js)', () => {
|
||||||
|
it('der Dienst liefert seine Texte (sonst bestünde der Vergleich leer)', () => {
|
||||||
|
expect(Object.keys(MSG).length).toBeGreaterThan(30);
|
||||||
|
expect(MSG.alreadyPublished).toBe('Diese Karte ist schon veröffentlicht.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jeder Schlüssel von MSG steht mit demselben deutschen Text in API_MESSAGES', () => {
|
||||||
|
const different = Object.keys(MSG).filter((key) => API_MESSAGES[key] !== MSG[key]);
|
||||||
|
expect(different).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('darüber hinaus hat API_MESSAGES nur die Client-Codes generic und jwtUnsupported', () => {
|
||||||
|
const extra = Object.keys(API_MESSAGES).filter((key) => !Object.hasOwn(MSG, key));
|
||||||
|
expect(extra.sort()).toEqual(['generic', 'jwtUnsupported']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jeder Text hat eine englische Übersetzung, die vom Deutschen abweicht', () => {
|
||||||
|
const untranslated = Object.values(API_MESSAGES).filter((text) => !Object.hasOwn(EN, text) || EN[text] === text);
|
||||||
|
expect(untranslated).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('apiMessage: bekannter Code', () => {
|
||||||
|
it('Deutsch: der Text des Codes', () => {
|
||||||
|
expect(apiMessage('alreadyPublished', 409, 'Diese Karte ist schon veröffentlicht.')).toBe(
|
||||||
|
'Diese Karte ist schon veröffentlicht.',
|
||||||
|
);
|
||||||
|
expect(apiMessage('logoGone', 404, 'Dieses Logo ist nicht (mehr) verfügbar.')).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Englisch: die Übersetzung des Codes', () => {
|
||||||
|
setLang('en');
|
||||||
|
expect(apiMessage('alreadyPublished', 409, 'Diese Karte ist schon veröffentlicht.')).toBe('This card is already published.');
|
||||||
|
expect(apiMessage('logoGone', 404, 'Dieses Logo ist nicht (mehr) verfügbar.')).toBe('This logo is no longer available.');
|
||||||
|
expect(apiMessage('unavailable', 503, 'Der Dienst ist gerade nicht erreichbar.')).toBe('The service is currently unavailable.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jeder Code in beiden Sprachen: deutsch wie im Wörterbuch, englisch laut en.ts', () => {
|
||||||
|
for (const [code, text] of Object.entries(API_MESSAGES)) {
|
||||||
|
setLang('de');
|
||||||
|
expect(apiMessage(code, 400, text)).toBe(text);
|
||||||
|
setLang('en');
|
||||||
|
expect(apiMessage(code, 400, text)).toBe(EN[text]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Englisch: der Code entscheidet, nicht der mitgelieferte Text und nicht der Status', () => {
|
||||||
|
setLang('en');
|
||||||
|
expect(apiMessage('logoGone', 500, 'irgendein anderer Text')).toBe('This logo is no longer available.');
|
||||||
|
expect(apiMessage('logoGone', 404, '')).toBe('This logo is no longer available.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Sprache wird bei jedem Aufruf neu gelesen', () => {
|
||||||
|
expect(apiMessage('logoGone', 404, 'x')).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
|
||||||
|
setLang('en');
|
||||||
|
expect(apiMessage('logoGone', 404, 'x')).toBe('This logo is no longer available.');
|
||||||
|
setLang('de');
|
||||||
|
expect(apiMessage('logoGone', 404, 'x')).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Client-Codes generic und jwtUnsupported werden wie die des Dienstes übersetzt', () => {
|
||||||
|
setLang('en');
|
||||||
|
expect(apiMessage('generic', 500, 'x')).toBe(GENERIC_EN);
|
||||||
|
expect(apiMessage('jwtUnsupported', 501, 'x')).toBe(
|
||||||
|
'Token sign-in is turned off on the server – please let the site owner know.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('apiMessage: dynamische Prüftexte (Code badRequest)', () => {
|
||||||
|
const dynamic = 'Der Titel ist zu lang (höchstens 60 Zeichen).';
|
||||||
|
|
||||||
|
it('Deutsch: der genauere Servertext statt „Ungültige Anfrage.“', () => {
|
||||||
|
expect(apiMessage('badRequest', 400, dynamic)).toBe(dynamic);
|
||||||
|
expect(apiMessage('badRequest', 400, 'Ungültige Anfrage.')).toBe('Ungültige Anfrage.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Englisch: der allgemeine Text des Codes (der deutsche Prüftext lässt sich nicht übersetzen)', () => {
|
||||||
|
setLang('en');
|
||||||
|
expect(apiMessage('badRequest', 400, dynamic)).toBe('Invalid request.');
|
||||||
|
expect(apiMessage('badRequest', 400, 'Ungültige Anfrage.')).toBe('Invalid request.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('apiMessage: unbekannter Code', () => {
|
||||||
|
it.each([null, 'error', 'neuerCode', ''])('Deutsch (%j): der Servertext', (code) => {
|
||||||
|
expect(apiMessage(code, 500, 'Der Server sagt etwas.')).toBe('Der Server sagt etwas.');
|
||||||
|
expect(apiMessage(code, 400, 'Der Server sagt etwas.')).toBe('Der Server sagt etwas.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[400, 'Invalid request.'],
|
||||||
|
[403, 'You are not allowed to do that.'],
|
||||||
|
[404, 'Not found.'],
|
||||||
|
[401, GENERIC_EN],
|
||||||
|
[409, GENERIC_EN],
|
||||||
|
[429, GENERIC_EN],
|
||||||
|
[500, GENERIC_EN],
|
||||||
|
[503, GENERIC_EN],
|
||||||
|
] as const)('Englisch: Status %i → „%s“', (status, expected) => {
|
||||||
|
setLang('en');
|
||||||
|
for (const code of [null, 'error', 'neuerCode']) expect(apiMessage(code, status, 'Der Server sagt etwas.')).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['constructor', 'toString', '__proto__', 'hasOwnProperty'])(
|
||||||
|
'Namen aus Object.prototype (%s) sind kein Code',
|
||||||
|
(code) => {
|
||||||
|
expect(apiMessage(code, 500, 'Servertext')).toBe('Servertext');
|
||||||
|
setLang('en');
|
||||||
|
expect(apiMessage(code, 500, 'Servertext')).toBe(GENERIC_EN);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Übersetzbare Fehlertexte der API: Der Dienst `aircard-api` (deploy/api) antwortet auf Fehler mit `{error, code}`. `error` ist der
|
||||||
|
// deutsche Text, `code` sein Schlüssel in MSG (deploy/api/common.js). Hier steht jeder Text noch einmal unter seinem Schlüssel
|
||||||
|
// (der Browser lädt den Dienst nicht); api-messages.test.ts lädt MSG und prüft den Gleichlauf. Dazu kommen zwei Client-Codes.
|
||||||
|
// Übersetzt wird beim Anzeigen (`apiMessage`); `ApiError.message` bleibt der deutsche Text, damit Vergleiche damit weiter gehen.
|
||||||
|
import { currentLang, msg, t } from './i18n';
|
||||||
|
|
||||||
|
export const API_MESSAGES: Readonly<Record<string, string>> = {
|
||||||
|
// Die Schlüssel und Texte von MSG, in derselben Reihenfolge.
|
||||||
|
unauthorized: msg('Bitte melde dich an.'),
|
||||||
|
notFound: msg('Nicht gefunden.'),
|
||||||
|
cardGone: msg('Diese Karte ist nicht (mehr) verfügbar.'),
|
||||||
|
forbidden: msg('Dazu hast du keine Berechtigung.'),
|
||||||
|
adminOnly: msg('Nur für Admins.'),
|
||||||
|
foreignFile: msg('Diese Datei gehört dir nicht.'),
|
||||||
|
fileMissing: msg('Bild nicht gefunden.'),
|
||||||
|
fileType: msg('Das Bild muss eine PNG- oder WebP-Datei sein.'),
|
||||||
|
fileSize: msg('Das Bild ist größer als 5 MB.'),
|
||||||
|
alreadyPublished: msg('Diese Karte ist schon veröffentlicht.'),
|
||||||
|
filesTaken: msg('Diese Bilder sind bereits veröffentlicht oder gesperrt.'),
|
||||||
|
imageFormat: msg('Das Bild hat nicht das Kartenformat.'),
|
||||||
|
notYourCard: msg('Diese Karte gehört nicht zu deinem Konto.'),
|
||||||
|
tooManyPublications: msg('Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.'),
|
||||||
|
tooManyReports: msg('Zu viele Meldungen – bitte später nochmal.'),
|
||||||
|
badRequest: msg('Ungültige Anfrage.'),
|
||||||
|
badCardId: msg('Ungültige Karten-ID.'),
|
||||||
|
badReason: msg('Bitte wähle einen gültigen Grund.'),
|
||||||
|
badVisibility: msg('Ungültige Angabe zur Sichtbarkeit.'),
|
||||||
|
logoGone: msg('Dieses Logo ist nicht (mehr) verfügbar.'),
|
||||||
|
badLogoId: msg('Ungültige Logo-ID.'),
|
||||||
|
logoNameMissing: msg('Bitte gib einen Namen an.'),
|
||||||
|
logoFileType: msg('Das Logo muss eine PNG-Datei sein.'),
|
||||||
|
logoFileSize: msg('Das Logo ist größer als 1 MB.'),
|
||||||
|
logoFormat: msg('Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).'),
|
||||||
|
logoTaken: msg('Diese Datei wird schon für ein Logo verwendet.'),
|
||||||
|
tooManyLogos: msg('Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.'),
|
||||||
|
badHeart: msg('Ungültige Angabe zum Herz.'),
|
||||||
|
badMono: msg('Ungültige Angabe zu „einfarbig“.'),
|
||||||
|
tooLarge: msg('Die Anfrage ist zu groß.'),
|
||||||
|
jsonOnly: msg('Nur application/json wird unterstützt.'),
|
||||||
|
unavailable: msg('Der Dienst ist gerade nicht erreichbar.'),
|
||||||
|
internal: msg('Interner Fehler.'),
|
||||||
|
// Nur im Client (kein Schlüssel im Dienst): allgemeiner Fehler und „Anmeldung per Token ausgeschaltet“.
|
||||||
|
generic: msg('Etwas ist schiefgelaufen. Bitte nochmal versuchen.'),
|
||||||
|
jwtUnsupported: msg('Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Die allgemeine Meldung zu einem Status, wenn der Code unbekannt ist (Spec § 7): 400, 403, 404, sonst „Etwas ist schiefgelaufen“. */
|
||||||
|
function fallbackText(status: number): string {
|
||||||
|
if (status === 400) return API_MESSAGES.badRequest;
|
||||||
|
if (status === 403) return API_MESSAGES.forbidden;
|
||||||
|
if (status === 404) return API_MESSAGES.notFound;
|
||||||
|
return API_MESSAGES.generic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Text zu einem Fehler des Dienstes (oder einem Fehler, den der Client mit denselben Codes erzeugt) in der Sprache der Seite.
|
||||||
|
* Ein bekannter Code wird über das Wörterbuch übersetzt. Ein unbekannter oder fehlender Code (älterer Dienst, Proxy) zeigt auf
|
||||||
|
* Deutsch den Text des Dienstes, auf Englisch eine allgemeine Meldung je Status.
|
||||||
|
*/
|
||||||
|
export function apiMessage(serverCode: string | null, status: number, serverText: string): string {
|
||||||
|
const english = currentLang() === 'en';
|
||||||
|
if (serverCode !== null && Object.hasOwn(API_MESSAGES, serverCode)) {
|
||||||
|
// `badRequest` tragen auch die dynamischen Prüftexte des Dienstes („Der Titel ist zu lang (höchstens 60 Zeichen).“):
|
||||||
|
// Deutsch zeigt dann den genaueren Servertext, Englisch den allgemeinen Text des Codes.
|
||||||
|
if (!english && serverCode === 'badRequest') return serverText;
|
||||||
|
return t(API_MESSAGES[serverCode]);
|
||||||
|
}
|
||||||
|
return english ? t(fallbackText(status)) : serverText;
|
||||||
|
}
|
||||||
@@ -30,4 +30,44 @@ export const EN: Readonly<Record<string, string>> = {
|
|||||||
// Fußzeile (shell/footer.ts)
|
// Fußzeile (shell/footer.ts)
|
||||||
'Inoffizielle Community-Seite für die App AirCard-iOS – kein Teil des Original-Projekts und nicht mit Apple verbunden. Logos und Motive stammen von Nutzern; alle Marken gehören ihren Inhabern.':
|
'Inoffizielle Community-Seite für die App AirCard-iOS – kein Teil des Original-Projekts und nicht mit Apple verbunden. Logos und Motive stammen von Nutzern; alle Marken gehören ihren Inhabern.':
|
||||||
'Unofficial community site for the AirCard-iOS app – not part of the original project and not affiliated with Apple. Logos and artwork are uploaded by users; all trademarks belong to their owners.',
|
'Unofficial community site for the AirCard-iOS app – not part of the original project and not affiliated with Apple. Logos and artwork are uploaded by users; all trademarks belong to their owners.',
|
||||||
|
|
||||||
|
// API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich.
|
||||||
|
'Bitte melde dich an.': 'Please sign in.',
|
||||||
|
'Nicht gefunden.': 'Not found.',
|
||||||
|
'Diese Karte ist nicht (mehr) verfügbar.': 'This card is no longer available.',
|
||||||
|
'Dazu hast du keine Berechtigung.': 'You are not allowed to do that.',
|
||||||
|
'Nur für Admins.': 'Admins only.',
|
||||||
|
'Diese Datei gehört dir nicht.': 'This file doesn’t belong to you.',
|
||||||
|
'Bild nicht gefunden.': 'Image not found.',
|
||||||
|
'Das Bild muss eine PNG- oder WebP-Datei sein.': 'The image must be a PNG or WebP file.',
|
||||||
|
'Das Bild ist größer als 5 MB.': 'The image is larger than 5 MB.',
|
||||||
|
'Diese Karte ist schon veröffentlicht.': 'This card is already published.',
|
||||||
|
'Diese Bilder sind bereits veröffentlicht oder gesperrt.': 'These images are already published or blocked.',
|
||||||
|
'Das Bild hat nicht das Kartenformat.': 'The image doesn’t match the card format.',
|
||||||
|
'Diese Karte gehört nicht zu deinem Konto.': 'This card doesn’t belong to your account.',
|
||||||
|
'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.':
|
||||||
|
'You’ve already published 20 cards. Withdraw one to publish a new one.',
|
||||||
|
'Zu viele Meldungen – bitte später nochmal.': 'Too many reports – please try again later.',
|
||||||
|
'Ungültige Anfrage.': 'Invalid request.',
|
||||||
|
'Ungültige Karten-ID.': 'Invalid card ID.',
|
||||||
|
'Bitte wähle einen gültigen Grund.': 'Please choose a valid reason.',
|
||||||
|
'Ungültige Angabe zur Sichtbarkeit.': 'Invalid visibility value.',
|
||||||
|
'Dieses Logo ist nicht (mehr) verfügbar.': 'This logo is no longer available.',
|
||||||
|
'Ungültige Logo-ID.': 'Invalid logo ID.',
|
||||||
|
'Bitte gib einen Namen an.': 'Please enter a name.',
|
||||||
|
'Das Logo muss eine PNG-Datei sein.': 'The logo must be a PNG file.',
|
||||||
|
'Das Logo ist größer als 1 MB.': 'The logo is larger than 1 MB.',
|
||||||
|
'Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).': 'The logo has invalid dimensions (8 × 8 to 720 × 250 pixels).',
|
||||||
|
'Diese Datei wird schon für ein Logo verwendet.': 'This file is already used for a logo.',
|
||||||
|
'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.':
|
||||||
|
'You’ve already uploaded 30 logos. Delete one to upload a new one.',
|
||||||
|
'Ungültige Angabe zum Herz.': 'Invalid heart value.',
|
||||||
|
'Ungültige Angabe zu „einfarbig“.': 'Invalid value for “single color”.',
|
||||||
|
'Die Anfrage ist zu groß.': 'The request is too large.',
|
||||||
|
'Nur application/json wird unterstützt.': 'Only application/json is supported.',
|
||||||
|
'Der Dienst ist gerade nicht erreichbar.': 'The service is currently unavailable.',
|
||||||
|
'Interner Fehler.': 'Internal error.',
|
||||||
|
'Etwas ist schiefgelaufen. Bitte nochmal versuchen.': 'Something went wrong. Please try again.',
|
||||||
|
'Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.':
|
||||||
|
'Token sign-in is turned off on the server – please let the site owner know.',
|
||||||
};
|
};
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user