Task 6: Verarbeitung und Hochladen eines Logos als Ablauf ohne Oberfläche (der Dialog folgt in Task 7).
- logos/prepare-logo.ts (neu): validateLogoFile (PNG, JPG, WebP, SVG; ohne Typ zählt die Endung .svg; höchstens 5 MB), die reinen
Helfer trimBox (Alpha ≤ 8 gilt als leer), fitWithin (nie größer, gerundet, mindestens 1), svgRasterSize (längste Seite genau 2048,
ungültiges Seitenverhältnis quadratisch), hasTransparency und planLogo (Ausschnitt und Zielgröße, „leer“ und „zu klein“ nach der
fertigen Größe) sowie sizeSvgRoot. prepareLogo fügt sie zusammen: Raster über createImageBitmap (Breite × Höhe wird vor dem
Zeichnen gegen 40 Megapixel geprüft, das Bild danach freigegeben), SVG nur als Bild aus einer Object-URL (Seitenverhältnis aus
der viewBox, Größe ins Wurzelelement geschrieben), Zuschnitt, Verkleinern auf höchstens 720 × 250, PNG, hasTransparency auf den
Endpixeln. Jeder Fehler beim Dekodieren oder Zeichnen heißt „Das Logo konnte nicht gelesen werden.“; alle Meldungen sind schon
übersetzt (t() beim Auslösen), die deutschen Konstanten LOGO_*_MESSAGE stehen über msg().
- sizeSvgRoot ersetzt eine eigene width/height des Wurzelelements, statt ein zweites Attribut anzuhängen (doppelte Attribute machen
ein SVG ungültig, so scheiterten viele echte SVGs). Es macht genau einen Versuch ab dem ersten „<svg“: ein Suchmuster, das an
jedem „<svg“ neu ansetzt, brauchte bei einer Datei voller offener „<svg x="“ quadratisch viel Zeit (160 kB: 6 s).
- logos/upload-logo.ts (neu): uploadPreparedLogo legt logo.png mit read("any") und delete("user:<ich>") in den Bucket logo-images,
ruft uploadLogo und liefert das neue CommunityLogo (ID = Datei-ID, likeCount 0). mono gilt nur bei durchsichtigen Stellen. Wie
beim Veröffentlichen wird nur bei eindeutigem Scheitern aufgeräumt (genau die Datei dieses Versuchs, ein Fehler dabei überdeckt
nie den eigentlichen), bei offenem Ausgang bleibt die Datei. Ausnahme: 409 mit serverCode logoTaken löscht nichts, ein anderes
Logo trägt diese Datei-ID.
- i18n/en.ts: englische Texte zu den sechs neuen Meldungen.
- Tests: prepare-logo.test.ts (Helfer einzeln, dazu prepareLogo mit gestelltem Dekoder und Canvas: Zuschnitt, Skalierung, Rasterung,
Fehlertexte, 40-Megapixel-Grenze, SVG nie in der Seite) und upload-logo.test.ts (Reihenfolge, Rechte, Felder, mono, Aufräumen je
Status, logoTaken, Standard-Abhängigkeiten gegen den Bucket logo-images). Vitest 47 Dateien, 1452 Tests grün, tsc sauber.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
287 lines
13 KiB
TypeScript
287 lines
13 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
createFile: vi.fn(),
|
|
deleteFile: vi.fn(),
|
|
requireUserId: vi.fn(),
|
|
uploadLogo: vi.fn(),
|
|
}));
|
|
|
|
// Appwrite, Nutzer und der API-Aufruf sind nur für den Test der Standard-Abhängigkeiten gebraucht; sonst kommen Fakes (deps) zum Einsatz.
|
|
vi.mock('../account/appwrite', () => ({
|
|
IDS: { logoBucket: 'logo-images' },
|
|
account: {},
|
|
client: {},
|
|
tables: {},
|
|
storage: { createFile: mocks.createFile, deleteFile: mocks.deleteFile },
|
|
APPWRITE_ENDPOINT: 'https://aircard.test/v1',
|
|
}));
|
|
vi.mock('../account/cards', () => ({ requireUserId: mocks.requireUserId }));
|
|
vi.mock('./logo-api', () => ({ uploadLogo: mocks.uploadLogo }));
|
|
|
|
import { ID_PATTERN } from '../card/config';
|
|
import { ApiError } from '../community/api';
|
|
import type { PreparedLogo } from './prepare-logo';
|
|
import { uploadPreparedLogo, type LogoForm, type UploadLogoDeps } from './upload-logo';
|
|
|
|
const prepared = (patch: Partial<PreparedLogo> = {}): PreparedLogo => ({
|
|
png: new Blob(['png-bytes'], { type: 'image/png' }),
|
|
width: 200,
|
|
height: 80,
|
|
hasTransparency: true,
|
|
...patch,
|
|
});
|
|
|
|
const form: LogoForm = { name: 'Beispielbank', displayName: 'Kenso', mono: false };
|
|
|
|
type Call = { name: string; args: unknown[] };
|
|
|
|
/** Fake-Abhängigkeiten, die jeden Aufruf in Reihenfolge aufzeichnen; die hochgeladene Datei bekommt die ID f1. */
|
|
function fakeDeps(overrides: Partial<UploadLogoDeps> = {}) {
|
|
const calls: Call[] = [];
|
|
const log = <T>(name: string, args: unknown[], result: T): T => {
|
|
calls.push({ name, args });
|
|
return result;
|
|
};
|
|
const deps: UploadLogoDeps = {
|
|
userId: () => log('userId', [], 'u1'),
|
|
createFile: async (file, permissions) => log('createFile', [file, permissions], { $id: 'f1' }),
|
|
deleteFile: async (id) => log('deleteFile', [id], undefined),
|
|
uploadLogo: async (input) => log('uploadLogo', [input], { id: 'f1' }),
|
|
...overrides,
|
|
};
|
|
return {
|
|
deps,
|
|
calls,
|
|
names: () => calls.map((c) => c.name),
|
|
of: (name: string) => calls.filter((c) => c.name === name),
|
|
};
|
|
}
|
|
|
|
describe('uploadPreparedLogo', () => {
|
|
it('Reihenfolge: Nutzer, Upload, API', async () => {
|
|
const fake = fakeDeps();
|
|
await uploadPreparedLogo(prepared(), form, fake.deps);
|
|
expect(fake.names()).toEqual(['userId', 'createFile', 'uploadLogo']);
|
|
});
|
|
|
|
it('lädt das PNG als logo.png (image/png) mit read(any) und delete(user:<ich>) hoch', async () => {
|
|
const fake = fakeDeps();
|
|
const png = prepared().png;
|
|
await uploadPreparedLogo({ ...prepared(), png }, form, fake.deps);
|
|
const [file, permissions] = fake.of('createFile')[0].args as [File, string[]];
|
|
expect(file.name).toBe('logo.png');
|
|
expect(file.type).toBe('image/png');
|
|
expect(file.size).toBe(png.size);
|
|
expect(permissions).toEqual(['read("any")', 'delete("user:u1")']);
|
|
});
|
|
|
|
it('schickt der API die neue Datei-ID und genau vier Felder, Name und Anzeigename getrimmt', async () => {
|
|
const fake = fakeDeps();
|
|
await uploadPreparedLogo(prepared(), { name: ' Beispielbank ', displayName: ' Kenso\t', mono: false }, fake.deps);
|
|
expect(fake.of('uploadLogo')[0].args[0]).toEqual({ name: 'Beispielbank', displayName: 'Kenso', mono: false, fileId: 'f1' });
|
|
});
|
|
|
|
it('ein leerer Anzeigename bleibt leer (der Server macht daraus „Anonym“)', async () => {
|
|
const fake = fakeDeps();
|
|
await uploadPreparedLogo(prepared(), { ...form, displayName: ' ' }, fake.deps);
|
|
expect(fake.of('uploadLogo')[0].args[0]).toMatchObject({ displayName: '' });
|
|
});
|
|
|
|
it.each([
|
|
{ mono: true, hasTransparency: true, sent: true },
|
|
{ mono: true, hasTransparency: false, sent: false },
|
|
{ mono: false, hasTransparency: true, sent: false },
|
|
{ mono: false, hasTransparency: false, sent: false },
|
|
])('mono $mono bei hasTransparency $hasTransparency: gesendet wird $sent', async ({ mono, hasTransparency, sent }) => {
|
|
const fake = fakeDeps();
|
|
const logo = await uploadPreparedLogo(prepared({ hasTransparency }), { ...form, mono }, fake.deps);
|
|
expect(fake.of('uploadLogo')[0].args[0]).toMatchObject({ mono: sent });
|
|
expect(logo.mono).toBe(sent);
|
|
});
|
|
|
|
it('Erfolg: das neue Logo (ID = Datei-ID, mein Konto, ohne Herzen und Meldungen), nichts wird gelöscht', async () => {
|
|
const fake = fakeDeps();
|
|
const logo = await uploadPreparedLogo(prepared(), { name: ' Beispielbank ', displayName: 'Kenso', mono: true }, fake.deps);
|
|
expect(logo).toEqual({
|
|
id: 'f1',
|
|
name: 'Beispielbank',
|
|
displayName: 'Kenso',
|
|
fileId: 'f1',
|
|
ownerId: 'u1',
|
|
mono: true,
|
|
likeCount: 0,
|
|
hidden: false,
|
|
reportCount: 0,
|
|
createdAt: expect.any(String),
|
|
});
|
|
expect(new Date(logo.createdAt).toISOString()).toBe(logo.createdAt);
|
|
expect(fake.of('deleteFile')).toHaveLength(0);
|
|
});
|
|
|
|
describe('Fehler der API: gelöscht wird nur bei eindeutiger Ablehnung', () => {
|
|
it.each([400, 401, 403, 404])(
|
|
'eindeutig abgelehnt (%i): die Datei dieses Versuchs wird gelöscht, der Fehler kommt an',
|
|
async (code) => {
|
|
const failure = new ApiError(code, 'Abgelehnt.');
|
|
const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) });
|
|
await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure);
|
|
expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['f1']);
|
|
},
|
|
);
|
|
|
|
it('das Limit von 30 Logos (429 tooManyLogos, eindeutig): die Datei wird gelöscht', async () => {
|
|
const failure = new ApiError(429, 'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.', true, 'tooManyLogos');
|
|
const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) });
|
|
await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure);
|
|
expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['f1']);
|
|
});
|
|
|
|
it.each([408, 429, 500, 502, 503, 504])(
|
|
'unklarer Ausgang (%i): die Zeile kann angelegt sein, die Datei bleibt liegen, der Fehler kommt an',
|
|
async (code) => {
|
|
const failure = new ApiError(code, 'Unklar.');
|
|
const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) });
|
|
await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure);
|
|
expect(fake.of('deleteFile')).toHaveLength(0);
|
|
},
|
|
);
|
|
|
|
it('der Dienst ist nicht erreichbar (unavailable): die Datei bleibt liegen', async () => {
|
|
const failure = new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable');
|
|
const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) });
|
|
await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure);
|
|
expect(fake.of('deleteFile')).toHaveLength(0);
|
|
});
|
|
|
|
it('409 logoTaken: die Datei gehört schon einem Logo und wird NICHT gelöscht, der Fehler kommt an', async () => {
|
|
const failure = new ApiError(409, 'Diese Datei wird schon für ein Logo verwendet.', undefined, 'logoTaken');
|
|
expect(failure.definitive).toBe(true);
|
|
const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) });
|
|
await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure);
|
|
expect(fake.of('deleteFile')).toHaveLength(0);
|
|
});
|
|
|
|
it('entscheidend ist der Schlüssel logoTaken, nicht der Status: ein anderes 409 wird aufgeräumt', async () => {
|
|
const failure = new ApiError(409, 'Anderer Konflikt.', undefined, 'alreadyPublished');
|
|
const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) });
|
|
await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure);
|
|
expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['f1']);
|
|
});
|
|
|
|
it('JWT-Methode am Server aus (ApiError mit definitive-Flag, es ging keine Anfrage raus): gelöscht', async () => {
|
|
const failure = new ApiError(501, 'Anmeldung per Token ist auf dem Server ausgeschaltet.', true, 'jwtUnsupported');
|
|
const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) });
|
|
await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure);
|
|
expect(fake.of('deleteFile')).toHaveLength(1);
|
|
});
|
|
|
|
it('ein anderer Fehler vor der Anfrage (z. B. createJWT mit 401): gelöscht', async () => {
|
|
const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' });
|
|
const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) });
|
|
await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure);
|
|
expect(fake.of('deleteFile')).toHaveLength(1);
|
|
});
|
|
|
|
it('löscht nie etwas anderes als die Datei dieses Versuchs', async () => {
|
|
const fake = fakeDeps({
|
|
createFile: async () => ({ $id: 'neu42' }),
|
|
uploadLogo: async () => Promise.reject(new ApiError(403, 'Dazu hast du keine Berechtigung.')),
|
|
});
|
|
await uploadPreparedLogo(prepared(), form, fake.deps).catch(() => undefined);
|
|
expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['neu42']);
|
|
});
|
|
|
|
it('ein Fehler beim Aufräumen (auch ein sofort geworfener) überdeckt den ursprünglichen Fehler nicht', async () => {
|
|
const failure = new ApiError(400, 'Bitte gib einen Namen an.');
|
|
const rejecting = fakeDeps({
|
|
uploadLogo: async () => Promise.reject(failure),
|
|
deleteFile: async () => Promise.reject(new Error('delete kaputt')),
|
|
});
|
|
await expect(uploadPreparedLogo(prepared(), form, rejecting.deps)).rejects.toBe(failure);
|
|
|
|
const throwing = fakeDeps({
|
|
uploadLogo: async () => Promise.reject(failure),
|
|
deleteFile: () => {
|
|
throw new Error('delete kaputt');
|
|
},
|
|
});
|
|
await expect(uploadPreparedLogo(prepared(), form, throwing.deps)).rejects.toBe(failure);
|
|
});
|
|
});
|
|
|
|
describe('vor dem API-Aufruf', () => {
|
|
it('scheitert der Upload, wird die API nicht gerufen und nichts gelöscht, der Fehler kommt an', async () => {
|
|
const failure = new Error('upload kaputt');
|
|
const fake = fakeDeps({ createFile: async () => Promise.reject(failure) });
|
|
await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure);
|
|
expect(fake.of('uploadLogo')).toHaveLength(0);
|
|
expect(fake.of('deleteFile')).toHaveLength(0);
|
|
});
|
|
|
|
it('ohne angemeldeten Nutzer wird nichts hochgeladen', async () => {
|
|
const failure = Object.assign(new Error('unauthorized'), { code: 401 });
|
|
const fake = fakeDeps({
|
|
userId: () => {
|
|
throw failure;
|
|
},
|
|
});
|
|
await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure);
|
|
expect(fake.names()).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Standard-Abhängigkeiten (Appwrite-Bucket logo-images)', () => {
|
|
beforeEach(() => {
|
|
for (const fn of Object.values(mocks)) fn.mockReset();
|
|
mocks.requireUserId.mockReturnValue('u1');
|
|
// Wie Appwrite: die Datei hat die ID, mit der sie angelegt wurde.
|
|
mocks.createFile.mockImplementation(async ({ fileId }: { fileId: string }) => ({ $id: fileId }));
|
|
mocks.deleteFile.mockResolvedValue({});
|
|
mocks.uploadLogo.mockImplementation(async ({ fileId }: { fileId: string }) => ({ id: fileId }));
|
|
});
|
|
|
|
it('lädt mit frischer ID in den Logo-Bucket und ruft die API mit genau dieser ID', async () => {
|
|
const logo = await uploadPreparedLogo(prepared(), form);
|
|
expect(mocks.createFile).toHaveBeenCalledTimes(1);
|
|
const { bucketId, fileId, file, permissions } = mocks.createFile.mock.calls[0][0] as {
|
|
bucketId: string;
|
|
fileId: string;
|
|
file: File;
|
|
permissions: string[];
|
|
};
|
|
expect(bucketId).toBe('logo-images');
|
|
expect(fileId).toMatch(ID_PATTERN);
|
|
expect(file.name).toBe('logo.png');
|
|
expect(permissions).toEqual(['read("any")', 'delete("user:u1")']);
|
|
expect(mocks.uploadLogo).toHaveBeenCalledWith({ name: 'Beispielbank', displayName: 'Kenso', mono: false, fileId });
|
|
expect(logo.id).toBe(fileId);
|
|
expect(mocks.deleteFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('jeder Versuch bekommt seine eigene Datei-ID', async () => {
|
|
await uploadPreparedLogo(prepared(), form);
|
|
await uploadPreparedLogo(prepared(), form);
|
|
const [first, second] = mocks.createFile.mock.calls.map((call) => (call[0] as { fileId: string }).fileId);
|
|
expect(first).not.toBe(second);
|
|
});
|
|
|
|
it('räumt bei eindeutiger Ablehnung genau diese Datei im Logo-Bucket auf', async () => {
|
|
mocks.uploadLogo.mockRejectedValue(new ApiError(403, 'Dazu hast du keine Berechtigung.'));
|
|
await expect(uploadPreparedLogo(prepared(), form)).rejects.toBeInstanceOf(ApiError);
|
|
const { fileId } = mocks.createFile.mock.calls[0][0] as { fileId: string };
|
|
expect(mocks.deleteFile).toHaveBeenCalledTimes(1);
|
|
expect(mocks.deleteFile).toHaveBeenCalledWith({ bucketId: 'logo-images', fileId });
|
|
});
|
|
|
|
it('ohne angemeldeten Nutzer (401 aus requireUserId): nichts wird hochgeladen', async () => {
|
|
const failure = Object.assign(new Error('unauthorized'), { code: 401 });
|
|
mocks.requireUserId.mockImplementation(() => {
|
|
throw failure;
|
|
});
|
|
await expect(uploadPreparedLogo(prepared(), form)).rejects.toBe(failure);
|
|
expect(mocks.createFile).not.toHaveBeenCalled();
|
|
});
|
|
});
|