managarten/services/mana-analytics/src/lib/pseudonym.test.ts
Till JS 8b0a943e71 feat(mana-analytics): pseudonym + reactions + public feed + admin
Macht mana-analytics zur Backend-Heimat des Public-Community-Hubs.

- Pseudonym: createDisplayHash(userId, secret) + generateDisplayName()
  produzieren deterministisch "Wachsame Eule #4528" pro User. 100
  Adjektive × 80 Tieren × 10000 Suffixe → ~80M Kombinationen. 7 unit
  tests, 0 PII im Output.
- Schema-Erweiterung user_feedback: display_hash, display_name,
  module_context, parent_id (1-level Threading), reactions jsonb
  (cached emoji→count), score (cached weighted sort).
- feedback_votes ersetzt durch feedback_reactions (Slack-Pattern:
  unique(feedback_id, user_id, emoji), pro User mehrere Emojis möglich).
- Service: createFeedback stempelt display_hash + display_name. Neue
  Methoden getPublicFeed (redacted), getReplies, toggleReaction
  (rebuilt reactions+score). Admin-Methoden adminListAll/adminUpdate
  founder-tier-gated im Route-Layer.
- Routes:
  /api/v1/public/feedback/*  — anonymous reads (kein Auth, kein
                              userId/displayHash/deviceInfo im Output)
  /api/v1/feedback/*         — auth-required Submit/React/Manage,
                              plus :id/replies, :id/react, /admin
- Config: neuer FEEDBACK_PSEUDONYM_SECRET-Env-Var seedet die Hashes;
  Rotation re-keyt nur künftige Pseudonyme, alte Records bleiben stabil.

Migration 0002_public-community-foundation.sql idempotent, lokal +
prod (mana-server) eingespielt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 00:00:35 +02:00

49 lines
1.8 KiB
TypeScript

import { describe, expect, it } from 'bun:test';
import { createDisplayHash, generateDisplayName, __TEST__ } from './pseudonym';
describe('pseudonym', () => {
it('createDisplayHash is deterministic per (userId, secret)', () => {
const a = createDisplayHash('user-123', 'secret');
const b = createDisplayHash('user-123', 'secret');
expect(a).toBe(b);
});
it('createDisplayHash varies per userId', () => {
const a = createDisplayHash('user-1', 'secret');
const b = createDisplayHash('user-2', 'secret');
expect(a).not.toBe(b);
});
it('createDisplayHash varies per secret (rotation safety)', () => {
const a = createDisplayHash('user-1', 'secret-old');
const b = createDisplayHash('user-1', 'secret-new');
expect(a).not.toBe(b);
});
it('generateDisplayName is deterministic per hash', () => {
const hash = createDisplayHash('user-123', 'secret');
expect(generateDisplayName(hash)).toBe(generateDisplayName(hash));
});
it('generateDisplayName format is "{Adj} {Tier} #{4-digit}"', () => {
const hash = createDisplayHash('user-1', 'secret');
const name = generateDisplayName(hash);
expect(name).toMatch(/^[A-ZÄÖÜ][a-zäöüß]+ [A-ZÄÖÜ][a-zäöüß]+ #\d{4}$/);
});
it('uses adjectives + animals from the curated lists', () => {
const hash = createDisplayHash('user-1', 'secret');
const [adj, animal] = generateDisplayName(hash).split(' ');
expect(__TEST__.ADJECTIVES).toContain(adj);
expect(__TEST__.ANIMALS).toContain(animal);
});
it('produces varied names across many users (no obvious clustering)', () => {
const names = new Set<string>();
for (let i = 0; i < 1000; i++) {
names.add(generateDisplayName(createDisplayHash(`user-${i}`, 'secret')));
}
// 1000 users → expect ~1000 unique pseudonyms (collisions allowed but rare).
expect(names.size).toBeGreaterThan(950);
});
});