Backend fuer oeffentliche Community-Fremdprofile (Stufe 2 der Roadmap). handle.ts: - handleToSlug: KochHeld#A23B -> KochHeld-A23B (URL-tauglich, '#' geht nicht im Pfad). - parseHandleSlug: zerlegt am LETZTEN Bindestrich (Username darf Bindestriche enthalten), validiert Username + Discriminator gegen das Alphabet. users.publicProfileByHandle (protectedProcedure): - DATENSCHUTZ: liefert nur bei profileVisible=true, sonst NOT_FOUND (privates und nicht existierendes Profil sind von aussen ununterscheidbar). - Strikte Feld-Whitelist: handle, username, discriminator, bio, image. NIEMALS Art.-9-Daten (Gewicht/Kalorien/Ziele), keine Kontakt-/Auth-Felder. - Aggregat-Punkte gesamt + 30 Tage aus activity_logs.points, plus anonymisierte Aktivitaets-Streak (distinkte aktive Tage der letzten 7). Tests (node:test): 9 Slug-Tests + 6 Endpoint-Tests (sichtbar, Leak-Schutz, NOT_FOUND bei privat/unbekannt/ungueltigem Slug, Auth-Pflicht). Verifiziert: 75 API-Tests gruen, Typecheck 3/3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
151 lines
5.2 KiB
TypeScript
151 lines
5.2 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import {
|
|
BIO_MAX_LENGTH,
|
|
DISCRIMINATOR_ALPHABET,
|
|
DISCRIMINATOR_LENGTH,
|
|
formatHandle,
|
|
generateDiscriminator,
|
|
generateUniqueDiscriminator,
|
|
handleToSlug,
|
|
parseHandleSlug,
|
|
validateUsername,
|
|
} from "../lib/handle";
|
|
|
|
/** Liefert eine deterministische Random-Funktion, die der Reihe nach `values` ausgibt. */
|
|
function seededRandom(values: number[]): () => number {
|
|
let i = 0;
|
|
return () => values[i++ % values.length];
|
|
}
|
|
|
|
test("generateDiscriminator hat die konfigurierte Länge", () => {
|
|
assert.equal(generateDiscriminator().length, DISCRIMINATOR_LENGTH);
|
|
});
|
|
|
|
test("generateDiscriminator nutzt nur Zeichen aus dem Alphabet", () => {
|
|
for (let i = 0; i < 200; i++) {
|
|
for (const ch of generateDiscriminator()) {
|
|
assert.ok(DISCRIMINATOR_ALPHABET.includes(ch), `unerwartetes Zeichen: ${ch}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("Alphabet enthält keine verwechselbaren Zeichen (0 O 1 I L)", () => {
|
|
for (const forbidden of ["0", "O", "1", "I", "L"]) {
|
|
assert.ok(!DISCRIMINATOR_ALPHABET.includes(forbidden), `verboten: ${forbidden}`);
|
|
}
|
|
});
|
|
|
|
test("generateDiscriminator ist deterministisch bei fester Random-Quelle", () => {
|
|
// random()=0 -> immer erstes Zeichen des Alphabets
|
|
assert.equal(
|
|
generateDiscriminator(() => 0),
|
|
DISCRIMINATOR_ALPHABET[0].repeat(DISCRIMINATOR_LENGTH),
|
|
);
|
|
});
|
|
|
|
test("generateUniqueDiscriminator umgeht bereits vergebene Discriminatoren", () => {
|
|
const first = DISCRIMINATOR_ALPHABET[0].repeat(DISCRIMINATOR_LENGTH);
|
|
const second =
|
|
DISCRIMINATOR_ALPHABET[1] + DISCRIMINATOR_ALPHABET[0].repeat(DISCRIMINATOR_LENGTH - 1);
|
|
// Erster Versuch kollidiert (alles idx 0), zweiter Versuch erstes Zeichen = idx 1
|
|
const random = seededRandom([0, 0, 0, 0, 1 / DISCRIMINATOR_ALPHABET.length, 0, 0, 0]);
|
|
assert.equal(generateUniqueDiscriminator([first], random), second);
|
|
});
|
|
|
|
test("generateUniqueDiscriminator ignoriert Groß-/Kleinschreibung", () => {
|
|
const taken = DISCRIMINATOR_ALPHABET[0].repeat(DISCRIMINATOR_LENGTH).toLowerCase();
|
|
const random = seededRandom([0, 0, 0, 0, 1 / DISCRIMINATOR_ALPHABET.length, 0, 0, 0]);
|
|
const result = generateUniqueDiscriminator([taken], random);
|
|
assert.notEqual(result, DISCRIMINATOR_ALPHABET[0].repeat(DISCRIMINATOR_LENGTH));
|
|
});
|
|
|
|
test("generateUniqueDiscriminator wirft, wenn nach maxAttempts kein freier gefunden wird", () => {
|
|
const onlyZero = () => 0; // erzeugt immer denselben Discriminator
|
|
const taken = [DISCRIMINATOR_ALPHABET[0].repeat(DISCRIMINATOR_LENGTH)];
|
|
assert.throws(() => generateUniqueDiscriminator(taken, onlyZero, 10));
|
|
});
|
|
|
|
test("formatHandle baut Username#Discriminator", () => {
|
|
assert.equal(formatHandle("Held", "A23B"), "Held#A23B");
|
|
});
|
|
|
|
test("formatHandle liefert null ohne Username", () => {
|
|
assert.equal(formatHandle(null, "A23B"), null);
|
|
});
|
|
|
|
test("formatHandle liefert null ohne Discriminator", () => {
|
|
assert.equal(formatHandle("Held", null), null);
|
|
});
|
|
|
|
test("validateUsername akzeptiert gültige Namen und trimmt", () => {
|
|
assert.deepEqual(validateUsername(" Held_2 "), { ok: true, value: "Held_2" });
|
|
});
|
|
|
|
test("validateUsername lehnt zu kurze Namen ab", () => {
|
|
assert.equal(validateUsername("ab").ok, false);
|
|
});
|
|
|
|
test("validateUsername lehnt zu lange Namen ab", () => {
|
|
assert.equal(validateUsername("a".repeat(25)).ok, false);
|
|
});
|
|
|
|
test("validateUsername lehnt '#' und Leerzeichen ab", () => {
|
|
assert.equal(validateUsername("Held#1").ok, false);
|
|
assert.equal(validateUsername("Held Held").ok, false);
|
|
});
|
|
|
|
test("validateUsername lehnt Umlaute ab (Handle bleibt URL-tauglich)", () => {
|
|
assert.equal(validateUsername("Hähnchen").ok, false);
|
|
});
|
|
|
|
test("Bio-Limit ist 280", () => {
|
|
assert.equal(BIO_MAX_LENGTH, 280);
|
|
});
|
|
|
|
test("handleToSlug baut Username-Discriminator (URL-tauglich, kein #)", () => {
|
|
assert.equal(handleToSlug("KochHeld", "A23B"), "KochHeld-A23B");
|
|
});
|
|
|
|
test("handleToSlug liefert null ohne vollstaendigen Handle", () => {
|
|
assert.equal(handleToSlug(null, "A23B"), null);
|
|
assert.equal(handleToSlug("KochHeld", null), null);
|
|
});
|
|
|
|
test("parseHandleSlug zerlegt am letzten Bindestrich", () => {
|
|
assert.deepEqual(parseHandleSlug("KochHeld-A23B"), {
|
|
username: "KochHeld",
|
|
discriminator: "A23B",
|
|
});
|
|
});
|
|
|
|
test("parseHandleSlug behaelt Bindestriche im Username", () => {
|
|
assert.deepEqual(parseHandleSlug("Koch-Held-A23B"), {
|
|
username: "Koch-Held",
|
|
discriminator: "A23B",
|
|
});
|
|
});
|
|
|
|
test("parseHandleSlug normalisiert den Discriminator auf Grossschreibung", () => {
|
|
assert.deepEqual(parseHandleSlug("KochHeld-a23b"), {
|
|
username: "KochHeld",
|
|
discriminator: "A23B",
|
|
});
|
|
});
|
|
|
|
test("parseHandleSlug lehnt ungueltige Slugs ab", () => {
|
|
assert.equal(parseHandleSlug("KochHeld"), null); // kein Discriminator
|
|
assert.equal(parseHandleSlug("KochHeld-"), null); // leerer Discriminator
|
|
assert.equal(parseHandleSlug("-A23B"), null); // leerer Username
|
|
assert.equal(parseHandleSlug("KochHeld-A1"), null); // Discriminator zu kurz
|
|
assert.equal(parseHandleSlug("KochHeld-A1IB"), null); // I nicht im Alphabet
|
|
});
|
|
|
|
test("handleToSlug und parseHandleSlug sind invers", () => {
|
|
const slug = handleToSlug("Koch-Held", "A23B");
|
|
assert.ok(slug);
|
|
assert.deepEqual(parseHandleSlug(slug), {
|
|
username: "Koch-Held",
|
|
discriminator: "A23B",
|
|
});
|
|
});
|