hellth-hub/packages/api/src/trpc/handle.test.ts

152 lines
5.2 KiB
TypeScript
Raw Normal View History

Feature: Community-Profil-Fundament (Handle, Bio, Sichtbarkeit) - Backend Battle.net-Style Handle Username#XXXX als Basis fuer kommende Community-Features (Stufe 2 der Roadmap). Diese Stufe: Schema, Migration, Generator und API. Die UI im Profil-Tab folgt in einem eigenen Commit. Schema (packages/db/src/schema/auth.ts): - Neue Spalten an der user-Tabelle: username, discriminator, bio (alle nullable), profileVisible (notNull, default false = Privacy-by-Default). - uniqueIndex auf (username, discriminator); NULLs distinct, greift also nur bei vollstaendigen Handles. Index user_username_idx auf username fuer die Handle-Erstvergabe-Query. - Migration 0019_community_profile, per drizzle-kit generate erzeugt, driftfrei (zweiter generate: keine Aenderung), drizzle-kit check ok. Noch nicht gegen die DB angewendet. Generator (packages/api/src/lib/handle.ts): - Discriminator aus verwechslungsarmem Alphabet, Kollisions-Retry. - formatHandle, validateUsername. 17 node:test-Unit-Tests. API (packages/api/src/trpc/routers/users.ts): - myProfile um die Felder + abgeleitetes handle erweitert (Feld-Whitelist). - setMyHandle: Discriminator stabil bei Erst-Vergabe, Kollisionspruefung inkl. DB-unique-violation-Fallback (PG 23505). - updateMyCommunityProfile: bio + Sichtbarkeit, strikt userId-gescoped. Verifiziert: Typecheck gruen (db/api/admin), 62 API-Tests gruen, drizzle-kit check ok. Datenmodell-Architekt abgenommen (Index-Auflage erfuellt). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:57:24 +02:00
import assert from "node:assert/strict";
import test from "node:test";
import {
BIO_MAX_LENGTH,
DISCRIMINATOR_ALPHABET,
DISCRIMINATOR_LENGTH,
formatHandle,
generateDiscriminator,
generateUniqueDiscriminator,
handleToSlug,
parseHandleSlug,
Feature: Community-Profil-Fundament (Handle, Bio, Sichtbarkeit) - Backend Battle.net-Style Handle Username#XXXX als Basis fuer kommende Community-Features (Stufe 2 der Roadmap). Diese Stufe: Schema, Migration, Generator und API. Die UI im Profil-Tab folgt in einem eigenen Commit. Schema (packages/db/src/schema/auth.ts): - Neue Spalten an der user-Tabelle: username, discriminator, bio (alle nullable), profileVisible (notNull, default false = Privacy-by-Default). - uniqueIndex auf (username, discriminator); NULLs distinct, greift also nur bei vollstaendigen Handles. Index user_username_idx auf username fuer die Handle-Erstvergabe-Query. - Migration 0019_community_profile, per drizzle-kit generate erzeugt, driftfrei (zweiter generate: keine Aenderung), drizzle-kit check ok. Noch nicht gegen die DB angewendet. Generator (packages/api/src/lib/handle.ts): - Discriminator aus verwechslungsarmem Alphabet, Kollisions-Retry. - formatHandle, validateUsername. 17 node:test-Unit-Tests. API (packages/api/src/trpc/routers/users.ts): - myProfile um die Felder + abgeleitetes handle erweitert (Feld-Whitelist). - setMyHandle: Discriminator stabil bei Erst-Vergabe, Kollisionspruefung inkl. DB-unique-violation-Fallback (PG 23505). - updateMyCommunityProfile: bio + Sichtbarkeit, strikt userId-gescoped. Verifiziert: Typecheck gruen (db/api/admin), 62 API-Tests gruen, drizzle-kit check ok. Datenmodell-Architekt abgenommen (Index-Auflage erfuellt). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:57:24 +02:00
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");
Feature: Community-Profil-Fundament (Handle, Bio, Sichtbarkeit) - Backend Battle.net-Style Handle Username#XXXX als Basis fuer kommende Community-Features (Stufe 2 der Roadmap). Diese Stufe: Schema, Migration, Generator und API. Die UI im Profil-Tab folgt in einem eigenen Commit. Schema (packages/db/src/schema/auth.ts): - Neue Spalten an der user-Tabelle: username, discriminator, bio (alle nullable), profileVisible (notNull, default false = Privacy-by-Default). - uniqueIndex auf (username, discriminator); NULLs distinct, greift also nur bei vollstaendigen Handles. Index user_username_idx auf username fuer die Handle-Erstvergabe-Query. - Migration 0019_community_profile, per drizzle-kit generate erzeugt, driftfrei (zweiter generate: keine Aenderung), drizzle-kit check ok. Noch nicht gegen die DB angewendet. Generator (packages/api/src/lib/handle.ts): - Discriminator aus verwechslungsarmem Alphabet, Kollisions-Retry. - formatHandle, validateUsername. 17 node:test-Unit-Tests. API (packages/api/src/trpc/routers/users.ts): - myProfile um die Felder + abgeleitetes handle erweitert (Feld-Whitelist). - setMyHandle: Discriminator stabil bei Erst-Vergabe, Kollisionspruefung inkl. DB-unique-violation-Fallback (PG 23505). - updateMyCommunityProfile: bio + Sichtbarkeit, strikt userId-gescoped. Verifiziert: Typecheck gruen (db/api/admin), 62 API-Tests gruen, drizzle-kit check ok. Datenmodell-Architekt abgenommen (Index-Auflage erfuellt). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:57:24 +02:00
});
test("formatHandle liefert null ohne Username", () => {
assert.equal(formatHandle(null, "A23B"), null);
Feature: Community-Profil-Fundament (Handle, Bio, Sichtbarkeit) - Backend Battle.net-Style Handle Username#XXXX als Basis fuer kommende Community-Features (Stufe 2 der Roadmap). Diese Stufe: Schema, Migration, Generator und API. Die UI im Profil-Tab folgt in einem eigenen Commit. Schema (packages/db/src/schema/auth.ts): - Neue Spalten an der user-Tabelle: username, discriminator, bio (alle nullable), profileVisible (notNull, default false = Privacy-by-Default). - uniqueIndex auf (username, discriminator); NULLs distinct, greift also nur bei vollstaendigen Handles. Index user_username_idx auf username fuer die Handle-Erstvergabe-Query. - Migration 0019_community_profile, per drizzle-kit generate erzeugt, driftfrei (zweiter generate: keine Aenderung), drizzle-kit check ok. Noch nicht gegen die DB angewendet. Generator (packages/api/src/lib/handle.ts): - Discriminator aus verwechslungsarmem Alphabet, Kollisions-Retry. - formatHandle, validateUsername. 17 node:test-Unit-Tests. API (packages/api/src/trpc/routers/users.ts): - myProfile um die Felder + abgeleitetes handle erweitert (Feld-Whitelist). - setMyHandle: Discriminator stabil bei Erst-Vergabe, Kollisionspruefung inkl. DB-unique-violation-Fallback (PG 23505). - updateMyCommunityProfile: bio + Sichtbarkeit, strikt userId-gescoped. Verifiziert: Typecheck gruen (db/api/admin), 62 API-Tests gruen, drizzle-kit check ok. Datenmodell-Architekt abgenommen (Index-Auflage erfuellt). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:57:24 +02:00
});
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",
});
});