diff --git a/web/src/logos/logo-api.ts b/web/src/logos/logo-api.ts index 684d8df..6b45a9f 100644 --- a/web/src/logos/logo-api.ts +++ b/web/src/logos/logo-api.ts @@ -6,7 +6,7 @@ export interface LogoUploadInput { name: string; displayName: string; mono: boolean; - /** Die Datei im Logo-Bucket (frisch hochgeladen, mit `delete("user:")`); ihre ID wird auch die ID des Logos. */ + /** Die Datei im Logo-Bucket (frisch hochgeladen, mit `delete("user:")`). Das Logo bekommt vom Dienst eine eigene ID (Antwort), nicht die der Datei. */ fileId: string; } diff --git a/web/src/logos/upload-logo.test.ts b/web/src/logos/upload-logo.test.ts index 5691f41..4b85330 100644 --- a/web/src/logos/upload-logo.test.ts +++ b/web/src/logos/upload-logo.test.ts @@ -36,7 +36,7 @@ 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. */ +/** 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 = {}) { const calls: Call[] = []; const log = (name: string, args: unknown[], result: T): T => { @@ -47,7 +47,7 @@ function fakeDeps(overrides: Partial = {}) { 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' }), + uploadLogo: async (input) => log('uploadLogo', [input], { id: 'r1' }), ...overrides, }; return { @@ -100,11 +100,11 @@ describe('uploadPreparedLogo', () => { expect(logo.mono).toBe(sent); }); - it('Erfolg: das neue Logo (ID = Datei-ID, mein Konto, ohne Herzen und Meldungen), nichts wird gelöscht', async () => { + 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 ', displayName: 'Kenso', mono: true }, fake.deps); expect(logo).toEqual({ - id: 'f1', + id: 'r1', name: 'Beispielbank', displayName: 'Kenso', fileId: 'f1', @@ -119,6 +119,16 @@ describe('uploadPreparedLogo', () => { 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', @@ -239,7 +249,8 @@ describe('Standard-Abhängigkeiten (Appwrite-Bucket logo-images)', () => { // 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 })); + // 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 () => { @@ -256,7 +267,8 @@ describe('Standard-Abhängigkeiten (Appwrite-Bucket logo-images)', () => { 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(logo.id).toBe(`zeile-${fileId}`); + expect(logo.fileId).toBe(fileId); expect(mocks.deleteFile).not.toHaveBeenCalled(); }); diff --git a/web/src/logos/upload-logo.ts b/web/src/logos/upload-logo.ts index 2942f6d..6d18e43 100644 --- a/web/src/logos/upload-logo.ts +++ b/web/src/logos/upload-logo.ts @@ -58,13 +58,14 @@ function isLogoTaken(err: unknown): boolean { /** * Lädt das Logo hoch: das PNG in den Logo-Bucket (`read("any")`, `delete("user:")`), dann legt die API das Logo daraus an. - * Liefert das neue Logo, so wie die Liste es danach zeigt (die Zeilen-ID ist die Datei-ID). „Einfarbig“ gilt nur für Logos mit - * durchsichtigen Stellen. Name und Anzeigename werden getrimmt, ihre Grenzen prüft der Dienst. + * Liefert das neue Logo, so wie die Liste es danach zeigt: `id` ist die Zeilen-ID aus der Antwort des Dienstes (nicht die Datei-ID), + * `fileId` die ID der hochgeladenen Datei. „Einfarbig“ gilt nur für Logos mit durchsichtigen Stellen. Name und Anzeigename werden + * getrimmt, ihre Grenzen prüft der Dienst. * * Aufräumen (nur die eine Datei dieses Versuchs, nie etwas anderes): * - Scheitert der Upload, wurde die API noch nicht gerufen: es gibt kein Logo, das auf die Datei zeigen könnte. * - Scheitert die API eindeutig (4xx außer 408/429, auch das Limit von 30 Logos, oder schon der JWT-Abruf), wurde nichts - * angelegt: die Datei wird gelöscht. Ausnahme `logoTaken` (409): ein anderes Logo trägt diese Datei-ID, die Datei bleibt. + * angelegt: die Datei wird gelöscht. Ausnahme `logoTaken` (409): ein Logo trägt diese Datei schon, die Datei bleibt. * - Bleibt der Ausgang offen (Netzfehler, Zeitlimit, 5xx, 408, 429, Antwort ohne `id`), kann die API das Logo schon angelegt * haben. Dann bleibt die Datei liegen: sonst zeigte ein öffentliches Logo auf ein gelöschtes Bild. * Der Fehler wird in jedem Fall weitergeworfen. @@ -96,7 +97,7 @@ export async function uploadPreparedLogo( id, name, displayName, - fileId: id, + fileId, ownerId: userId, mono, likeCount: 0,