feat(api): Benutzernamen (Profil-Tabelle, /api/profil, Pflicht beim Hochladen und Veröffentlichen)
- Schema: Tabelle `profiles` (Zeilen-ID = Konto-ID, `username` und `usernameKey`
je 20 Zeichen, Unique-Index `username_key` auf `usernameKey`, rowSecurity an,
keine Tabellenrechte) im Setup-Skript.
- Neues Modul `deploy/api/profiles.js`: `POST /api/profil` legt das Profil an
oder ändert es (Zeilenrecht genau read("any"), Sperre je Konto). Muster,
Sperrliste und Schlüssel (`usernameKey`) stehen in `common.js`; vergeben ->
409 `usernameTaken`, auch wenn erst der Unique-Index bei einem Rennen
entscheidet. Dazu die MSG-Texte `usernameInvalid`, `usernameReserved`,
`usernameTaken`, `usernameRequired`.
- `POST /api/logos/hochladen` und `POST /api/veroeffentlichen` antworten 403
`usernameRequired`, wenn das Konto kein Profil hat (`hasProfile`, nach der
Anmeldung und vor allen anderen Prüfungen).
- Fake-Appwrite: Tabelle `profiles` mit Unique-Index (409), `addProfile`,
`holdNext`. API-Dienst besteht jetzt aus sechs Dateien (READMEs).
- Client: die vier API-Texte in `API_MESSAGES` und `en.ts` (Gleichlauf mit MSG).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a65c075004
commit
19c8151c7e
15 files changed
+980
-53
No files matched your search
@@ -3,9 +3,9 @@
|
||||
* AirCard – Appwrite-Schema-Setup (idempotent)
|
||||
*
|
||||
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) die Datenbank `aircard`,
|
||||
* die Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes` und
|
||||
* `logo_reports` (TablesDB, mit Spalten und Indizes) und die Buckets
|
||||
* `card-images`, `gallery-images` und `logo-images` an.
|
||||
* die Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes`,
|
||||
* `logo_reports` und `profiles` (TablesDB, mit Spalten und Indizes) und die
|
||||
* Buckets `card-images`, `gallery-images` und `logo-images` an.
|
||||
* Reines `fetch`, keine Abhängigkeiten, Node 22+.
|
||||
*
|
||||
* Aufruf (vom Repo-Wurzelverzeichnis):
|
||||
@@ -41,7 +41,7 @@ const str = (key, size, required) => ({ key, type: 'string', size, required });
|
||||
|
||||
export const DESIRED = {
|
||||
database: { id: 'aircard', name: 'AirCard' },
|
||||
// Reihenfolge ist Ablauf: cards, gallery, reports, logos, logo_likes, logo_reports (je Tabelle: Spalten, dann Indizes).
|
||||
// Reihenfolge ist Ablauf: cards, gallery, reports, logos, logo_likes, logo_reports, profiles (je Tabelle: Spalten, dann Indizes).
|
||||
tables: [
|
||||
{
|
||||
id: 'cards',
|
||||
@@ -137,6 +137,16 @@ export const DESIRED = {
|
||||
],
|
||||
indexes: [{ key: 'logo', type: 'key', columns: ['logoId'] }],
|
||||
},
|
||||
{
|
||||
// Benutzernamen: eine Zeile je Konto, Zeilen-ID = Konto-ID, öffentlich lesbar (Zeilenrecht read("any") setzt die API); geschrieben wird nur
|
||||
// über die API. `usernameKey` ist der Name klein geschrieben: der Unique-Index macht ihn auch bei gleichzeitigen Anfragen nur einmal vergebbar.
|
||||
id: 'profiles',
|
||||
name: 'Profiles',
|
||||
permissions: [],
|
||||
rowSecurity: true,
|
||||
columns: [str('username', 20, true), str('usernameKey', 20, true)],
|
||||
indexes: [{ key: 'username_key', type: 'unique', columns: ['usernameKey'] }],
|
||||
},
|
||||
],
|
||||
buckets: [
|
||||
{
|
||||
|
||||
@@ -270,9 +270,9 @@ describe('DESIRED', () => {
|
||||
const bucket = (id) => DESIRED.buckets.find((b) => b.id === id);
|
||||
const str = (key, size, required) => ({ key, type: 'string', size, required });
|
||||
|
||||
test('Reihenfolge: cards zuerst, dann gallery, reports, logos, logo_likes, logo_reports; Buckets card-images, gallery-images, logo-images', () => {
|
||||
test('Reihenfolge: cards zuerst, dann gallery, reports, logos, logo_likes, logo_reports, profiles; Buckets card-images, gallery-images, logo-images', () => {
|
||||
assert.deepEqual(DESIRED.database, { id: 'aircard', name: 'AirCard' });
|
||||
assert.deepEqual(DESIRED.tables.map((t) => t.id), ['cards', 'gallery', 'reports', 'logos', 'logo_likes', 'logo_reports']);
|
||||
assert.deepEqual(DESIRED.tables.map((t) => t.id), ['cards', 'gallery', 'reports', 'logos', 'logo_likes', 'logo_reports', 'profiles']);
|
||||
assert.deepEqual(DESIRED.buckets.map((b) => b.id), ['card-images', 'gallery-images', 'logo-images']);
|
||||
});
|
||||
|
||||
@@ -393,6 +393,16 @@ describe('DESIRED', () => {
|
||||
assert.deepEqual(t.indexes, [{ key: 'logo', type: 'key', columns: ['logoId'] }]);
|
||||
});
|
||||
|
||||
test('profiles entspricht Benutzernamen-Spec §3 (Unique-Index username_key auf usernameKey)', () => {
|
||||
const t = table('profiles');
|
||||
assert.equal(t.name, 'Profiles');
|
||||
assert.deepEqual(t.permissions, []);
|
||||
assert.equal(t.rowSecurity, true);
|
||||
assert.deepEqual(t.columns, [str('username', 20, true), str('usernameKey', 20, true)]);
|
||||
// unique: nur so kann bei Gleichzeitigkeit kein Name doppelt vergeben werden
|
||||
assert.deepEqual(t.indexes, [{ key: 'username_key', type: 'unique', columns: ['usernameKey'] }]);
|
||||
});
|
||||
|
||||
test('logo-images entspricht Logo-Spec §8', () => {
|
||||
assert.deepEqual(bucket('logo-images'), {
|
||||
id: 'logo-images',
|
||||
@@ -622,6 +632,19 @@ describe('compareIndex', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareIndex (unique)', () => {
|
||||
const usernameKey = DESIRED.tables.find((t) => t.id === 'profiles').indexes[0];
|
||||
const ok = () => ({ key: 'username_key', type: 'unique', columns: ['usernameKey'], lengths: [], orders: [], status: 'available' });
|
||||
|
||||
test('Unique-Index profiles.username_key: Typ, Spalte und Status werden verglichen', () => {
|
||||
assert.deepEqual(compareIndex(ok(), usernameKey), []);
|
||||
// ein normaler Index verhindert keine doppelten Namen: das muss auffallen
|
||||
assert.deepEqual(compareIndex({ ...ok(), type: 'key' }, usernameKey), ['type ist key, erwartet unique']);
|
||||
assert.deepEqual(compareIndex({ ...ok(), columns: ['username'] }, usernameKey), ['columns ist [username], erwartet [usernameKey]']);
|
||||
assert.deepEqual(compareIndex({ ...ok(), status: 'processing' }, usernameKey), ['status ist processing, erwartet available']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareBucket', () => {
|
||||
const CARD_IMAGES = DESIRED.buckets[0];
|
||||
const ok = () => ({
|
||||
@@ -741,6 +764,10 @@ describe('main gegen Fake-Server', () => {
|
||||
'Spalte logo_reports.reason',
|
||||
'Spalte logo_reports.reporterHash',
|
||||
'Index logo_reports.logo',
|
||||
'Tabelle profiles',
|
||||
'Spalte profiles.username',
|
||||
'Spalte profiles.usernameKey',
|
||||
'Index profiles.username_key',
|
||||
'Bucket card-images',
|
||||
'Bucket gallery-images',
|
||||
'Bucket logo-images',
|
||||
@@ -748,6 +775,8 @@ describe('main gegen Fake-Server', () => {
|
||||
// Erweiterung um die Galerie (gallery, reports, gallery-images) bzw. um die Logos (logos, logo_likes, logo_reports, logo-images)
|
||||
const GALLERY_OBJECTS = EXPECTED_OBJECTS.filter((o) => /^(Tabelle|Spalte|Index) (gallery|reports)(\.|$)|^Bucket gallery-images$/.test(o));
|
||||
const LOGO_OBJECTS = EXPECTED_OBJECTS.filter((o) => /^(Tabelle|Spalte|Index) logo(s|_likes|_reports)(\.|$)|^Bucket logo-images$/.test(o));
|
||||
// Erweiterung um die Benutzernamen (profiles)
|
||||
const PROFILE_OBJECTS = EXPECTED_OBJECTS.filter((o) => /^(Tabelle|Spalte|Index) profiles(\.|$)/.test(o));
|
||||
|
||||
const T = '/v1/tablesdb/aircard/tables';
|
||||
const str = (table, key, size, required) => [`${T}/${table}/columns/string`, { key, size, required }];
|
||||
@@ -805,6 +834,11 @@ describe('main gegen Fake-Server', () => {
|
||||
}],
|
||||
str('logo_reports', 'reporterHash', 64, true),
|
||||
[`${T}/logo_reports/indexes`, { key: 'logo', type: 'key', columns: ['logoId'] }],
|
||||
[T, { tableId: 'profiles', name: 'Profiles', permissions: [], rowSecurity: true }],
|
||||
str('profiles', 'username', 20, true),
|
||||
str('profiles', 'usernameKey', 20, true),
|
||||
// unique: das Skript legt den Index mit dem Typ `unique` an (Appwrite meldet dann 409 statt eine zweite Zeile mit demselben Schlüssel anzulegen)
|
||||
[`${T}/profiles/indexes`, { key: 'username_key', type: 'unique', columns: ['usernameKey'] }],
|
||||
['/v1/storage/buckets', {
|
||||
bucketId: 'card-images', name: 'Card Images', permissions: ['create("users")'], fileSecurity: true,
|
||||
maximumFileSize: 5242880, allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
@@ -822,15 +856,16 @@ describe('main gegen Fake-Server', () => {
|
||||
}],
|
||||
];
|
||||
const isLogoPost = ([path, body]) => /\/tables\/logo/.test(path) || /^logo/.test(body.tableId ?? body.bucketId ?? '');
|
||||
const isProfilePost = ([path, body]) => /\/tables\/profiles(\/|$)/.test(path) || body.tableId === 'profiles';
|
||||
|
||||
test('--check auf leerem Projekt: Exit 1, alle 51 Objekte FEHLT, nichts angelegt', async () => {
|
||||
test('--check auf leerem Projekt: Exit 1, alle 55 Objekte FEHLT, nichts angelegt', async () => {
|
||||
await withFake({}, async (fake) => {
|
||||
const { code, lines } = await run(fake, { check: true });
|
||||
assert.equal(code, 1);
|
||||
assert.equal(EXPECTED_OBJECTS.length, 51);
|
||||
assert.equal(EXPECTED_OBJECTS.length, 55);
|
||||
assert.deepEqual(startsWith(lines, 'FEHLT'), EXPECTED_OBJECTS.map((o) => `FEHLT: ${o}`));
|
||||
assert.equal(startsWith(lines, 'OK').length, 0);
|
||||
assert.ok(lines.includes('UNVOLLSTÄNDIG: 51 fehlend.'), lines.join('\n'));
|
||||
assert.ok(lines.includes('UNVOLLSTÄNDIG: 55 fehlend.'), lines.join('\n'));
|
||||
assert.equal(fake.posts().length, 0);
|
||||
assert.equal(fake.otherWrites().length, 0);
|
||||
});
|
||||
@@ -859,8 +894,8 @@ describe('main gegen Fake-Server', () => {
|
||||
assert.ok(!first.lines.join('\n').includes(KEY));
|
||||
|
||||
// Alle Spalten und Indizes wurden vor dem Ende ausgewartet (Status available)
|
||||
assert.equal(fake.state.columns.size, 31);
|
||||
assert.equal(fake.state.indexes.size, 10);
|
||||
assert.equal(fake.state.columns.size, 33);
|
||||
assert.equal(fake.state.indexes.size, 11);
|
||||
for (const col of fake.state.columns.values()) assert.ok(col.gets >= 2, `Spalte ${col.key} nicht bis available geprüft`);
|
||||
for (const idx of fake.state.indexes.values()) assert.ok(idx.gets >= 2, `Index ${idx.key} nicht bis available geprüft`);
|
||||
|
||||
@@ -868,6 +903,7 @@ describe('main gegen Fake-Server', () => {
|
||||
assert.deepEqual(fake.state.indexPosts.map((p) => `${p.table}.${p.key}`), [
|
||||
'gallery.search_text', 'gallery.owner', 'gallery.hidden', 'reports.gallery',
|
||||
'logos.owner', 'logos.hidden', 'logos.likes', 'logo_likes.logo', 'logo_likes.user_logo', 'logo_reports.logo',
|
||||
'profiles.username_key',
|
||||
]);
|
||||
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable), JSON.stringify(fake.state.indexPosts));
|
||||
|
||||
@@ -920,7 +956,7 @@ describe('main gegen Fake-Server', () => {
|
||||
dropFromFake(fake, { tables: ['logos', 'logo_likes', 'logo_reports'], buckets: ['logo-images'] });
|
||||
const kept = EXPECTED_OBJECTS.filter((o) => !LOGO_OBJECTS.includes(o));
|
||||
assert.equal(LOGO_OBJECTS.length, 23);
|
||||
assert.equal(kept.length, 28);
|
||||
assert.equal(kept.length, 32);
|
||||
const requestsBefore = fake.state.requests.length;
|
||||
const postsBefore = fake.posts().length;
|
||||
|
||||
@@ -947,8 +983,8 @@ describe('main gegen Fake-Server', () => {
|
||||
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable));
|
||||
// Alles Bestehende wurde nur gelesen
|
||||
assert.equal(fake.otherWrites().length, 0);
|
||||
assert.equal(fake.state.columns.size, 31);
|
||||
assert.equal(fake.state.indexes.size, 10);
|
||||
assert.equal(fake.state.columns.size, 33);
|
||||
assert.equal(fake.state.indexes.size, 11);
|
||||
|
||||
// danach ist das Schema vollständig
|
||||
const again = await run(fake, { check: true });
|
||||
@@ -956,6 +992,42 @@ describe('main gegen Fake-Server', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('Bestehendes Schema ohne `profiles` wird um die Tabelle samt Unique-Index ergänzt, alles Bestehende bleibt unberührt', async () => {
|
||||
await withFake({}, async (fake) => {
|
||||
assert.equal((await run(fake)).code, 0);
|
||||
// Stand vor den Benutzernamen nachstellen: alles außer profiles
|
||||
dropFromFake(fake, { tables: ['profiles'] });
|
||||
const kept = EXPECTED_OBJECTS.filter((o) => !PROFILE_OBJECTS.includes(o));
|
||||
assert.equal(PROFILE_OBJECTS.length, 4);
|
||||
assert.equal(kept.length, 51);
|
||||
const requestsBefore = fake.state.requests.length;
|
||||
const postsBefore = fake.posts().length;
|
||||
|
||||
// --check: genau die vier neuen Objekte fehlen, nichts wird angelegt
|
||||
const check = await run(fake, { check: true });
|
||||
assert.equal(check.code, 1);
|
||||
assert.deepEqual(startsWith(check.lines, 'FEHLT'), PROFILE_OBJECTS.map((o) => `FEHLT: ${o}`));
|
||||
assert.equal(startsWith(check.lines, 'ABWEICHUNG').length, 0);
|
||||
assert.deepEqual(startsWith(check.lines, 'OK: ').filter((l) => l !== 'OK: Schema vollständig'), kept.map((o) => `OK: ${o}`));
|
||||
assert.ok(check.lines.includes('UNVOLLSTÄNDIG: 4 fehlend.'), check.lines.join('\n'));
|
||||
assert.equal(fake.posts().length, postsBefore);
|
||||
|
||||
// Anlegen: Tabelle, beide Spalten, dann der Unique-Index (erst wenn die Spalten available sind)
|
||||
const apply = await run(fake);
|
||||
assert.equal(apply.code, 0, apply.lines.join('\n'));
|
||||
assert.deepEqual(startsWith(apply.lines, 'ANGELEGT'), PROFILE_OBJECTS.map((o) => `ANGELEGT: ${o}`));
|
||||
assert.deepEqual(startsWith(apply.lines, 'OK: ').filter((l) => l !== 'OK: Schema vollständig'), kept.map((o) => `OK: ${o}`));
|
||||
const newPosts = fake.state.requests.slice(requestsBefore).filter((r) => r.method === 'POST');
|
||||
assert.deepEqual(newPosts.map((r) => [r.path, r.body]), EXPECTED_POSTS.filter(isProfilePost));
|
||||
assert.deepEqual(newPosts.at(-1).body, { key: 'username_key', type: 'unique', columns: ['usernameKey'] });
|
||||
assert.equal(fake.state.indexes.get('aircard/profiles/username_key').type, 'unique');
|
||||
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable));
|
||||
assert.equal(fake.otherWrites().length, 0);
|
||||
|
||||
assert.equal((await run(fake, { check: true })).code, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Fehlende Indizes bei vorhandener Tabelle: --check meldet FEHLT, Anlegen legt nur sie an', async () => {
|
||||
await withFake({}, async (fake) => {
|
||||
assert.equal((await run(fake)).code, 0);
|
||||
@@ -1118,6 +1190,33 @@ describe('main gegen Fake-Server', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('Abweichende Benutzernamen-Tabelle (Rechte, Länge, Pflicht, Index nur `key` statt `unique`) wird gemeldet, aber nicht verändert (Exit 1)', async () => {
|
||||
await withFake({}, async (fake) => {
|
||||
assert.equal((await run(fake)).code, 0);
|
||||
fake.state.tables.get('aircard/profiles').$permissions = ['read("any")'];
|
||||
fake.state.tables.get('aircard/profiles').rowSecurity = false;
|
||||
fake.state.columns.get('aircard/profiles/username').size = 30;
|
||||
fake.state.columns.get('aircard/profiles/usernameKey').required = false;
|
||||
fake.state.indexes.get('aircard/profiles/username_key').type = 'key';
|
||||
const requestsBefore = fake.state.requests.length;
|
||||
|
||||
for (const check of [true, false]) {
|
||||
const { code, lines } = await run(fake, { check });
|
||||
assert.equal(code, 1);
|
||||
assert.deepEqual(startsWith(lines, 'ABWEICHUNG'), [
|
||||
'ABWEICHUNG: profiles.$permissions ist [read("any")], erwartet []',
|
||||
'ABWEICHUNG: profiles.rowSecurity ist false, erwartet true',
|
||||
'ABWEICHUNG: profiles.username.size ist 30, erwartet 20',
|
||||
'ABWEICHUNG: profiles.usernameKey.required ist false, erwartet true',
|
||||
'ABWEICHUNG: profiles.username_key.type ist key, erwartet unique',
|
||||
]);
|
||||
assert.equal(startsWith(lines, 'ANGELEGT').length, 0);
|
||||
assert.ok(!lines.includes('OK: Schema vollständig'));
|
||||
}
|
||||
assert.ok(fake.state.requests.slice(requestsBefore).every((r) => r.method === 'GET'));
|
||||
});
|
||||
});
|
||||
|
||||
test('Integer-Spalte mit riesigen Standard-min/max der Antwort gilt als in Ordnung', async () => {
|
||||
await withFake({}, async (fake) => {
|
||||
assert.equal((await run(fake)).code, 0);
|
||||
@@ -1303,6 +1402,8 @@ describe('CLI', () => {
|
||||
assert.match(stdout, /FEHLT: Tabelle logos/);
|
||||
assert.match(stdout, /FEHLT: Index logo_likes\.user_logo/);
|
||||
assert.match(stdout, /FEHLT: Tabelle logo_reports/);
|
||||
assert.match(stdout, /FEHLT: Tabelle profiles/);
|
||||
assert.match(stdout, /FEHLT: Index profiles\.username_key/);
|
||||
assert.match(stdout, /FEHLT: Bucket logo-images/);
|
||||
assert.ok(!(stdout + stderr).includes(KEY));
|
||||
assert.equal(fake.posts().length, 0);
|
||||
|
||||
Reference in new issue
Block a user