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()), fetchFile: mocks.fetchFile, })); import { LOGO_PAGE_SIZE, getLogo, listLogoReports, listLogos, loadLogoBlob, logoQueries, myHearts, toLogo, type CommunityLogo, } from './logos'; const row = (n: number, patch: Record = {}) => ({ $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('../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(); }, ); });