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:
ClaudeandClaude Opus 5.5 committed 2026-10-04 11:01:28 +00:00
1 parent 09de06e797
commit 0c51689dfd
7 files changed
+546 -57

No files matched your search

+72 -8
View File
@@ -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);
+28 -8
View File
@@ -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;
}
+79
View File
@@ -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();
});
});
});
+30 -8
View File
@@ -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();