managarten/services/mana-analytics/src/test-helpers/db.ts
Till JS 941df57f77 feat(feedback): rename community-identity columns + settings-section
Letzter "community"-Rest aus dem Feedback-Hub räumt sich auf — DB-Spalten,
Settings-Search-Index, Section-Name und i18n-Keys einheitlich auf
"feedback":

- DB: auth.users.community_show_real_name → feedback_show_real_name,
  community_karma → feedback_karma. Migration unter
  services/mana-auth/sql/009_rename_community_to_feedback.sql (manuell
  via psql, in Drizzle-Schema beider Services nachgezogen).
- mana-auth/me.ts: PATCH /api/v1/me/profile akzeptiert jetzt
  feedbackShowRealName und gibt es im Response zurück.
- mana-analytics: feedback.ts liest authUsers.feedbackShowRealName /
  feedbackKarma, redact() + Karma-Increment + Tests entsprechend.
- Frontend: CommunitySection.svelte → FeedbackIdentitySection.svelte
  (Datei umbenannt, Property-Namen + Toast-Texte aktualisiert,
  HeartHalf-Icon, "Feedback-Identität" als Title).
- searchIndex.ts: CategoryId 'community' → 'feedback', anchor
  'community-identity' → 'feedback-identity'.
- i18n (5 locales): settings.categories.community → .feedback,
  settings.search.community_* → feedback_*. Labels DE/EN/FR/IT/ES
  jeweils auf "Feedback" + "im Feedback-Feed" angepasst.

38/38 Integration-Tests grün, validate:i18n-parity sauber, svelte-check 0.

BREAKING (intern, nicht live): Frontend, das gegen die alten Spalten- /
Property-Namen aus dem PATCH-Response geht, fällt jetzt um. Kein
Production-Risiko da Hub noch nicht öffentlich.

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

157 lines
4.7 KiB
TypeScript

/**
* Integration-test scaffolding for mana-analytics.
*
* Connects to TEST_DATABASE_URL, exposes helpers to seed + clean up
* test data, and patches globalThis.fetch so calls to mana-credits
* are captured locally instead of hitting a real service. The whole
* suite skips itself when TEST_DATABASE_URL is unset so a fresh
* `bun test` doesn't fail in environments without a Postgres.
*/
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';
import { eq, sql } from 'drizzle-orm';
import { randomUUID } from 'crypto';
import { authUsers } from '../db/schema/auth-users';
import {
userFeedback,
feedbackReactions,
feedbackNotifications,
feedbackGrantLog,
} from '../db/schema/feedback';
import * as schema from '../db/schema';
export const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL ?? '';
export const HAVE_TEST_DB = TEST_DATABASE_URL.length > 0;
export type TestDb = ReturnType<typeof drizzle<typeof schema>>;
export function connectTestDb() {
const client = postgres(TEST_DATABASE_URL, { max: 3 });
const db = drizzle(client, { schema });
return { client, db };
}
export interface SeededUser {
id: string;
email: string;
name: string;
}
let seededIds = new Set<string>();
/**
* Insert a fresh row in auth.users for a test, returns the userId.
* Always namespaced with `test-` prefix so a missed cleanup never
* collides with real production data.
*/
export async function seedUser(
db: TestDb,
overrides: Partial<{ name: string; feedbackShowRealName: boolean; feedbackKarma: number }> = {}
): Promise<SeededUser> {
const id = `test-${randomUUID()}`;
const email = `${id}@test.local`;
const name = overrides.name ?? `Test User ${id.slice(5, 10)}`;
// Use a raw SQL insert because the cross-schema authUsers Drizzle
// model only declares the columns mana-analytics READS — auth.users
// has additional NOT NULL columns (email, etc.) we'd otherwise miss.
await db.execute(sql`
INSERT INTO auth.users (id, email, name, feedback_show_real_name, feedback_karma)
VALUES (
${id},
${email},
${name},
${overrides.feedbackShowRealName ?? false},
${overrides.feedbackKarma ?? 0}
)
ON CONFLICT (id) DO NOTHING
`);
seededIds.add(id);
return { id, email, name };
}
/** Read auth.users.feedback_karma for a test user. */
export async function getKarma(db: TestDb, userId: string): Promise<number> {
const [row] = await db
.select({ karma: authUsers.feedbackKarma })
.from(authUsers)
.where(eq(authUsers.id, userId))
.limit(1);
return row?.karma ?? 0;
}
/** Truncate test-namespaced rows after a suite. */
export async function cleanupTestData(db: TestDb): Promise<void> {
if (seededIds.size === 0) return;
const ids = Array.from(seededIds);
// Delete in dependency-aware order.
for (const id of ids) {
await db.delete(feedbackNotifications).where(sql`user_id = ${id}`);
await db.delete(feedbackReactions).where(sql`user_id = ${id}`);
await db.delete(feedbackGrantLog).where(sql`user_id = ${id}`);
await db.delete(userFeedback).where(sql`user_id = ${id}`);
}
for (const id of ids) {
await db.delete(authUsers).where(eq(authUsers.id, id));
}
seededIds.clear();
}
/**
* Replace globalThis.fetch with a recorder. Returns the captured calls
* + a `restore()` to put the original fetch back. The mock returns a
* fixed `{ ok: true, alreadyGranted: false, newBalance: <amount> }`
* response for /credits/grant — enough to keep grantCredits happy.
*/
export interface CreditGrantCall {
userId: string;
amount: number;
reason: string;
referenceId: string;
description?: string;
}
export interface FetchMock {
calls: CreditGrantCall[];
restore: () => void;
makeAlreadyGranted: () => void;
}
export function mockCreditsFetch(): FetchMock {
const original = globalThis.fetch;
const calls: CreditGrantCall[] = [];
let alreadyGrantedNext = false;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
const u = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
if (u.includes('/internal/credits/grant')) {
const body = init?.body ? (typeof init.body === 'string' ? JSON.parse(init.body) : {}) : {};
calls.push(body as CreditGrantCall);
const resp = {
ok: true,
alreadyGranted: alreadyGrantedNext,
newBalance: alreadyGrantedNext ? 0 : (body as CreditGrantCall).amount,
transactionId: `mock-tx-${calls.length}`,
};
alreadyGrantedNext = false;
return new Response(JSON.stringify(resp), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
// Pass-through for non-credits calls.
return original(url, init);
}) as typeof fetch;
return {
calls,
restore: () => {
globalThis.fetch = original;
},
makeAlreadyGranted: () => {
alreadyGrantedNext = true;
},
};
}