fix(cards): Sitzung, Entwurf und Datei-Aufräumen beim Öffnen und Speichern
- Gäste/abgelaufene Sitzung: Appwrite antwortet mit 404; withLogin und openSavedCard melden dann an (Dialog, einmal wiederholen) statt „nicht gefunden“ zu zeigen. - Öffnen einer Karte fragt vor dem Verwerfen eines ungespeicherten Entwurfs nach; ein fehlgeschlagenes Öffnen setzt einen gefüllten Editor nicht zurück. - Update ohne Bildwechsel schickt kein imageFileId mehr (veralteter Stand zeigte sonst auf eine gelöschte Datei). - Uploads werden nur nach eindeutiger Ablehnung (4xx ohne 408/429) gelöscht, nicht bei Netzfehler, 5xx oder 429. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
09de06e797
commit
0c51689dfd
7 files changed
+546
-57
No files matched your search
@@ -147,6 +147,9 @@ const newInput = (patch: Partial<SaveInput> = {}): SaveInput => ({
|
||||
const service = (fake: ReturnType<typeof fakeApi>, userId: () => string = () => 'u1') =>
|
||||
createCardsService(fake.api, userId);
|
||||
|
||||
/** Der Server hat die Anfrage eindeutig abgelehnt (4xx): es wurde nichts geschrieben. */
|
||||
const rejected = (code = 400) => new AppwriteException('Abgelehnt', code, 'document_invalid_structure');
|
||||
|
||||
describe('normalizeName', () => {
|
||||
it('trimmt und fasst Leerraum zusammen', () => {
|
||||
expect(normalizeName(' Mein Urlaub ')).toBe('Mein Urlaub');
|
||||
@@ -240,14 +243,60 @@ describe('save: neue Karte', () => {
|
||||
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 () => {
|
||||
it('lehnt der Server createRow eindeutig ab (4xx), werden beide neuen Dateien gelöscht und der Fehler kommt an', async () => {
|
||||
const fake = fakeApi();
|
||||
const boom = new Error('createRow');
|
||||
const boom = rejected(400);
|
||||
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('auch 401 (Sitzung weg) und andere 4xx gelten als eindeutig abgelehnt', async () => {
|
||||
for (const code of [401, 403, 404, 409, 413]) {
|
||||
const fake = fakeApi();
|
||||
fake.failOnce.set('createRow', rejected(code));
|
||||
await expect(service(fake).save(newInput())).rejects.toThrow();
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['file1', 'file2']);
|
||||
}
|
||||
});
|
||||
|
||||
describe('unklarer Ausgang beim Schreiben der Zeile: Uploads bleiben liegen', () => {
|
||||
const ambiguous: [string, () => unknown][] = [
|
||||
['Netzfehler (TypeError)', () => new TypeError('Failed to fetch')],
|
||||
['503', () => new AppwriteException('Server', 503, 'general_server_error')],
|
||||
['500', () => new AppwriteException('Server', 500, 'general_server_error')],
|
||||
['429 (zu viele Anfragen)', () => new AppwriteException('Rate', 429, 'general_rate_limit_exceeded')],
|
||||
['408 (Zeitüberschreitung)', () => new AppwriteException('Timeout', 408, 'general_timeout')],
|
||||
['ein Fehler ohne Statuscode', () => new Error('unbekannt')],
|
||||
];
|
||||
|
||||
for (const [label, make] of ambiguous) {
|
||||
it(`neue Karte, createRow: ${label}`, async () => {
|
||||
const fake = fakeApi();
|
||||
const boom = make();
|
||||
fake.failOnce.set('createRow', boom);
|
||||
await expect(service(fake).save(newInput())).rejects.toBe(boom);
|
||||
expect(fake.methods('deleteFile')).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
|
||||
it('bestehende Karte, updateRow: Netzfehler löscht weder neue noch alte Dateien', async () => {
|
||||
const fake = fakeApi([{ $id: 'row1', $updatedAt: NOW, name: 'Alt', config: serializeConfig(config()), imageFileId: 'oldImage', previewFileId: 'oldPreview' }]);
|
||||
const boom = new TypeError('Failed to fetch');
|
||||
fake.failOnce.set('updateRow', boom);
|
||||
await expect(service(fake).save(newInput({ imageChanged: true, previous: previous() }))).rejects.toBe(boom);
|
||||
expect(fake.methods('deleteFile')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Zeile verschwunden, dann Netzfehler beim Anlegen: nichts wird gelöscht', async () => {
|
||||
const fake = fakeApi();
|
||||
fake.failOnce.set('updateRow', new AppwriteException('Row not found', 404, 'row_not_found'));
|
||||
fake.failOnce.set('createRow', new TypeError('Failed to fetch'));
|
||||
await expect(service(fake).save(newInput({ imageChanged: false, previous: previous() }))).rejects.toThrow(TypeError);
|
||||
expect(fake.methods('deleteFile')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('scheitert die Vorschau, wird das Bild gelöscht und keine Zeile angelegt', async () => {
|
||||
const fake = fakeApi();
|
||||
const boom = new Error('preview');
|
||||
@@ -259,7 +308,7 @@ describe('save: neue Karte', () => {
|
||||
|
||||
it('Fehler beim Aufräumen überdecken den Ursprungsfehler nicht', async () => {
|
||||
const fake = fakeApi();
|
||||
const boom = new Error('createRow');
|
||||
const boom = rejected(400);
|
||||
fake.failOnce.set('createRow', boom);
|
||||
const original = fake.api.deleteFile;
|
||||
fake.api.deleteFile = async (id) => {
|
||||
@@ -309,10 +358,13 @@ describe('save: bestehende Karte', () => {
|
||||
const [file] = fake.methods('createFile');
|
||||
expect((file.args[0] as File).name).toBe('preview.webp');
|
||||
expect(file.args[1]).toEqual(OWNER);
|
||||
// Ohne Bildwechsel steht kein imageFileId im Update: ein veralteter Schnappschuss darf die Zeile nicht auf eine
|
||||
// inzwischen gelöschte Bilddatei zurücksetzen.
|
||||
expect(fake.methods('updateRow')[0].args).toEqual([
|
||||
'row1',
|
||||
{ name: 'Neu', config: serializeConfig(c), imageFileId: 'oldImage', previewFileId: 'file1' },
|
||||
{ name: 'Neu', config: serializeConfig(c), previewFileId: 'file1' },
|
||||
]);
|
||||
expect(fake.methods('updateRow')[0].args[1]).not.toHaveProperty('imageFileId');
|
||||
expect(fake.methods('deleteFile')[0].args).toEqual(['oldPreview']);
|
||||
expect(saved.id).toBe('row1');
|
||||
expect(saved.imageFileId).toBe('oldImage');
|
||||
@@ -320,6 +372,18 @@ describe('save: bestehende Karte', () => {
|
||||
expect(saved.config.network).toBe('jcb');
|
||||
});
|
||||
|
||||
it('veralteter Schnappschuss (Bild in anderem Tab ersetzt): das Update schickt kein altes imageFileId, die Zeile behält das neue Bild', async () => {
|
||||
// Der Server kennt schon das neue Bild; der Editor hält noch den Stand von vorher.
|
||||
const fake = fakeApi([{ ...existing(), imageFileId: 'serverImage', previewFileId: 'serverPreview' }]);
|
||||
const saved = await service(fake).save(
|
||||
newInput({ imageChanged: false, previous: previous({ imageFileId: 'deletedImage', previewFileId: 'stalePreview' }) }),
|
||||
);
|
||||
expect(fake.methods('updateRow')[0].args[1]).not.toHaveProperty('imageFileId');
|
||||
expect(saved.imageFileId).toBe('serverImage');
|
||||
// Gelöscht wird nur die Vorschau des Schnappschusses, nie die Bilddatei.
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0])).toEqual(['stalePreview']);
|
||||
});
|
||||
|
||||
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() }));
|
||||
@@ -331,9 +395,9 @@ describe('save: bestehende Karte', () => {
|
||||
expect(saved.imageFileId).toBe('file1');
|
||||
});
|
||||
|
||||
it('scheitert updateRow, werden nur die neuen Dateien gelöscht', async () => {
|
||||
it('lehnt der Server updateRow ab, werden nur die neuen Dateien gelöscht', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
const boom = new Error('updateRow');
|
||||
const boom = rejected(400);
|
||||
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']);
|
||||
@@ -341,7 +405,7 @@ describe('save: bestehende Karte', () => {
|
||||
|
||||
it('scheitert updateRow ohne Bildwechsel, wird nur die neue Vorschau gelöscht', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
fake.failOnce.set('updateRow', new Error('updateRow'));
|
||||
fake.failOnce.set('updateRow', rejected(400));
|
||||
await expect(service(fake).save(newInput({ imageChanged: false, previous: previous() }))).rejects.toThrow();
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0])).toEqual(['file1']);
|
||||
});
|
||||
@@ -401,7 +465,7 @@ describe('save: bestehende Karte', () => {
|
||||
|
||||
it('scheitert auch das Anlegen, werden alle neuen Dateien gelöscht, die alten bleiben, der Fehler kommt an', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
const boom = new Error('createRow');
|
||||
const boom = rejected(400);
|
||||
fake.failOnce.set('updateRow', gone());
|
||||
fake.failOnce.set('createRow', boom);
|
||||
await expect(service(fake).save(newInput({ imageChanged: false, previous: previous() }))).rejects.toBe(boom);
|
||||
|
||||
@@ -87,6 +87,15 @@ async function deleteQuietly(api: CardsApi, fileIds: string[]): Promise<void> {
|
||||
await Promise.allSettled(fileIds.map((id) => api.deleteFile(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Server hat eine Anfrage eindeutig abgelehnt (4xx außer Zeitüberschreitung 408 und Drosselung 429): es wurde nichts
|
||||
* geschrieben. Netzfehler, 5xx und alles ohne Statuscode lassen offen, ob die Zeile doch angekommen ist.
|
||||
*/
|
||||
function isDefinitiveFailure(err: unknown): boolean {
|
||||
const code = typeof err === 'object' && err !== null ? (err as { code?: unknown }).code : undefined;
|
||||
return typeof code === 'number' && code >= 400 && code < 500 && code !== 408 && code !== 429;
|
||||
}
|
||||
|
||||
export function createCardsService(api: CardsApi, userId: () => string) {
|
||||
async function save(input: SaveInput): Promise<SavedCard> {
|
||||
// Ohne Nutzer wirft das hier, bevor etwas hochgeladen wird.
|
||||
@@ -109,25 +118,36 @@ export function createCardsService(api: CardsApi, userId: () => string) {
|
||||
let row: CardRow;
|
||||
// Die Zeile gab es nicht mehr (anderer Tab, anderes Konto): dann wurde eine neue Karte angelegt, die alten Dateien sind Müll.
|
||||
let replacedMissingCard = false;
|
||||
// Scheitert ein Upload, hängt noch keine Zeile an den Dateien. Beim Schreiben der Zeile ist der Ausgang bei
|
||||
// Netzfehler/5xx dagegen offen (sie kann angekommen sein): dann bleiben die Uploads lieber liegen, als dass
|
||||
// eine Zeile auf gelöschte Dateien zeigt.
|
||||
let writingRow = false;
|
||||
try {
|
||||
const imageFileId = imageFile ? await upload(imageFile) : (prev as SavedCardRef).imageFileId;
|
||||
const newImageId = imageFile ? await upload(imageFile) : null;
|
||||
const previewFileId = await upload(previewFile);
|
||||
const data: CardRowData = { name, config, imageFileId, previewFileId };
|
||||
writingRow = true;
|
||||
if (!prev) {
|
||||
row = await api.createRow(data, permissions);
|
||||
// Ohne vorherige Karte wird immer ein Bild hochgeladen (uploadImage).
|
||||
row = await api.createRow({ name, config, imageFileId: newImageId as string, previewFileId }, permissions);
|
||||
} else {
|
||||
// Ohne Bildwechsel steht kein imageFileId im Update: der Schnappschuss des Editors kann veraltet sein
|
||||
// (Bild in einem anderen Tab ersetzt) und würde die Zeile auf eine gelöschte Datei zurücksetzen.
|
||||
const update: Partial<CardRowData> = { name, config, previewFileId };
|
||||
if (newImageId) update.imageFileId = newImageId;
|
||||
try {
|
||||
row = await api.updateRow(prev.id, data);
|
||||
row = await api.updateRow(prev.id, update);
|
||||
} catch (err) {
|
||||
if (errorType(err) !== 'row_not_found') throw err;
|
||||
replacedMissingCard = true;
|
||||
// Ohne Bildwechsel zeigt `data` auf die Datei der verschwundenen Karte; die neue Karte bekommt ein eigenes Bild.
|
||||
const ownImageId = imageFile ? imageFileId : await upload(toUploadFile(input.image, 'image'));
|
||||
row = await api.createRow({ ...data, imageFileId: ownImageId }, permissions);
|
||||
// Ohne Bildwechsel gibt es noch keine Bilddatei der neuen Karte; die der verschwundenen gehört ihr nicht.
|
||||
writingRow = false;
|
||||
const ownImageId = newImageId ?? (await upload(toUploadFile(input.image, 'image')));
|
||||
writingRow = true;
|
||||
row = await api.createRow({ name, config, imageFileId: ownImageId, previewFileId }, permissions);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
await deleteQuietly(api, uploaded);
|
||||
if (!writingRow || isDefinitiveFailure(err)) await deleteQuietly(api, uploaded);
|
||||
throw err;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@ import { LoginCancelled, withLogin } from './with-login';
|
||||
|
||||
const unauthorized = () => Object.assign(new Error('Unauthorized'), { code: 401, type: 'user_unauthorized' });
|
||||
|
||||
// Appwrite antwortet Gästen auf Lesezugriffe mit 404, nicht mit 401 (Zeilen- und Dateirechte).
|
||||
const rowNotFound = () => Object.assign(new Error('Row not found'), { code: 404, type: 'row_not_found' });
|
||||
const fileNotFound = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' });
|
||||
const user = { $id: 'u1', email: 'a@b.de' };
|
||||
|
||||
describe('withLogin', () => {
|
||||
it('gibt das Ergebnis zurück, ohne den Login-Dialog zu öffnen', async () => {
|
||||
const fn = vi.fn().mockResolvedValue('ok');
|
||||
@@ -76,4 +81,78 @@ describe('withLogin', () => {
|
||||
await expect(withLogin(fn, requireLogin, refreshUser)).resolves.toBe('ok');
|
||||
expect(requireLogin).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe('404 für Gäste (row_not_found / storage_file_not_found)', () => {
|
||||
it.each([
|
||||
['row_not_found', rowNotFound],
|
||||
['storage_file_not_found', fileNotFound],
|
||||
])('%s ohne Sitzung: wie eine abgelaufene Sitzung behandeln (anmelden, einmal wiederholen)', async (_name, make) => {
|
||||
const order: string[] = [];
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => {
|
||||
order.push('fn1');
|
||||
throw make();
|
||||
})
|
||||
.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(order).toEqual(['fn1', 'refresh', 'login', 'fn2']);
|
||||
expect(refreshUser).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('ist der Nutzer noch angemeldet, ist es ein echtes „nicht gefunden“ und wird durchgereicht', async () => {
|
||||
const notFound = rowNotFound();
|
||||
const fn = vi.fn().mockRejectedValue(notFound);
|
||||
const requireLogin = vi.fn();
|
||||
const refreshUser = vi.fn().mockResolvedValue(user);
|
||||
await expect(withLogin(fn, requireLogin, refreshUser)).rejects.toBe(notFound);
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
expect(requireLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scheitert die Nutzerprüfung (Netz), wird das ursprüngliche 404 durchgereicht', async () => {
|
||||
const notFound = fileNotFound();
|
||||
const fn = vi.fn().mockRejectedValue(notFound);
|
||||
const requireLogin = vi.fn();
|
||||
const refreshUser = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
await expect(withLogin(fn, requireLogin, refreshUser)).rejects.toBe(notFound);
|
||||
expect(requireLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Abbruch im Dialog: LoginCancelled, fn wird nicht erneut aufgerufen', async () => {
|
||||
const fn = vi.fn().mockRejectedValue(rowNotFound());
|
||||
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(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('ein zweites 404 nach der Anmeldung wird durchgereicht (kein dritter Versuch)', async () => {
|
||||
const second = rowNotFound();
|
||||
const fn = vi.fn().mockRejectedValueOnce(rowNotFound()).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('ein anderes 404 (z. B. falscher Pfad) löst keinen Dialog aus', async () => {
|
||||
const other = Object.assign(new Error('Route not found'), { code: 404, type: 'general_route_not_found' });
|
||||
const fn = vi.fn().mockRejectedValue(other);
|
||||
const refreshUser = vi.fn();
|
||||
await expect(withLogin(fn, vi.fn(), refreshUser)).rejects.toBe(other);
|
||||
expect(refreshUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// Führt eine Kartenanfrage aus und meldet bei abgelaufener Sitzung (401) einmal neu an.
|
||||
// Führt eine Kartenanfrage aus und meldet bei abgelaufener Sitzung (401, oder 404 für Gäste) einmal neu an.
|
||||
import { getCurrentUser } from './auth';
|
||||
import { isUnauthorized } from './errors';
|
||||
import { errorType, isUnauthorized } from './errors';
|
||||
import { requireLogin } from './login-dialog';
|
||||
|
||||
/** Der Nutzer hat den Login-Dialog geschlossen, ohne sich anzumelden. */
|
||||
@@ -11,9 +11,20 @@ export class LoginCancelled extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appwrite beantwortet Lesezugriffe von Gästen auf Zeilen und Dateien mit Zeilen-/Dateirechten nicht mit 401,
|
||||
* sondern mit 404 (die Daten sind für sie „nicht vorhanden“).
|
||||
*/
|
||||
function isMissingForGuest(err: unknown): boolean {
|
||||
const type = errorType(err);
|
||||
return type === 'row_not_found' || type === 'storage_file_not_found';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Dasselbe gilt für „Zeile/Datei nicht gefunden“, wenn die Prüfung ergibt, dass gar keine Sitzung mehr besteht;
|
||||
* ist der Nutzer noch angemeldet (oder die Prüfung scheitert), ist es ein echtes „nicht gefunden“ und geht durch.
|
||||
* Schließt der Nutzer den Dialog, wirft das `LoginCancelled`; jeder andere Fehler geht unverändert durch.
|
||||
*/
|
||||
export async function withLogin<T>(
|
||||
@@ -24,12 +35,23 @@ export async function withLogin<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 (isUnauthorized(err)) {
|
||||
try {
|
||||
await refreshUser();
|
||||
} catch {
|
||||
// Ein Netzfehler hier darf die Anmeldung nicht verhindern; der Login-Dialog setzt den Nutzer ohnehin neu.
|
||||
}
|
||||
} else if (isMissingForGuest(err)) {
|
||||
let user: unknown;
|
||||
try {
|
||||
user = await refreshUser();
|
||||
} catch {
|
||||
throw err; // ohne Gewissheit über die Sitzung bleibt es beim ursprünglichen Fehler
|
||||
}
|
||||
if (user) throw err;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
if (!(await requireLoginFn())) throw new LoginCancelled();
|
||||
return fn();
|
||||
|
||||
@@ -93,6 +93,7 @@ const fileInput = () => root.querySelector<HTMLInputElement>('input[type="file"]
|
||||
const errorText = () => root.querySelector('.msg-error')!.textContent;
|
||||
const warningText = () => root.querySelector('.msg-warning')!.textContent;
|
||||
const noticeText = () => root.querySelector('.msg-notice')!.textContent;
|
||||
const discardDialogEl = () => document.querySelector<HTMLDialogElement>('dialog.confirm-dialog');
|
||||
|
||||
/** Der Editor mit einem gewählten Bild, bereit zum Speichern. */
|
||||
function withImage(blob = png(), img = image()): { blob: Blob; img: CardImage } {
|
||||
@@ -365,9 +366,9 @@ describe('Karte öffnen (/?karte=<id>)', () => {
|
||||
expect(mocks.decodeImage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Karte nicht gefunden: Meldung und leerer Editor', async () => {
|
||||
it('Karte nicht gefunden (angemeldet): Meldung, ein leerer Editor wird zurückgesetzt', async () => {
|
||||
open('gibtsnicht');
|
||||
store.set({ image: image(), imageBlob: png(), cardName: 'Altlast' });
|
||||
store.set({ config: { ...store.get().config, template: 'minimal' }, cardName: 'Altlast' });
|
||||
mocks.get.mockRejectedValue(Object.assign(new Error('nope'), { code: 404, type: 'row_not_found' }));
|
||||
mount();
|
||||
await vi.waitFor(() =>
|
||||
@@ -375,28 +376,230 @@ describe('Karte öffnen (/?karte=<id>)', () => {
|
||||
);
|
||||
expect(store.get()).toEqual(initialEditorState());
|
||||
expect(noticeText()).toBe('');
|
||||
expect(mocks.requireLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Anmeldung abgebrochen: Hinweis, Editor unverändert', async () => {
|
||||
open('k1');
|
||||
mocks.get.mockRejectedValue(Object.assign(new Error('Unauthorized'), { code: 401 }));
|
||||
mocks.requireLogin.mockResolvedValue(false);
|
||||
it('Karte nicht gefunden (angemeldet) bei gefülltem Editor: nur die Meldung, der Editor bleibt', async () => {
|
||||
open('gibtsnicht');
|
||||
const { blob, img } = withImage();
|
||||
store.set({ cardName: 'Entwurf' });
|
||||
const before = store.get();
|
||||
mocks.get.mockRejectedValue(Object.assign(new Error('nope'), { code: 404, type: 'row_not_found' }));
|
||||
mount();
|
||||
await vi.waitFor(() => expect(warningText()).toBe('Melde dich an, um diese Karte zu öffnen.'));
|
||||
expect(store.get().saved).toBeNull();
|
||||
await vi.waitFor(() =>
|
||||
expect(errorText()).toBe('Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.'),
|
||||
);
|
||||
expect(store.get()).toBe(before);
|
||||
expect(store.get().imageBlob).toBe(blob);
|
||||
expect(store.get().image).toBe(img);
|
||||
expect(store.get().cardName).toBe('Entwurf');
|
||||
expect(noticeText()).toBe('');
|
||||
expect(discardDialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('Sitzung abgelaufen, Anmeldung klappt: Karte wird danach geöffnet', async () => {
|
||||
open('k1');
|
||||
mocks.get
|
||||
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { code: 401 }))
|
||||
.mockResolvedValueOnce(savedCard());
|
||||
mocks.loadImage.mockResolvedValue(png());
|
||||
mocks.decodeImage.mockResolvedValue(image());
|
||||
it('Karte nicht gefunden bei gefülltem Editor mit gespeicherter Karte: auch hier bleibt alles', async () => {
|
||||
open('gibtsnicht');
|
||||
withImage();
|
||||
store.set({
|
||||
saved: { id: 'k9', name: 'Neun', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' },
|
||||
imageChanged: false,
|
||||
});
|
||||
const before = store.get();
|
||||
mocks.get.mockRejectedValue(Object.assign(new Error('nope'), { code: 404, type: 'row_not_found' }));
|
||||
mount();
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
expect(mocks.requireLogin).toHaveBeenCalledTimes(1);
|
||||
await vi.waitFor(() => expect(errorText()).not.toBe(''));
|
||||
expect(store.get()).toBe(before);
|
||||
});
|
||||
|
||||
describe('ohne Sitzung (Appwrite antwortet Gästen mit 404, nicht 401)', () => {
|
||||
it('Gast: zuerst anmelden, dann erst laden; danach wird die Karte geöffnet', async () => {
|
||||
open('k1');
|
||||
const order: string[] = [];
|
||||
mocks.getCurrentUser.mockImplementation(async () => {
|
||||
order.push('user');
|
||||
return null;
|
||||
});
|
||||
mocks.requireLogin.mockImplementation(async () => {
|
||||
order.push('login');
|
||||
return true;
|
||||
});
|
||||
mocks.get.mockImplementation(async () => {
|
||||
order.push('get');
|
||||
return savedCard();
|
||||
});
|
||||
mocks.loadImage.mockResolvedValue(png());
|
||||
mocks.decodeImage.mockResolvedValue(image());
|
||||
mount();
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
expect(order).toEqual(['user', 'login', 'get']);
|
||||
expect(mocks.requireLogin).toHaveBeenCalledWith('login');
|
||||
expect(errorText()).toBe('');
|
||||
});
|
||||
|
||||
it('Gast bricht den Dialog ab: Hinweis, nichts wird geladen, der Editor bleibt unberührt', async () => {
|
||||
open('k1');
|
||||
const { blob } = withImage();
|
||||
const before = store.get();
|
||||
mocks.getCurrentUser.mockResolvedValue(null);
|
||||
mocks.requireLogin.mockResolvedValue(false);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(warningText()).toBe('Melde dich an, um diese Karte zu öffnen.'));
|
||||
expect(mocks.get).not.toHaveBeenCalled();
|
||||
expect(store.get()).toBe(before);
|
||||
expect(store.get().imageBlob).toBe(blob);
|
||||
expect(noticeText()).toBe('');
|
||||
expect(errorText()).toBe('');
|
||||
});
|
||||
|
||||
it('Sitzung läuft mitten im Laden ab (404 row_not_found, Nutzerprüfung ergibt null): anmelden und einmal wiederholen', async () => {
|
||||
open('k1');
|
||||
mocks.getCurrentUser.mockResolvedValueOnce(user).mockResolvedValue(null);
|
||||
mocks.get
|
||||
.mockRejectedValueOnce(Object.assign(new Error('Row not found'), { code: 404, type: 'row_not_found' }))
|
||||
.mockResolvedValueOnce(savedCard());
|
||||
mocks.loadImage.mockResolvedValue(png());
|
||||
mocks.decodeImage.mockResolvedValue(image());
|
||||
mount();
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
expect(mocks.requireLogin).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Bilddatei 404 ohne Sitzung: anmelden, das Bild wird danach geladen', async () => {
|
||||
open('k1');
|
||||
mocks.get.mockResolvedValue(savedCard());
|
||||
mocks.getCurrentUser.mockResolvedValueOnce(user).mockResolvedValue(null);
|
||||
mocks.loadImage
|
||||
.mockRejectedValueOnce(Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' }))
|
||||
.mockResolvedValueOnce(png());
|
||||
mocks.decodeImage.mockResolvedValue(image());
|
||||
mount();
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
expect(mocks.requireLogin).toHaveBeenCalledTimes(1);
|
||||
expect(store.get().image).not.toBeNull();
|
||||
expect(warningText()).toBe('');
|
||||
});
|
||||
|
||||
it('angemeldet und Bilddatei wirklich weg: die Karte öffnet ohne Bild, kein Dialog', async () => {
|
||||
open('k1');
|
||||
mocks.get.mockResolvedValue(savedCard());
|
||||
mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
|
||||
mount();
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
expect(mocks.requireLogin).not.toHaveBeenCalled();
|
||||
expect(warningText()).toBe('Das Bild dieser Karte fehlt.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ungespeicherte Änderungen im Editor', () => {
|
||||
const dialogButton = (label: string) =>
|
||||
[...discardDialogEl()!.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement;
|
||||
const loadable = () => {
|
||||
mocks.get.mockResolvedValue(savedCard());
|
||||
mocks.loadImage.mockResolvedValue(png());
|
||||
mocks.decodeImage.mockResolvedValue(image(1200, 800));
|
||||
};
|
||||
|
||||
it('ein neues, nie gespeichertes Bild: Rückfrage; „Verwerfen“ übernimmt die geöffnete Karte', async () => {
|
||||
open('k1');
|
||||
loadable();
|
||||
withImage();
|
||||
mount();
|
||||
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
|
||||
expect(discardDialogEl()!.querySelector('h2')!.textContent).toBe('Ungespeicherte Änderungen verwerfen?');
|
||||
// Solange gefragt wird, bleibt der Entwurf.
|
||||
expect(store.get().saved).toBeNull();
|
||||
expect(store.get().imageChanged).toBe(true);
|
||||
dialogButton('Verwerfen').click();
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
expect(store.get().imageChanged).toBe(false);
|
||||
expect(store.get().cardName).toBe('Urlaub');
|
||||
expect(discardDialogEl()).toBeNull();
|
||||
expect(noticeText()).toBe('');
|
||||
});
|
||||
|
||||
it('„Abbrechen“: Entwurf bleibt, ?karte= verschwindet aus der Adresse, die Karte wird nicht übernommen', async () => {
|
||||
open('k1');
|
||||
loadable();
|
||||
const { blob, img } = withImage();
|
||||
const before = store.get();
|
||||
mount();
|
||||
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
|
||||
dialogButton('Abbrechen').click();
|
||||
await vi.waitFor(() => expect(discardDialogEl()).toBeNull());
|
||||
await vi.waitFor(() => expect(noticeText()).toBe(''));
|
||||
expect(store.get()).toBe(before);
|
||||
expect(store.get().imageBlob).toBe(blob);
|
||||
expect(store.get().image).toBe(img);
|
||||
expect(location.pathname + location.search).toBe('/');
|
||||
});
|
||||
|
||||
it('Esc im Dialog verwirft nichts', async () => {
|
||||
open('k1');
|
||||
loadable();
|
||||
withImage();
|
||||
const before = store.get();
|
||||
mount();
|
||||
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
|
||||
discardDialogEl()!.dispatchEvent(new Event('close'));
|
||||
await vi.waitFor(() => expect(discardDialogEl()).toBeNull());
|
||||
await vi.waitFor(() => expect(location.search).toBe(''));
|
||||
expect(store.get()).toBe(before);
|
||||
});
|
||||
|
||||
it('geänderte Konfiguration einer anderen gespeicherten Karte: ebenfalls Rückfrage', async () => {
|
||||
open('k1');
|
||||
loadable();
|
||||
withImage();
|
||||
const config = store.get().config;
|
||||
store.set({
|
||||
saved: { id: 'k7', name: 'Sieben', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' },
|
||||
savedConfig: config,
|
||||
imageChanged: false,
|
||||
config: { ...config, template: 'minimal' },
|
||||
});
|
||||
mount();
|
||||
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
|
||||
dialogButton('Verwerfen').click();
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
});
|
||||
|
||||
it('eine andere gespeicherte Karte ohne Änderungen wird ohne Rückfrage ersetzt', async () => {
|
||||
open('k1');
|
||||
loadable();
|
||||
withImage();
|
||||
const config = store.get().config;
|
||||
store.set({
|
||||
saved: { id: 'k7', name: 'Sieben', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' },
|
||||
savedConfig: config,
|
||||
imageChanged: false,
|
||||
});
|
||||
mount();
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
expect(discardDialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('ein leerer Editor wird ohne Rückfrage befüllt', async () => {
|
||||
open('k1');
|
||||
loadable();
|
||||
mount();
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
expect(discardDialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('verlässt der Nutzer die Seite, während gefragt wird, schließt der Dialog und nichts wird übernommen', async () => {
|
||||
open('k1');
|
||||
loadable();
|
||||
withImage();
|
||||
const before = store.get();
|
||||
mount();
|
||||
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
|
||||
unmount?.();
|
||||
unmount = null;
|
||||
await vi.waitFor(() => expect(discardDialogEl()).toBeNull());
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(store.get()).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
it('anderer Fehler (Netz): Meldung, Editor unverändert', async () => {
|
||||
|
||||
@@ -51,8 +51,14 @@ function hasUnsavedChanges(s: EditorState): boolean {
|
||||
return s.savedConfig !== null && serializeConfig(s.config) !== serializeConfig(s.savedConfig);
|
||||
}
|
||||
|
||||
/** Hält der Editor ein Bild mit Änderungen, die ein Öffnen einer anderen Karte verlieren würde? */
|
||||
function hasUnsavedWork(s: EditorState): boolean {
|
||||
return s.image !== null && hasUnsavedChanges(s);
|
||||
}
|
||||
|
||||
export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
let disposed = false;
|
||||
const disposal = new AbortController(); // schließt eine offene Rückfrage beim Verlassen der Seite
|
||||
|
||||
const patchConfig = (patch: Partial<CardConfig>) => store.set({ config: { ...store.get().config, ...patch } });
|
||||
|
||||
@@ -213,6 +219,12 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
preview.setWarning(null);
|
||||
preview.setNotice(LOADING_CARD);
|
||||
try {
|
||||
// Appwrite beantwortet Gäste mit 404 statt 401: ohne Sitzung zuerst anmelden, statt „nicht gefunden“ zu melden.
|
||||
if (!(await getCurrentUser())) {
|
||||
if (stale()) return;
|
||||
if (!(await requireLogin('login'))) throw new LoginCancelled();
|
||||
if (stale()) return;
|
||||
}
|
||||
const card = await withLogin(() => service.get(id));
|
||||
if (stale()) return;
|
||||
|
||||
@@ -229,6 +241,16 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
const image: CardImage | null = blob ? await decodeImage(blob) : null;
|
||||
if (stale()) return;
|
||||
|
||||
// Die geladene Karte würde einen ungespeicherten Entwurf ersetzen: erst fragen (wie bei „Neue Karte“).
|
||||
if (hasUnsavedWork(store.get())) {
|
||||
const discard = await askToDiscard();
|
||||
if (stale()) return;
|
||||
if (!discard) {
|
||||
dropCardParam();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const config = image ? { ...card.config, crop: clampCrop(image, card.config.crop) } : card.config;
|
||||
store.set({
|
||||
config,
|
||||
@@ -245,8 +267,9 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
if (err instanceof LoginCancelled) {
|
||||
preview.setWarning(LOGIN_TO_OPEN);
|
||||
} else if (errorType(err) === 'row_not_found') {
|
||||
// Die Karte gibt es nicht (für dieses Konto): kein halb übernommener Zustand, sondern ein leerer Editor.
|
||||
store.set(initialEditorState());
|
||||
// Die Karte gibt es nicht (für dieses Konto). Ein leerer Editor wird zurückgesetzt; hält er Arbeit, bleibt sie.
|
||||
const s = store.get();
|
||||
if (s.image === null && s.saved === null) store.set(initialEditorState());
|
||||
preview.setError(errorMessage(err, 'load'));
|
||||
} else {
|
||||
preview.setError(err instanceof Error && err.message === READ_MESSAGE ? READ_MESSAGE : errorMessage(err, 'load'));
|
||||
@@ -256,24 +279,32 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
}
|
||||
}
|
||||
|
||||
// „Neue Karte“: löst den Editor von der gespeicherten Karte, damit ein anderes Bild sie nicht überschreibt.
|
||||
// Rückfrage vor dem Verwerfen von Änderungen („Neue Karte“, Öffnen einer anderen Karte).
|
||||
let askingToDiscard = false;
|
||||
async function startNewCard(): Promise<void> {
|
||||
if (askingToDiscard || store.get().saving || !store.get().saved) return;
|
||||
if (hasUnsavedChanges(store.get())) {
|
||||
askingToDiscard = true;
|
||||
let discard = false;
|
||||
try {
|
||||
discard = await confirmDialog({
|
||||
async function askToDiscard(): Promise<boolean> {
|
||||
askingToDiscard = true;
|
||||
try {
|
||||
return await confirmDialog(
|
||||
{
|
||||
title: 'Ungespeicherte Änderungen verwerfen?',
|
||||
text: 'Alles, was du seit dem letzten Speichern geändert hast, geht verloren.',
|
||||
confirmLabel: 'Verwerfen',
|
||||
});
|
||||
} catch {
|
||||
// Dialog nicht darstellbar: dann lieber nichts verwerfen.
|
||||
} finally {
|
||||
askingToDiscard = false;
|
||||
}
|
||||
},
|
||||
disposal.signal,
|
||||
);
|
||||
} catch {
|
||||
// Dialog nicht darstellbar: dann lieber nichts verwerfen.
|
||||
return false;
|
||||
} finally {
|
||||
askingToDiscard = false;
|
||||
}
|
||||
}
|
||||
|
||||
// „Neue Karte“: löst den Editor von der gespeicherten Karte, damit ein anderes Bild sie nicht überschreibt.
|
||||
async function startNewCard(): Promise<void> {
|
||||
if (askingToDiscard || store.get().saving || !store.get().saved) return;
|
||||
if (hasUnsavedChanges(store.get())) {
|
||||
const discard = await askToDiscard();
|
||||
// Währenddessen kann gespeichert oder die Seite verlassen worden sein.
|
||||
if (!discard || disposed || store.get().saving) return;
|
||||
}
|
||||
@@ -371,6 +402,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
disposal.abort();
|
||||
clearTimeout(flashTimer);
|
||||
offLogout();
|
||||
unsubscribe();
|
||||
|
||||
@@ -279,6 +279,75 @@ describe('Meine Karten: Öffnen und Herunterladen', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Meine Karten: Sitzung weg, Appwrite antwortet mit 404 statt 401', () => {
|
||||
const fileGone = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' });
|
||||
const rowGone = () => Object.assign(new Error('Row not found'), { code: 404, type: 'row_not_found' });
|
||||
/** Beim Laden der Seite ist der Nutzer noch da; danach (Nutzerprüfung nach dem 404) nicht mehr. */
|
||||
const sessionEndsAfterLoad = () => mocks.getCurrentUser.mockResolvedValueOnce(user).mockResolvedValue(null);
|
||||
|
||||
it('Vorschau: erst anmelden, dann wird die Vorschau geladen (statt „Keine Vorschau“)', async () => {
|
||||
sessionEndsAfterLoad();
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||
mocks.loadPreview.mockReset();
|
||||
mocks.loadPreview
|
||||
.mockRejectedValueOnce(fileGone())
|
||||
.mockImplementation(async () => new Blob(['p'], { type: 'image/webp' }));
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull());
|
||||
expect(mocks.requireLogin).toHaveBeenCalledOnce();
|
||||
expect(mocks.loadPreview).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Herunterladen: erst anmelden, dann wird das Bild geladen (statt „Das Bild dieser Karte fehlt.“)', async () => {
|
||||
const png = new Blob(['png'], { type: 'image/png' });
|
||||
const image = { source: {}, w: 3000, h: 2000 };
|
||||
sessionEndsAfterLoad();
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||
mocks.loadImage.mockRejectedValueOnce(fileGone()).mockResolvedValueOnce(new Blob(['i'], { type: 'image/jpeg' }));
|
||||
mocks.decodeImage.mockResolvedValue(image);
|
||||
mocks.loadCardAssets.mockResolvedValue({ image, network: null, brand: null });
|
||||
mocks.exportCard.mockResolvedValue(png);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||
button(tileFor('a'), 'Herunterladen').click();
|
||||
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-alpha.png'));
|
||||
expect(mocks.requireLogin).toHaveBeenCalledOnce();
|
||||
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('Löschen: die Karte gilt nicht als „schon gelöscht“, sondern wird nach dem Anmelden wirklich gelöscht', async () => {
|
||||
sessionEndsAfterLoad();
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
|
||||
mocks.remove.mockRejectedValueOnce(rowGone()).mockResolvedValueOnce(undefined);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
|
||||
await vi.waitFor(() => expect(button(tileFor('b'), 'Löschen').disabled).toBe(false));
|
||||
button(tileFor('b'), 'Löschen').click();
|
||||
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
|
||||
button(dialog()!, 'Löschen').click();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||
expect(mocks.requireLogin).toHaveBeenCalledOnce();
|
||||
expect(mocks.remove).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Löschen, Anmeldung abgebrochen: die Karte bleibt, Hinweis an der Kachel', async () => {
|
||||
sessionEndsAfterLoad();
|
||||
mocks.requireLogin.mockResolvedValue(false);
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||
mocks.remove.mockRejectedValue(rowGone());
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||
await vi.waitFor(() => expect(button(tileFor('a'), 'Löschen').disabled).toBe(false));
|
||||
button(tileFor('a'), 'Löschen').click();
|
||||
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
|
||||
button(dialog()!, 'Löschen').click();
|
||||
await vi.waitFor(() =>
|
||||
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Bitte melde dich an, um fortzufahren.'),
|
||||
);
|
||||
expect(tiles()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Meine Karten: Umbenennen', () => {
|
||||
const startRename = async () => {
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
|
||||
|
||||
Reference in new issue
Block a user