fix(api): Logo löschen blendet das Logo zuerst aus, dann folgen Datei, Herzen, Meldungen und die Zeile

deleteLogo löschte zuerst die Datei und zuletzt die Zeile. Brach es dazwischen ab, blieb eine öffentliche Zeile mit gelöschter Datei stehen: unter
dieser Datei-ID kann jedes Konto im Bucket eine neue Datei anlegen, und alle gespeicherten Karten mit diesem Logo zeigten bis zur Wiederholung deren Bild.

- logos.js: Vor dem Löschen der Datei wird das Logo ausgeblendet wie bei der Admin-Sichtbarkeit (hideLogo: Datei, dann Zeile auf genau die ausgeblendeten
  Rechte mit hidden: true; die Rechte der Zeile bleiben die des Hochladers, auch wenn ein Admin löscht). Ein schon ausgeblendetes Logo überspringt das.
  Danach wie bisher Datei, Herzen, Meldungen, die Zeile zuletzt. Wer nicht löschen darf (403), ändert nichts. Das Zurückziehen von Karten bleibt unverändert.
- Tests: Reihenfolge (Datei und Zeile ausblenden, Datei, Herzen, Meldungen, Zeile); Abbruch nach dem Löschen der Datei bei „Herzen lesen“, „Meldungen lesen“
  und „Zeile löschen“ (Hochlader und Admin): die Zeile bleibt mit genau den ausgeblendeten Rechten, kein Gast liest sie, die Wiederholung vollendet das
  Löschen ohne erneutes Ausblenden; scheitert schon das Ausblenden, ist nichts gelöscht; Fremde und schon ausgeblendete Logos lösen kein Ausblenden aus.
- README und Spec: Reihenfolge beim Löschen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 05:19:57 +00:00
1 parent 7cb1381566
commit af05e5f1f1
4 files changed
+98 -5

No files matched your search

