feat(cards): Karten speichern und Meine Karten
- account/cards.ts: Kartendienst (neu/bestehend speichern mit Aufräumen halber Uploads, Umbenennen, Löschen, Laden) über ein schmales CardsApi-Interface, Appwrite-Anschluss mit 22.4.1-Objektparametern, fetchFile über client.prepareRequest (Projekt-Header + Cookie, kein getFileView) - account/with-login.ts: bei 401 Nutzer neu prüfen, einmal anmelden, wiederholen - editor/save.ts: Speichern-Ablauf (ein Lauf zur Zeit, 401 -> Login -> einmal wiederholen, deutsche Fehlermeldung) - Editor: Speichern mit Schnappschuss und 480-px-Vorschau, Karte öffnen über ?karte=<id>, Bildwechsel während des Speicherns gesperrt - pages/cards-page.ts: /karten mit Kacheln, Öffnen, Herunterladen, Umbenennen, Löschen (eigener Dialog), Abmelde- und Leer-Ansicht - Tests für Dienst, withLogin, Speichern-Ablauf, Karten-Seite und Editor-Anbindung Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a147d3b496
commit
ba4d9f76d4
14 files changed
+2849
-16
No files matched your search
@@ -0,0 +1,597 @@
|
||||
import { AppwriteException } from 'appwrite';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_CONFIG, parseConfig, serializeConfig, type CardConfig } from '../card/config';
|
||||
import type { SavedCardRef } from '../editor/state';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
prepareRequest: vi.fn(),
|
||||
createRow: vi.fn(),
|
||||
updateRow: vi.fn(),
|
||||
getRow: vi.fn(),
|
||||
deleteRow: vi.fn(),
|
||||
listRows: vi.fn(),
|
||||
createFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./appwrite', () => ({
|
||||
account: {},
|
||||
APPWRITE_ENDPOINT: 'https://aircard.test/v1',
|
||||
IDS: { database: 'db', table: 'cards-table', bucket: 'bucket' },
|
||||
client: { prepareRequest: mocks.prepareRequest },
|
||||
tables: {
|
||||
createRow: mocks.createRow,
|
||||
updateRow: mocks.updateRow,
|
||||
getRow: mocks.getRow,
|
||||
deleteRow: mocks.deleteRow,
|
||||
listRows: mocks.listRows,
|
||||
},
|
||||
storage: { createFile: mocks.createFile, deleteFile: mocks.deleteFile },
|
||||
}));
|
||||
|
||||
import {
|
||||
appwriteCardsApi,
|
||||
createCardsService,
|
||||
normalizeName,
|
||||
ownerPermissions,
|
||||
toUploadFile,
|
||||
type CardRow,
|
||||
type CardRowData,
|
||||
type CardsApi,
|
||||
type SaveInput,
|
||||
} from './cards';
|
||||
|
||||
const NOW = '2026-10-04T10:00:00.000Z';
|
||||
const OWNER = ownerPermissions('u1');
|
||||
|
||||
const config = (patch: Partial<CardConfig> = {}): CardConfig => ({
|
||||
...DEFAULT_CONFIG,
|
||||
crop: { x: 12, y: -8, zoom: 1.5 },
|
||||
...patch,
|
||||
});
|
||||
|
||||
const blob = (type: string) => new Blob(['x'], { type });
|
||||
const png = () => blob('image/png');
|
||||
const webp = () => blob('image/webp');
|
||||
|
||||
/** Eine bestehende Karte, wie der Editor sie als `previous` hält. */
|
||||
const previous = (patch: Partial<SavedCardRef> = {}): SavedCardRef => ({
|
||||
id: 'row1',
|
||||
name: 'Alt',
|
||||
imageFileId: 'oldImage',
|
||||
previewFileId: 'oldPreview',
|
||||
updatedAt: NOW,
|
||||
...patch,
|
||||
});
|
||||
|
||||
type Call = { method: string; args: unknown[] };
|
||||
|
||||
/**
|
||||
* Fake-API: zeichnet Aufrufe auf. `failOnce.set('updateRow', error)` lässt den nächsten Aufruf dieser Methode scheitern,
|
||||
* `failOnce.set('createFile#2', error)` den zweiten.
|
||||
*/
|
||||
function fakeApi(rows: CardRow[] = []) {
|
||||
const calls: Call[] = [];
|
||||
const failOnce = new Map<string, unknown>();
|
||||
let fileCounter = 0;
|
||||
const store = new Map(rows.map((r) => [r.$id, r]));
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
const enter = (method: string, ...args: unknown[]) => {
|
||||
calls.push({ method, args });
|
||||
const nth = (counts.get(method) ?? 0) + 1;
|
||||
counts.set(method, nth);
|
||||
for (const key of [method, `${method}#${nth}`]) {
|
||||
if (failOnce.has(key)) {
|
||||
const err = failOnce.get(key);
|
||||
failOnce.delete(key);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const api: CardsApi = {
|
||||
async createFile(file, permissions) {
|
||||
enter('createFile', file, permissions);
|
||||
fileCounter += 1;
|
||||
return { $id: `file${fileCounter}` };
|
||||
},
|
||||
async deleteFile(fileId) {
|
||||
enter('deleteFile', fileId);
|
||||
},
|
||||
async fetchFile(fileId) {
|
||||
enter('fetchFile', fileId);
|
||||
return new Blob([fileId], { type: 'image/png' });
|
||||
},
|
||||
async createRow(data, permissions) {
|
||||
enter('createRow', data, permissions);
|
||||
const row: CardRow = { $id: 'row-new', $updatedAt: NOW, ...data };
|
||||
store.set(row.$id, row);
|
||||
return row;
|
||||
},
|
||||
async updateRow(id, data) {
|
||||
enter('updateRow', id, data);
|
||||
const row = { ...store.get(id)!, ...data, $updatedAt: '2026-10-05T10:00:00.000Z' };
|
||||
store.set(id, row);
|
||||
return row;
|
||||
},
|
||||
async getRow(id) {
|
||||
enter('getRow', id);
|
||||
return store.get(id)!;
|
||||
},
|
||||
async deleteRow(id) {
|
||||
enter('deleteRow', id);
|
||||
store.delete(id);
|
||||
},
|
||||
async listRows() {
|
||||
enter('listRows');
|
||||
return [...store.values()];
|
||||
},
|
||||
};
|
||||
|
||||
const methods = (name: string) => calls.filter((c) => c.method === name);
|
||||
const names = () => calls.map((c) => c.method);
|
||||
return { api, calls, failOnce, methods, names };
|
||||
}
|
||||
|
||||
const newInput = (patch: Partial<SaveInput> = {}): SaveInput => ({
|
||||
name: 'Mein Urlaub',
|
||||
config: config(),
|
||||
image: png(),
|
||||
imageChanged: true,
|
||||
preview: webp(),
|
||||
previous: null,
|
||||
...patch,
|
||||
});
|
||||
|
||||
const service = (fake: ReturnType<typeof fakeApi>, userId: () => string = () => 'u1') =>
|
||||
createCardsService(fake.api, userId);
|
||||
|
||||
describe('normalizeName', () => {
|
||||
it('trimmt und fasst Leerraum zusammen', () => {
|
||||
expect(normalizeName(' Mein Urlaub ')).toBe('Mein Urlaub');
|
||||
expect(normalizeName('a\t\nb')).toBe('a b');
|
||||
});
|
||||
|
||||
it('leer oder nur Leerraum ergibt „Meine Karte“', () => {
|
||||
expect(normalizeName(' ')).toBe('Meine Karte');
|
||||
expect(normalizeName('')).toBe('Meine Karte');
|
||||
});
|
||||
|
||||
it('kürzt auf 80 Zeichen (Codepoints, nicht UTF-16-Einheiten)', () => {
|
||||
expect(Array.from(normalizeName('😀'.repeat(100)))).toHaveLength(80);
|
||||
expect(normalizeName('a'.repeat(81))).toHaveLength(80);
|
||||
expect(normalizeName('b'.repeat(80))).toBe('b'.repeat(80));
|
||||
});
|
||||
|
||||
it('lässt nach dem Kürzen kein Leerzeichen am Ende stehen', () => {
|
||||
const name = `${'a'.repeat(79)} xyz`;
|
||||
expect(normalizeName(name)).toBe('a'.repeat(79));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ownerPermissions', () => {
|
||||
it('gibt dem Besitzer lesen, ändern und löschen', () => {
|
||||
expect(ownerPermissions('abc')).toEqual(['read("user:abc")', 'update("user:abc")', 'delete("user:abc")']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toUploadFile', () => {
|
||||
it('benennt die Datei nach Art und Blob-Typ', () => {
|
||||
expect(toUploadFile(blob('image/png'), 'image').name).toBe('image.png');
|
||||
expect(toUploadFile(blob('image/jpeg'), 'image').name).toBe('image.jpg');
|
||||
expect(toUploadFile(blob('image/webp'), 'preview').name).toBe('preview.webp');
|
||||
});
|
||||
|
||||
it('behält den Typ und den Inhalt', async () => {
|
||||
const file = toUploadFile(new Blob(['abc'], { type: 'image/webp' }), 'preview');
|
||||
expect(file).toBeInstanceOf(File);
|
||||
expect(file.type).toBe('image/webp');
|
||||
expect(await file.text()).toBe('abc');
|
||||
});
|
||||
|
||||
it('wirft bei einem anderen Typ', () => {
|
||||
expect(() => toUploadFile(new Blob([], { type: 'image/gif' }), 'image')).toThrow();
|
||||
expect(() => toUploadFile(new Blob([]), 'preview')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('save: neue Karte', () => {
|
||||
it('lädt Bild und Vorschau hoch und legt die Zeile an', async () => {
|
||||
const fake = fakeApi();
|
||||
const c = config({ template: 'minimal', details: 'dark' });
|
||||
const saved = await service(fake).save(newInput({ config: c, name: ' Mein Urlaub ' }));
|
||||
|
||||
const files = fake.methods('createFile');
|
||||
expect(files).toHaveLength(2);
|
||||
expect((files[0].args[0] as File).name).toBe('image.png');
|
||||
expect((files[1].args[0] as File).name).toBe('preview.webp');
|
||||
expect(files[0].args[1]).toEqual(OWNER);
|
||||
expect(files[1].args[1]).toEqual(OWNER);
|
||||
|
||||
const [row] = fake.methods('createRow');
|
||||
expect(row.args[0]).toEqual({
|
||||
name: 'Mein Urlaub',
|
||||
config: serializeConfig(c),
|
||||
imageFileId: 'file1',
|
||||
previewFileId: 'file2',
|
||||
});
|
||||
expect(row.args[1]).toEqual(OWNER);
|
||||
|
||||
expect(saved).toEqual({
|
||||
id: 'row-new',
|
||||
name: 'Mein Urlaub',
|
||||
imageFileId: 'file1',
|
||||
previewFileId: 'file2',
|
||||
updatedAt: NOW,
|
||||
config: parseConfig(serializeConfig(c)),
|
||||
});
|
||||
});
|
||||
|
||||
it('JPEG-Bild heißt image.jpg', async () => {
|
||||
const fake = fakeApi();
|
||||
await service(fake).save(newInput({ image: blob('image/jpeg') }));
|
||||
expect((fake.methods('createFile')[0].args[0] as File).name).toBe('image.jpg');
|
||||
});
|
||||
|
||||
it('Vorschau als PNG (z. B. Safari kann kein WebP) heißt preview.png', async () => {
|
||||
const fake = fakeApi();
|
||||
await service(fake).save(newInput({ preview: png() }));
|
||||
expect((fake.methods('createFile')[1].args[0] as File).name).toBe('preview.png');
|
||||
});
|
||||
|
||||
it('scheitert createRow, werden beide neuen Dateien gelöscht und der Fehler kommt an', async () => {
|
||||
const fake = fakeApi();
|
||||
const boom = new Error('createRow');
|
||||
fake.failOnce.set('createRow', boom);
|
||||
await expect(service(fake).save(newInput())).rejects.toBe(boom);
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['file1', 'file2']);
|
||||
});
|
||||
|
||||
it('scheitert die Vorschau, wird das Bild gelöscht und keine Zeile angelegt', async () => {
|
||||
const fake = fakeApi();
|
||||
const boom = new Error('preview');
|
||||
fake.failOnce.set('createFile#2', boom);
|
||||
await expect(service(fake).save(newInput())).rejects.toBe(boom);
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0])).toEqual(['file1']);
|
||||
expect(fake.methods('createRow')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Fehler beim Aufräumen überdecken den Ursprungsfehler nicht', async () => {
|
||||
const fake = fakeApi();
|
||||
const boom = new Error('createRow');
|
||||
fake.failOnce.set('createRow', boom);
|
||||
const original = fake.api.deleteFile;
|
||||
fake.api.deleteFile = async (id) => {
|
||||
await original(id);
|
||||
throw new Error('delete kaputt');
|
||||
};
|
||||
await expect(service(fake).save(newInput())).rejects.toBe(boom);
|
||||
expect(fake.methods('deleteFile')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('wirft userId(), wird nichts hochgeladen', async () => {
|
||||
const fake = fakeApi();
|
||||
const unauthorized = Object.assign(new Error('unauthorized'), { code: 401 });
|
||||
const svc = service(fake, () => {
|
||||
throw unauthorized;
|
||||
});
|
||||
await expect(svc.save(newInput())).rejects.toBe(unauthorized);
|
||||
expect(fake.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ein nicht erlaubter Bildtyp wirft, bevor irgendetwas hochgeladen wird', async () => {
|
||||
const fake = fakeApi();
|
||||
await expect(service(fake).save(newInput({ image: blob('image/gif') }))).rejects.toThrow();
|
||||
await expect(service(fake).save(newInput({ preview: blob('image/gif') }))).rejects.toThrow();
|
||||
expect(fake.calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('save: bestehende Karte', () => {
|
||||
const existing = (): CardRow => ({
|
||||
$id: 'row1',
|
||||
$updatedAt: NOW,
|
||||
name: 'Alt',
|
||||
config: serializeConfig(config()),
|
||||
imageFileId: 'oldImage',
|
||||
previewFileId: 'oldPreview',
|
||||
});
|
||||
|
||||
it('ohne Bildwechsel: nur neue Vorschau, Update, dann alte Vorschau löschen', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
const c = config({ network: 'jcb' });
|
||||
const saved = await service(fake).save(
|
||||
newInput({ name: 'Neu', config: c, imageChanged: false, previous: previous() }),
|
||||
);
|
||||
|
||||
expect(fake.names()).toEqual(['createFile', 'updateRow', 'deleteFile']);
|
||||
const [file] = fake.methods('createFile');
|
||||
expect((file.args[0] as File).name).toBe('preview.webp');
|
||||
expect(file.args[1]).toEqual(OWNER);
|
||||
expect(fake.methods('updateRow')[0].args).toEqual([
|
||||
'row1',
|
||||
{ name: 'Neu', config: serializeConfig(c), imageFileId: 'oldImage', previewFileId: 'file1' },
|
||||
]);
|
||||
expect(fake.methods('deleteFile')[0].args).toEqual(['oldPreview']);
|
||||
expect(saved.id).toBe('row1');
|
||||
expect(saved.imageFileId).toBe('oldImage');
|
||||
expect(saved.previewFileId).toBe('file1');
|
||||
expect(saved.config.network).toBe('jcb');
|
||||
});
|
||||
|
||||
it('mit Bildwechsel: zwei neue Dateien, nach dem Update werden beide alten gelöscht', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
const saved = await service(fake).save(newInput({ imageChanged: true, previous: previous() }));
|
||||
|
||||
expect(fake.names()).toEqual(['createFile', 'createFile', 'updateRow', 'deleteFile', 'deleteFile']);
|
||||
expect((fake.methods('createFile')[0].args[0] as File).name).toBe('image.png');
|
||||
expect(fake.methods('updateRow')[0].args[1]).toMatchObject({ imageFileId: 'file1', previewFileId: 'file2' });
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['oldImage', 'oldPreview']);
|
||||
expect(saved.imageFileId).toBe('file1');
|
||||
});
|
||||
|
||||
it('scheitert updateRow, werden nur die neuen Dateien gelöscht', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
const boom = new Error('updateRow');
|
||||
fake.failOnce.set('updateRow', boom);
|
||||
await expect(service(fake).save(newInput({ imageChanged: true, previous: previous() }))).rejects.toBe(boom);
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['file1', 'file2']);
|
||||
});
|
||||
|
||||
it('scheitert updateRow ohne Bildwechsel, wird nur die neue Vorschau gelöscht', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
fake.failOnce.set('updateRow', new Error('updateRow'));
|
||||
await expect(service(fake).save(newInput({ imageChanged: false, previous: previous() }))).rejects.toThrow();
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0])).toEqual(['file1']);
|
||||
});
|
||||
|
||||
it('scheitert das Löschen der alten Vorschau, löst save trotzdem auf', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
fake.failOnce.set('deleteFile', new AppwriteException('weg', 404, 'storage_file_not_found'));
|
||||
const saved = await service(fake).save(newInput({ imageChanged: false, previous: previous() }));
|
||||
expect(saved.previewFileId).toBe('file1');
|
||||
});
|
||||
|
||||
it('scheitert das Löschen des alten Bildes, wird trotzdem die alte Vorschau gelöscht und aufgelöst', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
const original = fake.api.deleteFile;
|
||||
fake.api.deleteFile = async (id) => {
|
||||
await original(id);
|
||||
throw new Error('delete kaputt');
|
||||
};
|
||||
const saved = await service(fake).save(newInput({ imageChanged: true, previous: previous() }));
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['oldImage', 'oldPreview']);
|
||||
expect(saved.imageFileId).toBe('file1');
|
||||
});
|
||||
|
||||
it('wirft userId(), wird nichts hochgeladen und nichts geändert', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
const svc = service(fake, () => {
|
||||
throw new Error('unauthorized');
|
||||
});
|
||||
await expect(svc.save(newInput({ previous: previous() }))).rejects.toThrow();
|
||||
expect(fake.calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('löscht zuerst die Zeile, dann beide Dateien', async () => {
|
||||
const fake = fakeApi();
|
||||
await service(fake).remove(previous());
|
||||
expect(fake.names()[0]).toBe('deleteRow');
|
||||
expect(fake.methods('deleteRow')[0].args).toEqual(['row1']);
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['oldImage', 'oldPreview']);
|
||||
});
|
||||
|
||||
it('scheitert ein Datei-Löschen, löst remove trotzdem auf und versucht die andere Datei', async () => {
|
||||
const fake = fakeApi();
|
||||
fake.failOnce.set('deleteFile', new Error('weg'));
|
||||
await expect(service(fake).remove(previous())).resolves.toBeUndefined();
|
||||
expect(fake.methods('deleteFile')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('scheitert deleteRow, bleiben die Dateien liegen und der Fehler kommt an', async () => {
|
||||
const fake = fakeApi();
|
||||
const boom = new Error('deleteRow');
|
||||
fake.failOnce.set('deleteRow', boom);
|
||||
await expect(service(fake).remove(previous())).rejects.toBe(boom);
|
||||
expect(fake.methods('deleteFile')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list, get, rename, loadImage, loadPreview', () => {
|
||||
const row = (patch: Partial<CardRow> = {}): CardRow => ({
|
||||
$id: 'row1',
|
||||
$updatedAt: NOW,
|
||||
name: 'Karte',
|
||||
config: serializeConfig(config({ template: 'classic' })),
|
||||
imageFileId: 'img',
|
||||
previewFileId: 'prev',
|
||||
...patch,
|
||||
});
|
||||
|
||||
it('list: macht aus Zeilen SavedCards mit gelesener Konfiguration', async () => {
|
||||
const fake = fakeApi([row(), row({ $id: 'row2', name: 'Zwei' })]);
|
||||
const cards = await service(fake).list();
|
||||
expect(cards.map((c) => c.id)).toEqual(['row1', 'row2']);
|
||||
expect(cards[0]).toEqual({
|
||||
id: 'row1',
|
||||
name: 'Karte',
|
||||
imageFileId: 'img',
|
||||
previewFileId: 'prev',
|
||||
updatedAt: NOW,
|
||||
config: parseConfig(serializeConfig(config({ template: 'classic' }))),
|
||||
});
|
||||
});
|
||||
|
||||
it('list: kaputte Konfiguration wird zur Standardkonfiguration', async () => {
|
||||
const fake = fakeApi([row({ config: '{kaputt' })]);
|
||||
const [card] = await service(fake).list();
|
||||
expect(card.config).toEqual(DEFAULT_CONFIG);
|
||||
});
|
||||
|
||||
it('get: liest eine Karte', async () => {
|
||||
const fake = fakeApi([row()]);
|
||||
const card = await service(fake).get('row1');
|
||||
expect(card.name).toBe('Karte');
|
||||
expect(card.config.template).toBe('classic');
|
||||
});
|
||||
|
||||
it('get: eine ID mit Sonderzeichen wird nie an den Server geschickt', async () => {
|
||||
const fake = fakeApi();
|
||||
for (const id of ['', '../x', 'a/b', '-abc', 'a'.repeat(37), 'a b']) {
|
||||
await expect(service(fake).get(id)).rejects.toMatchObject({ code: 404, type: 'row_not_found' });
|
||||
}
|
||||
expect(fake.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rename: normalisiert den Namen und ändert nur ihn', async () => {
|
||||
const fake = fakeApi([row()]);
|
||||
const card = await service(fake).rename('row1', ' Neuer Name ');
|
||||
expect(fake.methods('updateRow')[0].args).toEqual(['row1', { name: 'Neuer Name' }]);
|
||||
expect(card.name).toBe('Neuer Name');
|
||||
expect(card.config.template).toBe('classic');
|
||||
});
|
||||
|
||||
it('loadImage und loadPreview holen die richtige Datei', async () => {
|
||||
const fake = fakeApi();
|
||||
const svc = service(fake);
|
||||
await svc.loadImage(previous());
|
||||
await svc.loadPreview(previous());
|
||||
expect(fake.methods('fetchFile').map((c) => c.args[0])).toEqual(['oldImage', 'oldPreview']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appwriteCardsApi', () => {
|
||||
const data: CardRowData = { name: 'N', config: '{}', imageFileId: 'i', previewFileId: 'p' };
|
||||
const apiRow = { $id: 'r1', $updatedAt: NOW, ...data };
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(mocks)) fn.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('createFile: Bucket, neue ID, Datei und Rechte', async () => {
|
||||
mocks.createFile.mockResolvedValue({ $id: 'f1', name: 'x' });
|
||||
const file = new File(['x'], 'image.png', { type: 'image/png' });
|
||||
const res = await appwriteCardsApi().createFile(file, OWNER);
|
||||
expect(res.$id).toBe('f1');
|
||||
expect(mocks.createFile).toHaveBeenCalledWith({
|
||||
bucketId: 'bucket',
|
||||
fileId: expect.stringMatching(/^[A-Za-z0-9._-]{1,36}$/),
|
||||
file,
|
||||
permissions: OWNER,
|
||||
});
|
||||
});
|
||||
|
||||
it('deleteFile: Bucket und Datei-ID', async () => {
|
||||
mocks.deleteFile.mockResolvedValue({});
|
||||
await appwriteCardsApi().deleteFile('f1');
|
||||
expect(mocks.deleteFile).toHaveBeenCalledWith({ bucketId: 'bucket', fileId: 'f1' });
|
||||
});
|
||||
|
||||
it('createRow: Datenbank, Tabelle, neue ID, Daten und Rechte', async () => {
|
||||
mocks.createRow.mockResolvedValue(apiRow);
|
||||
const row = await appwriteCardsApi().createRow(data, OWNER);
|
||||
expect(row).toEqual(apiRow);
|
||||
expect(mocks.createRow).toHaveBeenCalledWith({
|
||||
databaseId: 'db',
|
||||
tableId: 'cards-table',
|
||||
rowId: expect.stringMatching(/^[A-Za-z0-9._-]{1,36}$/),
|
||||
data,
|
||||
permissions: OWNER,
|
||||
});
|
||||
});
|
||||
|
||||
it('updateRow: ohne Rechte, getRow und deleteRow mit Zeilen-ID', async () => {
|
||||
mocks.updateRow.mockResolvedValue(apiRow);
|
||||
mocks.getRow.mockResolvedValue(apiRow);
|
||||
mocks.deleteRow.mockResolvedValue({});
|
||||
const api = appwriteCardsApi();
|
||||
await api.updateRow('r1', { name: 'Neu' });
|
||||
expect(mocks.updateRow).toHaveBeenCalledWith({
|
||||
databaseId: 'db',
|
||||
tableId: 'cards-table',
|
||||
rowId: 'r1',
|
||||
data: { name: 'Neu' },
|
||||
});
|
||||
expect(await api.getRow('r1')).toEqual(apiRow);
|
||||
expect(mocks.getRow).toHaveBeenCalledWith({ databaseId: 'db', tableId: 'cards-table', rowId: 'r1' });
|
||||
await api.deleteRow('r1');
|
||||
expect(mocks.deleteRow).toHaveBeenCalledWith({ databaseId: 'db', tableId: 'cards-table', rowId: 'r1' });
|
||||
});
|
||||
|
||||
it('listRows: zuletzt geändert zuerst, höchstens 100, Ergebnis ist .rows', async () => {
|
||||
mocks.listRows.mockResolvedValue({ total: 1, rows: [apiRow] });
|
||||
const rows = await appwriteCardsApi().listRows();
|
||||
expect(rows).toEqual([apiRow]);
|
||||
const params = mocks.listRows.mock.calls[0][0];
|
||||
expect(params).toMatchObject({ databaseId: 'db', tableId: 'cards-table' });
|
||||
expect(params.queries).toEqual([
|
||||
JSON.stringify({ method: 'orderDesc', attribute: '$updatedAt' }),
|
||||
JSON.stringify({ method: 'limit', values: [100] }),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('fetchFile', () => {
|
||||
const prepare = () =>
|
||||
mocks.prepareRequest.mockImplementation((method: string, url: URL) => ({
|
||||
uri: url.toString(),
|
||||
options: { method, headers: { 'X-Appwrite-Project': 'proj' }, credentials: 'include' },
|
||||
}));
|
||||
|
||||
it('holt die Datei über prepareRequest (Projekt-Header + Cookie), nie über eine View-URL mit ?project=', async () => {
|
||||
prepare();
|
||||
const fetchMock = vi.fn(async () => new Response(png(), { status: 200, headers: { 'content-type': 'image/png' } }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const result = await appwriteCardsApi().fetchFile('f1');
|
||||
expect(result.type).toBe('image/png');
|
||||
expect(mocks.prepareRequest).toHaveBeenCalledOnce();
|
||||
expect(mocks.prepareRequest.mock.calls[0][0]).toBe('GET');
|
||||
expect((mocks.prepareRequest.mock.calls[0][1] as URL).toString()).toBe(
|
||||
'https://aircard.test/v1/storage/buckets/bucket/files/f1/view',
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://aircard.test/v1/storage/buckets/bucket/files/f1/view', {
|
||||
method: 'GET',
|
||||
headers: { 'X-Appwrite-Project': 'proj' },
|
||||
credentials: 'include',
|
||||
});
|
||||
});
|
||||
|
||||
it('maskiert die Datei-ID im Pfad', async () => {
|
||||
prepare();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(png(), { headers: { 'content-type': 'image/png' } })));
|
||||
await appwriteCardsApi().fetchFile('../x?y');
|
||||
expect((mocks.prepareRequest.mock.calls[0][1] as URL).pathname).toBe('/v1/storage/buckets/bucket/files/..%2Fx%3Fy/view');
|
||||
});
|
||||
|
||||
it('wirft bei Fehlerantwort eine AppwriteException mit Status, Typ und Meldung des Servers', async () => {
|
||||
prepare();
|
||||
const body = JSON.stringify({ message: 'File not found', type: 'storage_file_not_found', code: 404 });
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(body, { status: 404, statusText: 'Not Found', headers: { 'content-type': 'application/json' } })),
|
||||
);
|
||||
const err = await appwriteCardsApi().fetchFile('f1').catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(AppwriteException);
|
||||
expect(err).toMatchObject({ code: 404, type: 'storage_file_not_found', message: 'File not found' });
|
||||
});
|
||||
|
||||
it('wirft auch bei einer Fehlerseite ohne JSON (z. B. 502 von nginx)', async () => {
|
||||
prepare();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('<html>Bad Gateway</html>', { status: 502, statusText: 'Bad Gateway' })));
|
||||
const err = await appwriteCardsApi().fetchFile('f1').catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(AppwriteException);
|
||||
expect(err).toMatchObject({ code: 502, message: 'Bad Gateway' });
|
||||
});
|
||||
|
||||
it('eine Antwort ohne Bildtyp ist ein Fehler', async () => {
|
||||
prepare();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('<html>hallo</html>', { status: 200, headers: { 'content-type': 'text/html' } })));
|
||||
const err = await appwriteCardsApi().fetchFile('f1').catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(AppwriteException);
|
||||
expect(err).toMatchObject({ code: 502, type: 'general_unknown', message: 'Unerwartete Antwort' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
// Gespeicherte Karten: Dienst (Regeln fürs Speichern, Aufräumen, Laden) und Anschluss an Appwrite.
|
||||
// Der Dienst kennt nur das schmale `CardsApi`-Interface und lässt sich dadurch ohne Netzwerk testen.
|
||||
import { AppwriteException, ID, Permission, Query, Role, type Models } from 'appwrite';
|
||||
import { parseConfig, serializeConfig, type CardConfig } from '../card/config';
|
||||
import type { SavedCardRef } from '../editor/state';
|
||||
import { APPWRITE_ENDPOINT, IDS, client, storage, tables } from './appwrite';
|
||||
import { currentUserId } from './auth';
|
||||
|
||||
export interface CardRowData {
|
||||
name: string;
|
||||
config: string;
|
||||
imageFileId: string;
|
||||
previewFileId: string;
|
||||
}
|
||||
|
||||
export interface CardRow extends CardRowData {
|
||||
$id: string;
|
||||
$updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CardsApi {
|
||||
createFile(file: File, permissions: string[]): Promise<{ $id: string }>;
|
||||
deleteFile(fileId: string): Promise<void>;
|
||||
fetchFile(fileId: string): Promise<Blob>;
|
||||
createRow(data: CardRowData, permissions: string[]): Promise<CardRow>;
|
||||
updateRow(id: string, data: Partial<CardRowData>): Promise<CardRow>;
|
||||
getRow(id: string): Promise<CardRow>;
|
||||
deleteRow(id: string): Promise<void>;
|
||||
listRows(): Promise<CardRow[]>;
|
||||
}
|
||||
|
||||
export interface SavedCard extends SavedCardRef {
|
||||
config: CardConfig;
|
||||
}
|
||||
|
||||
export interface SaveInput {
|
||||
name: string;
|
||||
config: CardConfig;
|
||||
image: Blob;
|
||||
/** false: das Bild ist schon gespeichert und wird nicht erneut hochgeladen (nur bei `previous`). */
|
||||
imageChanged: boolean;
|
||||
preview: Blob;
|
||||
previous: SavedCardRef | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_CARD_NAME = 'Meine Karte';
|
||||
const NAME_MAX = 80;
|
||||
/** Zeilen- und Datei-IDs von Appwrite: höchstens 36 Zeichen, kein Sonderzeichen am Anfang. */
|
||||
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
|
||||
|
||||
/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“. */
|
||||
export function normalizeName(name: string): string {
|
||||
const collapsed = name.replace(/\s+/g, ' ').trim();
|
||||
const cut = Array.from(collapsed).slice(0, NAME_MAX).join('').trim();
|
||||
return cut || DEFAULT_CARD_NAME;
|
||||
}
|
||||
|
||||
/** Nur der Besitzer darf die Zeile bzw. Datei lesen, ändern und löschen. */
|
||||
export function ownerPermissions(userId: string): string[] {
|
||||
const owner = Role.user(userId);
|
||||
return [Permission.read(owner), Permission.update(owner), Permission.delete(owner)];
|
||||
}
|
||||
|
||||
const EXTENSIONS: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' };
|
||||
|
||||
/** Verpackt ein Bild als Datei für den Bucket; der Name folgt dem Typ (der Bucket prüft die Endung). */
|
||||
export function toUploadFile(blob: Blob, base: 'image' | 'preview'): File {
|
||||
const ext = EXTENSIONS[blob.type];
|
||||
if (!ext) throw new Error(`Bildtyp nicht erlaubt: ${blob.type || 'unbekannt'}`);
|
||||
return new File([blob], `${base}.${ext}`, { type: blob.type });
|
||||
}
|
||||
|
||||
function toSaved(row: CardRow): SavedCard {
|
||||
return {
|
||||
id: row.$id,
|
||||
name: row.name,
|
||||
imageFileId: row.imageFileId,
|
||||
previewFileId: row.previewFileId,
|
||||
updatedAt: row.$updatedAt,
|
||||
config: parseConfig(row.config),
|
||||
};
|
||||
}
|
||||
|
||||
/** Räumt auf, soweit es geht; ein Fehler (auch 404) ist hier nie ein Grund abzubrechen. */
|
||||
async function deleteQuietly(api: CardsApi, fileIds: string[]): Promise<void> {
|
||||
await Promise.allSettled(fileIds.map((id) => api.deleteFile(id)));
|
||||
}
|
||||
|
||||
export function createCardsService(api: CardsApi, userId: () => string) {
|
||||
async function save(input: SaveInput): Promise<SavedCard> {
|
||||
// Ohne Nutzer wirft das hier, bevor etwas hochgeladen wird.
|
||||
const permissions = ownerPermissions(userId());
|
||||
const prev = input.previous;
|
||||
const name = normalizeName(input.name);
|
||||
const config = serializeConfig(input.config);
|
||||
const uploadImage = prev === null || input.imageChanged;
|
||||
// Typen vorab prüfen: ein unzulässiges Bild darf keine halben Uploads hinterlassen.
|
||||
const imageFile = uploadImage ? toUploadFile(input.image, 'image') : null;
|
||||
const previewFile = toUploadFile(input.preview, 'preview');
|
||||
|
||||
const uploaded: string[] = [];
|
||||
const upload = async (file: File): Promise<string> => {
|
||||
const created = await api.createFile(file, permissions);
|
||||
uploaded.push(created.$id);
|
||||
return created.$id;
|
||||
};
|
||||
|
||||
let row: CardRow;
|
||||
try {
|
||||
const imageFileId = imageFile ? await upload(imageFile) : (prev as SavedCardRef).imageFileId;
|
||||
const previewFileId = await upload(previewFile);
|
||||
const data: CardRowData = { name, config, imageFileId, previewFileId };
|
||||
row = prev ? await api.updateRow(prev.id, data) : await api.createRow(data, permissions);
|
||||
} catch (err) {
|
||||
await deleteQuietly(api, uploaded);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (prev) {
|
||||
// Die Karte zeigt schon auf die neuen Dateien; die alten sind nur noch Müll.
|
||||
await deleteQuietly(api, [prev.previewFileId, ...(uploadImage ? [prev.imageFileId] : [])]);
|
||||
}
|
||||
return toSaved(row);
|
||||
}
|
||||
|
||||
return {
|
||||
async list(): Promise<SavedCard[]> {
|
||||
return (await api.listRows()).map(toSaved);
|
||||
},
|
||||
|
||||
async get(id: string): Promise<SavedCard> {
|
||||
// Die ID landet im Pfad der Anfrage; was keine Appwrite-ID sein kann, gibt es nicht.
|
||||
if (!ID_PATTERN.test(id)) throw new AppwriteException('Row not found', 404, 'row_not_found', '');
|
||||
return toSaved(await api.getRow(id));
|
||||
},
|
||||
|
||||
save,
|
||||
|
||||
async rename(id: string, name: string): Promise<SavedCard> {
|
||||
return toSaved(await api.updateRow(id, { name: normalizeName(name) }));
|
||||
},
|
||||
|
||||
async remove(card: SavedCardRef): Promise<void> {
|
||||
await api.deleteRow(card.id);
|
||||
await deleteQuietly(api, [card.imageFileId, card.previewFileId]);
|
||||
},
|
||||
|
||||
loadImage(card: SavedCardRef): Promise<Blob> {
|
||||
return api.fetchFile(card.imageFileId);
|
||||
},
|
||||
|
||||
loadPreview(card: SavedCardRef): Promise<Blob> {
|
||||
return api.fetchFile(card.previewFileId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type CardsService = ReturnType<typeof createCardsService>;
|
||||
|
||||
/** Lädt eine Bilddatei mit Projekt-Header und Cookie (siehe `client.prepareRequest`). */
|
||||
async function fetchFile(fileId: string): Promise<Blob> {
|
||||
// Bewusst kein storage.getFileView(): das hängt das Projekt als Query-Parameter an die URL und schickt keinen
|
||||
// Header; so trifft die Anfrage die Traefik-Route für /v1 mit Header nicht. prepareRequest setzt Header und Cookie.
|
||||
const url = new URL(`${APPWRITE_ENDPOINT}/storage/buckets/${IDS.bucket}/files/${encodeURIComponent(fileId)}/view`);
|
||||
const { uri, options } = client.prepareRequest('GET', url);
|
||||
const res = await fetch(uri, options);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
let body: { message?: unknown; type?: unknown } = {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
if (typeof parsed === 'object' && parsed !== null) body = parsed;
|
||||
} catch {
|
||||
// keine JSON-Antwort (z. B. Fehlerseite des Proxys): Statuszeile genügt
|
||||
}
|
||||
throw new AppwriteException(
|
||||
typeof body.message === 'string' ? body.message : res.statusText,
|
||||
res.status,
|
||||
typeof body.type === 'string' ? body.type : '',
|
||||
text,
|
||||
);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
if (!blob.type.startsWith('image/')) throw new AppwriteException('Unerwartete Antwort', 502, 'general_unknown', '');
|
||||
return blob;
|
||||
}
|
||||
|
||||
type TableRow = Models.Row & CardRowData;
|
||||
|
||||
export function appwriteCardsApi(): CardsApi {
|
||||
const target = { databaseId: IDS.database, tableId: IDS.table };
|
||||
return {
|
||||
createFile: (file, permissions) =>
|
||||
storage.createFile({ bucketId: IDS.bucket, fileId: ID.unique(), file, permissions }),
|
||||
deleteFile: async (fileId) => {
|
||||
await storage.deleteFile({ bucketId: IDS.bucket, fileId });
|
||||
},
|
||||
fetchFile,
|
||||
createRow: (data, permissions) =>
|
||||
tables.createRow<TableRow>({ ...target, rowId: ID.unique(), data, permissions }),
|
||||
// Beim Ändern bleiben die Rechte der Zeile unangetastet.
|
||||
updateRow: (id, data) => tables.updateRow<TableRow>({ ...target, rowId: id, data }),
|
||||
getRow: (id) => tables.getRow<TableRow>({ ...target, rowId: id }),
|
||||
deleteRow: async (id) => {
|
||||
await tables.deleteRow({ ...target, rowId: id });
|
||||
},
|
||||
listRows: async () => {
|
||||
const list = await tables.listRows<TableRow>({
|
||||
...target,
|
||||
queries: [Query.orderDesc('$updatedAt'), Query.limit(100)],
|
||||
});
|
||||
return list.rows;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler, den `withLogin`/`createSaveAction` als 401 erkennen. */
|
||||
export function requireUserId(): string {
|
||||
const id = currentUserId();
|
||||
if (!id) throw Object.assign(new Error('unauthorized'), { code: 401 });
|
||||
return id;
|
||||
}
|
||||
|
||||
let shared: CardsService | null = null;
|
||||
|
||||
/** Der Kartendienst der Seite (beim ersten Gebrauch erzeugt). */
|
||||
export function cardsService(): CardsService {
|
||||
shared ??= createCardsService(appwriteCardsApi(), requireUserId);
|
||||
return shared;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { LoginCancelled, withLogin } from './with-login';
|
||||
|
||||
const unauthorized = () => Object.assign(new Error('Unauthorized'), { code: 401, type: 'user_unauthorized' });
|
||||
|
||||
describe('withLogin', () => {
|
||||
it('gibt das Ergebnis zurück, ohne den Login-Dialog zu öffnen', async () => {
|
||||
const fn = vi.fn().mockResolvedValue('ok');
|
||||
const requireLogin = vi.fn();
|
||||
const refreshUser = vi.fn();
|
||||
await expect(withLogin(fn, requireLogin, refreshUser)).resolves.toBe('ok');
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
expect(requireLogin).not.toHaveBeenCalled();
|
||||
expect(refreshUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('bei 401: Nutzer neu prüfen, einmal anmelden, dann fn erneut aufrufen', async () => {
|
||||
const order: string[] = [];
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => {
|
||||
order.push('fn1');
|
||||
throw unauthorized();
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
order.push('fn2');
|
||||
return 'ergebnis';
|
||||
});
|
||||
const refreshUser = vi.fn(async () => {
|
||||
order.push('refresh');
|
||||
return null;
|
||||
});
|
||||
const requireLogin = vi.fn(async () => {
|
||||
order.push('login');
|
||||
return true;
|
||||
});
|
||||
await expect(withLogin(fn, requireLogin, refreshUser)).resolves.toBe('ergebnis');
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(requireLogin).toHaveBeenCalledOnce();
|
||||
expect(order).toEqual(['fn1', 'refresh', 'login', 'fn2']);
|
||||
});
|
||||
|
||||
it('bricht der Nutzer den Dialog ab, wirft withLogin LoginCancelled und ruft fn nicht erneut auf', async () => {
|
||||
const fn = vi.fn().mockRejectedValue(unauthorized());
|
||||
const requireLogin = vi.fn().mockResolvedValue(false);
|
||||
const err = await withLogin(fn, requireLogin, vi.fn().mockResolvedValue(null)).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(LoginCancelled);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('andere Fehler werden durchgereicht, ohne Dialog und ohne zweiten Versuch', async () => {
|
||||
const boom = Object.assign(new Error('Serverfehler'), { code: 500 });
|
||||
const fn = vi.fn().mockRejectedValue(boom);
|
||||
const requireLogin = vi.fn();
|
||||
const refreshUser = vi.fn();
|
||||
await expect(withLogin(fn, requireLogin, refreshUser)).rejects.toBe(boom);
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
expect(requireLogin).not.toHaveBeenCalled();
|
||||
expect(refreshUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('401 auch beim zweiten Versuch wird weitergereicht (kein dritter Versuch, kein zweiter Dialog)', async () => {
|
||||
const second = unauthorized();
|
||||
const fn = vi.fn().mockRejectedValueOnce(unauthorized()).mockRejectedValueOnce(second);
|
||||
const requireLogin = vi.fn().mockResolvedValue(true);
|
||||
await expect(withLogin(fn, requireLogin, vi.fn().mockResolvedValue(null))).rejects.toBe(second);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(requireLogin).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('scheitert die Nutzerprüfung (z. B. Netz), wird trotzdem angemeldet', async () => {
|
||||
const fn = vi.fn().mockRejectedValueOnce(unauthorized()).mockResolvedValueOnce('ok');
|
||||
const refreshUser = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
const requireLogin = vi.fn().mockResolvedValue(true);
|
||||
await expect(withLogin(fn, requireLogin, refreshUser)).resolves.toBe('ok');
|
||||
expect(requireLogin).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// Führt eine Kartenanfrage aus und meldet bei abgelaufener Sitzung (401) einmal neu an.
|
||||
import { getCurrentUser } from './auth';
|
||||
import { isUnauthorized } from './errors';
|
||||
import { requireLogin } from './login-dialog';
|
||||
|
||||
/** Der Nutzer hat den Login-Dialog geschlossen, ohne sich anzumelden. */
|
||||
export class LoginCancelled extends Error {
|
||||
constructor() {
|
||||
super('Anmeldung abgebrochen');
|
||||
this.name = 'LoginCancelled';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft `fn` auf. Antwortet der Server mit 401, wird der Anmeldestatus neu geholt (damit Konto-Menü und
|
||||
* Nutzer-ID nicht veraltet sind), einmal der Login-Dialog gezeigt und `fn` danach genau einmal wiederholt.
|
||||
* Schließt der Nutzer den Dialog, wirft das `LoginCancelled`; jeder andere Fehler geht unverändert durch.
|
||||
*/
|
||||
export async function withLogin<T>(
|
||||
fn: () => Promise<T>,
|
||||
requireLoginFn: typeof requireLogin = requireLogin,
|
||||
refreshUser: () => Promise<unknown> = getCurrentUser,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (!isUnauthorized(err)) throw err;
|
||||
}
|
||||
try {
|
||||
await refreshUser();
|
||||
} catch {
|
||||
// Ein Netzfehler hier darf die Anmeldung nicht verhindern; der Login-Dialog setzt den Nutzer ohnehin neu.
|
||||
}
|
||||
if (!(await requireLoginFn())) throw new LoginCancelled();
|
||||
return fn();
|
||||
}
|
||||
Reference in new issue
Block a user