Logo-Kacheln, Logo-Infozeile und die Detailseite einer Community-Karte zeigen den Benutzernamen des Kontos (Hilfsfunktion byline: Profil, sonst alter Anzeigename, sonst „Anonym“). Die Namen kommen je geladener Seite in einer Abfrage nach, ein Fehler dabei bleibt ohne Fehlertext, ein eigenes Umbenennen aktualisiert die eigenen Einträge sofort. Die Felder „Anzeigename“ in den Dialogen „Logo hochladen“ und „Veröffentlichen“ fallen weg, ebenso account/display-name.ts und der localStorage-Schlüssel; der Client schickt displayName: ''. Vor dem Hochladen und Veröffentlichen verlangt requireUsername() einen Namen („Abbrechen“: es passiert nichts); antwortet der Dienst mit 403 usernameRequired, öffnet sich der Namensdialog, danach ist ein neuer Versuch möglich. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
299 lines
14 KiB
TypeScript
299 lines
14 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', mono: false };
|
|
|
|
type Call = { name: string; args: unknown[] };
|
|
|
|
/** Fake-Abhängigkeiten, die jeden Aufruf in Reihenfolge aufzeichnen; die hochgeladene Datei bekommt die ID f1, das Logo der Dienst die Zeilen-ID r1. */
|
|
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: 'r1' }),
|
|
...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: der Name getrimmt, der Anzeigename immer leer (die Zeile zeigt den Benutzernamen)', async () => {
|
|
const fake = fakeDeps();
|
|
await uploadPreparedLogo(prepared(), { name: ' Beispielbank ', mono: false }, fake.deps);
|
|
expect(fake.of('uploadLogo')[0].args[0]).toEqual({ name: 'Beispielbank', displayName: '', mono: false, fileId: 'f1' });
|
|
});
|
|
|
|
it('ein von einem alten Aufrufer mitgegebener Anzeigename geht nicht mehr mit', async () => {
|
|
const fake = fakeDeps();
|
|
await uploadPreparedLogo(prepared(), { ...form, displayName: 'Kenso' } as LogoForm, 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 des Dienstes, Datei-ID der hochgeladenen Datei, mein Konto, ohne Herzen und Meldungen), nichts wird gelöscht', async () => {
|
|
const fake = fakeDeps();
|
|
const logo = await uploadPreparedLogo(prepared(), { name: ' Beispielbank ', mono: true }, fake.deps);
|
|
expect(logo).toEqual({
|
|
id: 'r1',
|
|
name: 'Beispielbank',
|
|
displayName: '',
|
|
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);
|
|
});
|
|
|
|
it('die ID kommt aus der Antwort des Dienstes, die fileId aus der angelegten Datei: beide sind verschieden und werden nie vertauscht', async () => {
|
|
const fake = fakeDeps({
|
|
createFile: async () => ({ $id: 'datei-9' }),
|
|
uploadLogo: async () => ({ id: 'zeile-7' }),
|
|
});
|
|
const logo = await uploadPreparedLogo(prepared(), form, fake.deps);
|
|
expect(logo.id).toBe('zeile-7');
|
|
expect(logo.fileId).toBe('datei-9');
|
|
});
|
|
|
|
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({});
|
|
// Wie der Dienst: die Zeile des Logos bekommt eine eigene ID, nicht die der Datei.
|
|
mocks.uploadLogo.mockImplementation(async ({ fileId }: { fileId: string }) => ({ id: `zeile-${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: '', mono: false, fileId });
|
|
expect(logo.id).toBe(`zeile-${fileId}`);
|
|
expect(logo.fileId).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();
|
|
});
|
|
});
|