+87
View File
@@ -162,6 +162,9 @@ const nonAccountCalls = (fake) => fake.calls.filter((c) => c.path !== '/v1/accou
const filePuts = (fake, id) => fake.calls.filter((c) => c.method === 'PUT' && c.path.endsWith(`/files/${id}`));
const fileReads = (fake, id) => fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith(`/files/${id}/view`));
/** Kann ein Gast diese Zeile lesen? (Appwrite-Rollen `any` und `guests`.) */
const guestCanRead = (row) => row.$permissions.some((permission) => permission === 'read("any")' || permission === 'read("guests")');
/** Wartet (höchstens etwa 2 s), bis `condition()` stimmt: so fängt ein Test den Moment ab, in dem ein Aufruf bei Appwrite noch läuft. */
async function until(condition) {
for (let i = 0; i < 400 && !condition(); i += 1) await new Promise((resolve) => setTimeout(resolve, 5));
@@ -1190,6 +1193,7 @@ test('Logo löschen durch Admin geht, auch bei ausgeblendetem Logo', async () =>
assert.equal(fake.file(fileOf('logo-1')), null);
assert.equal(fake.rows('logo_reports').length, 0);
assert.equal(logs.filter((line) => line.endsWith('logo logo-1 deleted by admin')).length, 1);
assert.equal(fake.calls.filter((c) => c.method === 'PUT' || c.method === 'PATCH').length, 0, 'ein schon ausgeblendetes Logo wird nicht noch einmal ausgeblendet');
});
test('Logo löschen durch Fremde: 403 forbidden und nichts gelöscht', async () => {
@@ -1201,6 +1205,8 @@ test('Logo löschen durch Fremde: 403 forbidden und nichts gelöscht', async ()
assert.ok(fake.file(fileOf('logo-1')));
assert.equal(fake.rows('logo_likes').length, 1);
assert.equal(fake.calls.filter((c) => c.method === 'DELETE').length, 0);
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")'], 'auch nicht ausgeblendet: wer nicht löschen darf, ändert nichts');
assert.equal(fake.calls.filter((c) => c.method === 'PUT' || c.method === 'PATCH').length, 0);
});
test('Logo löschen: 401 ohne JWT, 404 logoGone bei fehlendem Logo, 400 badLogoId bei ungültiger ID', async () => {
@@ -1222,12 +1228,93 @@ test('Logo löschen ist wiederholbar, wenn die Zeile zuletzt scheitert (die Zeil
assert.ok(fake.row('logos', 'logo-1'), 'Zeile bleibt, bis alles andere weg ist');
assert.equal(fake.file(fileOf('logo-1')), null);
assert.equal(fake.rows('logo_likes').length, 0);
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, HIDDEN_PERMISSIONS, 'der Anker ist unsichtbar');
const retry = await remove('logo-1');
assert.equal(retry.status, 200, 'die Datei fehlt schon, das ist kein Fehler');
assert.equal(fake.row('logos', 'logo-1'), null);
});
test('Logo löschen: zuerst wird das Logo ausgeblendet (Datei, dann Zeile, genau die ausgeblendeten Rechte), danach folgen Datei, Herzen, Meldungen und zuletzt die Zeile', async () => {
const { remove, fake } = await setup();
seedLogo(fake);
seedLikes(fake, 'logo-1', ['u1']);
seedReports(fake, 'logo-1', ['h1']);
const file = fileOf('logo-1');
assert.equal((await remove('logo-1')).status, 200);
const at = (predicate) => fake.calls.findIndex(predicate);
const hideFile = at((c) => c.method === 'PUT' && c.path.endsWith(`/files/${file}`));
const hideRow = at((c) => c.method === 'PATCH' && c.path.endsWith('/tables/logos/rows/logo-1'));
const dropFile = at((c) => c.method === 'DELETE' && c.path.endsWith(`/files/${file}`));
const dropLike = at((c) => c.method === 'DELETE' && c.path.includes('/tables/logo_likes/rows/'));
const dropReport = at((c) => c.method === 'DELETE' && c.path.includes('/tables/logo_reports/rows/'));
const dropRow = at((c) => c.method === 'DELETE' && c.path.endsWith('/tables/logos/rows/logo-1'));
const order = [hideFile, hideRow, dropFile, dropLike, dropReport, dropRow];
assert.ok(order.every((index) => index !== -1), `jeder Schritt kam vor: ${order}`);
assert.deepEqual(order, [...order].sort((a, b) => a - b), 'Reihenfolge: Datei ausblenden, Zeile ausblenden, Datei, Herzen, Meldungen, Zeile löschen');
assert.deepEqual(fake.calls[hideFile].body, { permissions: HIDDEN_PERMISSIONS });
assert.deepEqual(fake.calls[hideRow].body, { data: { hidden: true }, permissions: HIDDEN_PERMISSIONS });
assert.equal(fake.row('logos', 'logo-1'), null);
assert.equal(fake.file(file), null);
});
test('Logo löschen: bricht es ab, nachdem die Datei gelöscht ist, bleibt höchstens eine unsichtbare Zeile (genau die ausgeblendeten Rechte des Hochladers), die Wiederholung vollendet es', async () => {
const failures = [
['Herzen lesen', (c) => c.method === 'GET' && c.path.endsWith('/tables/logo_likes/rows')],
['Meldungen lesen', (c) => c.method === 'GET' && c.path.endsWith('/tables/logo_reports/rows')],
['Zeile löschen', (c) => c.method === 'DELETE' && c.path.includes('/tables/logos/rows/')],
];
// der Hochlader löscht sein Logo, und ein Admin löscht das Logo eines anderen (die Rechte der Zeile bleiben die des Hochladers)
for (const [who, owner, jwt] of [['Hochlader', ME, JWT_ME], ['Admin', OTHER, JWT_ADMIN]]) {
for (const [step, failing] of failures) {
const label = `${who}, Abbruch bei „${step}“`;
const { remove, fake } = await setup();
seedLogo(fake, { owner });
seedLikes(fake, 'logo-1', ['u1', 'u2']);
seedReports(fake, 'logo-1', ['h1']);
assert.equal(guestCanRead(fake.row('logos', 'logo-1')), true, `${label}: vorher öffentlich`);
fake.failNext(failing, { status: 500 });
expectError(await remove('logo-1', jwt), 502, 'unavailable', label);
// die Datei ist weg (unter ihrer ID könnte jemand eine neue anlegen), die Zeile steht noch, aber kein Gast liest sie mehr
const row = fake.row('logos', 'logo-1');
assert.equal(fake.file(fileOf('logo-1')), null, `${label}: Datei gelöscht`);
assert.ok(row, `${label}: die Zeile bleibt als Anker`);
assert.equal(row.hidden, true, `${label}: hidden`);
assert.deepEqual(row.$permissions, hiddenRowPermissions(owner), `${label}: genau die ausgeblendeten Rechte`);
assert.equal(guestCanRead(row), false, `${label}: kein Gast liest die Zeile`);
// die Wiederholung vollendet das Löschen, ohne noch einmal auszublenden
const seen = fake.calls.length;
assert.equal((await remove('logo-1', jwt)).status, 200, `${label}: Wiederholung`);
assert.equal(fake.row('logos', 'logo-1'), null, `${label}: Zeile weg`);
assert.equal(fake.rows('logo_likes').length, 0, `${label}: Herzen weg`);
assert.equal(fake.rows('logo_reports').length, 0, `${label}: Meldungen weg`);
assert.equal(fake.calls.slice(seen).filter((c) => c.method === 'PUT' || c.method === 'PATCH').length, 0, `${label}: nicht noch einmal ausgeblendet`);
}
}
});
test('Logo löschen: scheitert schon das Ausblenden, ist noch nichts gelöscht (Zeile öffentlich, Datei und Herzen da), die Wiederholung klappt', async () => {
const { remove, fake } = await setup();
seedLogo(fake);
seedLikes(fake, 'logo-1', ['u1']);
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith(`/files/${fileOf('logo-1')}`), { status: 500 });
expectError(await remove('logo-1'), 502, 'unavailable');
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']);
assert.equal(fake.row('logos', 'logo-1').hidden, false);
assert.ok(fake.file(fileOf('logo-1')));
assert.equal(fake.rows('logo_likes').length, 1);
assert.equal(fake.calls.filter((c) => c.method === 'DELETE').length, 0, 'bis hierher wurde nichts gelöscht');
assert.equal((await remove('logo-1')).status, 200);
assert.equal(fake.row('logos', 'logo-1'), null);
assert.equal(fake.file(fileOf('logo-1')), null);
assert.equal(fake.rows('logo_likes').length, 0);
});
// ---- Admin: Sichtbarkeit ------------------------------------------------------------------------------
test('Logo-Sichtbarkeit: Nicht-Admin 403 adminOnly, ohne JWT 401, nichts ändert sich', async () => {