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,
|
Feature: Fremdprofil-API (publicProfileByHandle) + Handle-Slug-Helfer
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>
2026-05-30 12:50:50 +02:00
|
|
|
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", () => {
|
Feature: Fremdprofil-API (publicProfileByHandle) + Handle-Slug-Helfer
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>
2026-05-30 12:50:50 +02:00
|
|
|
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", () => {
|
Feature: Fremdprofil-API (publicProfileByHandle) + Handle-Slug-Helfer
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>
2026-05-30 12:50:50 +02:00
|
|
|
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);
|
|
|
|
|
});
|
Feature: Fremdprofil-API (publicProfileByHandle) + Handle-Slug-Helfer
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>
2026-05-30 12:50:50 +02:00
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
});
|
|
|
|
|
});
|