fix(api): Hochladen hält die Sperre des Logos bis zur zweiten Prüfung

Ab dem Anlegen steht die Zeile öffentlich in der Liste. Ein Herz, eine Meldung oder ein Löschen, das zwischen Anlegen, Festlegen der Dateirechte
und zweiter Prüfung eintraf, lief an der Sperre `logo:<id>` vorbei: entfernte die Prüfung das Logo danach, blieb eine Herz- oder Meldungszeile
ohne Logo zurück.

- logos.js: Der Upload nimmt die Sperre `logo:<Zeilen-ID>` innerhalb der Kontosperre (Reihenfolge Konto, dann Logo; nur das Hochladen nimmt beide,
  nichts kann sich verklemmen) für Anlegen, Festlegen, zweite Prüfung und Rücknahme.
- Tests mit Verzögerung (delayNext): Herz und Meldung, die in dieser Zeit eintreffen, warten und bekommen 404 ohne Reste (Beanstandung und
  Rücknahme nach einem Fehler); ein Löschen wartet, der Upload gelingt und danach ist das fertige Logo weg.
- README: Sperre beim Hochladen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 05:11:06 +00:00
1 parent 738358fcac
commit 6134fd26d7
3 files changed
+129 -50

No files matched your search

+72
View File
@@ -160,6 +160,13 @@ function seedReports(fake, logoId, hashes) {
const sha32 = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
const nonAccountCalls = (fake) => fake.calls.filter((c) => c.path !== '/v1/account');
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`));
/** 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));
assert.ok(condition(), 'der erwartete Zustand wurde nicht erreicht');
}
/** Die Aufrufe zu einem Logo in der Reihenfolge, in der sie ankamen (Datei abrufen, lesen, festlegen, löschen; Zeile anlegen, löschen). */
function logoSteps(fake, fileId) {
@@ -739,6 +746,71 @@ test('Logo hochladen: gleichzeitig dieselbe Datei oder zwei Dateien am Limit, ge
assert.equal(fake.rows('logos').filter((row) => row.ownerId === ME).length, 30);
});
// ---- Hochladen: die Sperre des Logos gilt vom Anlegen der Zeile bis zur zweiten Prüfung ----------------------------
test('Logo hochladen: Herz und Meldung, die nach dem Anlegen der Zeile eintreffen, warten auf die zweite Prüfung und hinterlassen nichts, wenn sie das Logo entfernt', async () => {
const { upload, heart, report, fake } = await setup();
const rowId = rowOf('logo1');
logoFile(fake, 'logo1');
fake.swapContentAfterPermissions('logo1', pngHeader(4000, 4000)); // die zweite Prüfung beanstandet das Logo ...
let reads = 0;
fake.delayNext((c) => c.method === 'GET' && c.path.endsWith('/files/logo1/view') && (reads += 1) === 2, 150); // ... und liest langsam
const uploading = upload(uploadBody());
await until(() => fileReads(fake, 'logo1').length === 2);
assert.ok(fake.row('logos', rowId), 'die Zeile steht schon (öffentlich), die zweite Prüfung läuft noch');
const [up, liked, reported] = await Promise.all([uploading, heart(rowId, true), report(rowId, '203.0.1.9')]);
expectError(up, 400, 'logoFormat');
expectError(liked, 404, 'logoGone', 'Herz');
expectError(reported, 404, 'logoGone', 'Meldung');
assert.equal(fake.rows('logos').length, 0);
assert.equal(fake.rows('logo_likes').length, 0, 'kein Herz ohne Logo');
assert.equal(fake.rows('logo_reports').length, 0, 'keine Meldung ohne Logo');
});
test('Logo hochladen: auch bei der Rücknahme nach einem Fehler (zweites Lesen scheitert) bleibt kein Herz zurück, das in der Zwischenzeit eintraf', async () => {
const { upload, heart, fake } = await setup();
const rowId = rowOf('logo1');
logoFile(fake, 'logo1');
let puts = 0;
let reads = 0;
fake.delayNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo1') && (puts += 1) === 1, 150); // das Festlegen dauert, die Zeile steht schon
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/files/logo1/view') && (reads += 1) === 2, { status: 500 }); // zweite Prüfung scheitert
const uploading = upload(uploadBody());
await until(() => filePuts(fake, 'logo1').length === 1);
assert.ok(fake.row('logos', rowId));
const [up, liked] = await Promise.all([uploading, heart(rowId, true)]);
expectError(up, 502, 'unavailable');
expectError(liked, 404, 'logoGone', 'Herz');
assert.equal(fake.rows('logos').length, 0);
assert.equal(fake.rows('logo_likes').length, 0, 'kein Herz ohne Logo');
assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS, 'die Datei bekam delete(owner) zurück');
});
test('Logo hochladen: ein Löschen, das während der zweiten Prüfung eintrifft, wartet: der Upload gelingt, danach ist das fertige Logo weg', async () => {
const { upload, remove, fake } = await setup();
const rowId = rowOf('logo1');
logoFile(fake, 'logo1');
let reads = 0;
fake.delayNext((c) => c.method === 'GET' && c.path.endsWith('/files/logo1/view') && (reads += 1) === 2, 150);
const done = [];
const uploading = upload(uploadBody()).then((res) => (done.push('upload'), res));
await until(() => fileReads(fake, 'logo1').length === 2);
const deleting = remove(rowId).then((res) => (done.push('delete'), res));
const [up, del] = await Promise.all([uploading, deleting]);
assert.equal(up.status, 201, 'das Löschen nimmt dem Upload nicht mitten in der Prüfung die Datei weg');
assert.deepEqual(up.json, { id: rowId });
assert.equal(del.status, 200);
assert.deepEqual(done, ['upload', 'delete']);
assert.equal(fake.row('logos', rowId), null);
assert.equal(fake.file('logo1'), null);
});
// ---- Herzen -----------------------------------------------------------------------------------------
test('Herz: an, nochmal an (bleibt 1), aus, nochmal aus (bleibt 0); Like-Zeile mit ID logoLikeId und Recht read("user:<ich>")', async () => {