mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 22:01:26 +02:00
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>
157 lines
4.7 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|