test(feedback): DB-backed integration tests — credits, karma, notifications

22 neue Integration-Tests gegen real Postgres, plus auth.users-Cross-
Schema-Joins, plus mock-fetch für mana-credits-Calls. Skip-pattern via
TEST_DATABASE_URL — fresh checkout's bun test passes without a DB.

src/test-helpers/db.ts:
- connectTestDb: drizzle + postgres-js mit voller schema-typing
- seedUser: namespaced 'test-${uuid}' userIds, raw-SQL insert in
  auth.users (cross-schema, kennt mana-analytics nicht alle NOT-NULL
  columns)
- cleanupTestData: FK-aware DELETE chain (notifications → reactions
  → grant_log → feedback → users)
- mockCreditsFetch: globalThis.fetch-shim, captures grant-calls,
  configurable alreadyGranted für idempotency tests

src/services/feedback-credits.integration.test.ts (11 tests):
- submit-bonus: 20+ chars happy path, <20 chars skip, replies skip,
  founder-whitelist skip, rate-limit (11. submit blocked),
  alreadyGranted doesn't write log row
- ship-bonus: +500/+25-emoji-eligible matrix, status-flapping doesn't
  double-pay (idempotent referenceId), founder-author skip
- reaction-bonus: self-react skip, multi-emoji-same-user counts once

src/services/feedback-karma.integration.test.ts (5 tests):
- foreign user reacts → author karma +1
- toggle off → karma -1
- self-react → no change
- floor-clamped at 0
- multi-emoji per user counts separately

src/services/feedback-notifications.integration.test.ts (6 tests):
- author-notify on every status transition
- no notify when status unchanged
- admin_response notify on adminResponse edit
- reactioner_bonus notify on completed transition (+25 in body)
- markRead scopes to caller (stranger can't mark)
- markAllRead only touches caller's rows

Plus:
- package.json: test + test:integration scripts
- schema/index.ts: re-export auth-users so drizzle's type inference
  recognizes the cross-schema model
- CLAUDE.md: full rewrite — endpoints (auth + public), env vars,
  test commands, reward-loop side-effects table

All 38 tests green (16 unit + 22 integration) in 2.35s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-27 19:08:29 +02:00
parent a842537191
commit e773e44cdf
7 changed files with 901 additions and 15 deletions

View file

@ -1,29 +1,105 @@
# mana-analytics
Feedback and analytics service. Extracted from mana-auth.
Public-Community-Feedback-Hub. Backend für `@mana/feedback`. Hostet
sowohl die auth-required Submission/React/Admin-Surface als auch die
anonymous Public-Mirror-Endpoints für `community.mana.how` und
`mana.how/community`.
## Port: 3064
## Port: 3064 (prod port via cloudflared tunnel: `community.mana.how`)
## API Endpoints (JWT auth)
## API Endpoints
### Authenticated (`/api/v1/feedback/*`, JWT via JWKS from mana-auth)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/v1/feedback` | Submit feedback |
| GET | `/api/v1/feedback/public` | List public feedback |
| GET | `/api/v1/feedback/me` | My feedback |
| POST | `/api/v1/feedback/:id/vote` | Upvote |
| DELETE | `/api/v1/feedback/:id/vote` | Remove vote |
| DELETE | `/api/v1/feedback/:id` | Delete my feedback |
| POST | `/api/v1/feedback` | Submit feedback (top-level wish or reply via `parentId`). Auto-titles via mana-llm if no `title` given. Stamps `display_hash` + `display_name`. Triggers fire-and-forget +5-Credit-grant via mana-credits when ≥20 chars + not founder + within 24h rate-limit |
| GET | `/api/v1/feedback/public` | Auth-enriched feed: each item carries `myReactions[]` for highlight + `realName` if author opted in |
| GET | `/api/v1/feedback/me` | Items the user authored (across all isPublic states) |
| GET | `/api/v1/feedback/me/reacted` | Items the user reacted on, redacted to PublicFeedbackItem (excludes own) |
| GET | `/api/v1/feedback/me/notifications?unread_only=true&limit=N` | Inbox |
| POST | `/api/v1/feedback/me/notifications/:id/read` | Mark single notification read (scoped to caller) |
| POST | `/api/v1/feedback/me/notifications/read-all` | Mark all caller's unread notifications read |
| GET | `/api/v1/feedback/:id/replies` | 1-level threading replies |
| POST | `/api/v1/feedback/:id/react` | Toggle a single emoji reaction (👍 ❤️ 🚀 🤔 🎉). Increments author karma on +1 / decrements on toggle-off (+0 for self-react) |
| DELETE | `/api/v1/feedback/:id` | Delete own feedback |
| GET | `/api/v1/feedback/admin?…` | List ALL feedback incl. private (founder/admin role only) |
| PATCH | `/api/v1/feedback/admin/:id` | Update status / adminResponse / isPublic. Status-transition triggers: notify-author-always, notify-reactioners-on-completed, +500 + +25 credit-grants on completed |
## Database: `mana_analytics`
### Anonymous Public (`/api/v1/public/feedback/*`, NO auth)
Tables: user_feedback, feedback_votes
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/public/feedback/feed?…` | Top-level public items, redacted (no userId, no realName even if opted-in) |
| GET | `/api/v1/public/feedback/eule/:hash` | Eulen-Profil: alle Posts unter dem display_hash + Karma |
| GET | `/api/v1/public/feedback/:id` | Single item + replies, redacted |
## Database
Lives in `mana_platform.feedback` (alongside the other services' schemas).
Tables:
- `user_feedback` — top-level wishes + replies (parent_id), with cached `reactions jsonb` + `score int` for sort
- `feedback_reactions` — Slack-pattern (feedback_id, user_id, emoji) unique
- `feedback_notifications` — Per-user inbox, ON DELETE CASCADE on user_feedback
- `feedback_grant_log` — Sliding-window rate-limit log (10/user/24h)
The `feedback_category` + `feedback_status` enums live in `public` schema (Drizzle's pgEnum quirk — see repo memory). `auth.users` is JOINed cross-schema for karma + Klarname-opt-in (read-only — mana-auth owns those columns).
Migrations are hand-authored under `drizzle/`:
- `0001_align-feedback-enums.sql` — Status rename + 'praise' + 'onboarding-wish'
- `0002_public-community-foundation.sql` — Pseudonym + reactions + score
- `0003_grant_log_drop_vote_count.sql` — Rate-limit log + drop legacy
- `0004_feedback_notifications.sql` — Inbox table
Apply manually with `psql -f` before next `db:push` (drizzle-kit can't safely do enum-renames or cross-schema CASCADE drops).
## Environment Variables
```env
PORT=3064
DATABASE_URL=postgresql://mana:devpassword@localhost:5432/mana_analytics
MANA_AUTH_URL=http://localhost:3001
MANA_LLM_URL=http://localhost:3025
DATABASE_URL=postgresql://... # mana_platform
MANA_AUTH_URL=http://mana-auth:3001 # JWKS lookup
MANA_LLM_URL=http://mana-llm:3025 # auto-title generation
MANA_CREDITS_URL=http://mana-credits:3002 # internal grant calls (prod port; dev 3061)
MANA_SERVICE_KEY=... # X-Service-Key for /internal/credits/grant
FEEDBACK_PSEUDONYM_SECRET=... # SHA256(userId+secret) → display_hash
FEEDBACK_FOUNDER_USER_IDS=…,… # Comma-separated; bypass +5/+500 grants
CORS_ORIGINS=https://mana.how,https://community.mana.how
```
## Tests
```bash
# Unit-only (pseudonym, redact privacy-boundary, avatar). 16 tests, ~50ms.
bun test
# Full suite incl. DB-backed integration tests against local mana_platform.
# Cleans up via afterAll DELETE — userIds prefixed with `test-`.
pnpm test:integration
# Or with explicit DB:
TEST_DATABASE_URL=postgres://mana:devpassword@localhost:5432/mana_platform bun test
```
Integration tests live next to the service files (`*.integration.test.ts`).
They mock `globalThis.fetch` so calls to mana-credits are captured locally —
no need for that service to be running. The whole integration-suite is
`describe.skip`-gated on `TEST_DATABASE_URL` so a fresh checkout's
`bun test` doesn't fail without a Postgres.
## Reward Loop (Phase 3)
Quick reference for the credit + karma side-effects:
| Trigger | Effect |
|---|---|
| Top-level submission ≥20 chars, non-founder, under rate-limit | +5 Credits, log row in feedback_grant_log |
| Status transition (any) | Author-Notification enqueued |
| Status → 'completed' (fresh) | Author +500 Credits + Notification, each 👍/🚀-Reactioner +25 Credits + Notification (one per user, idempotent via referenceId) |
| AdminResponse edit | Author admin_response-Notification |
| User reacts on someone else's post | Author karma +1 |
| User unreacts | Author karma -1, floor-clamped at 0 |
| User reacts on own post | No karma change (self-promotion guard) |
See [`docs/plans/feedback-rewards-and-identity.md`](../../docs/plans/feedback-rewards-and-identity.md) for the full Phase-3 plan.

View file

@ -7,7 +7,9 @@
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"test": "bun test",
"test:integration": "TEST_DATABASE_URL=\"${TEST_DATABASE_URL:-postgres://mana:devpassword@localhost:5432/mana_platform}\" bun test"
},
"dependencies": {
"@mana/shared-hono": "workspace:*",

View file

@ -1 +1,2 @@
export * from './feedback';
export * from './auth-users';

View file

@ -0,0 +1,318 @@
/**
* Integration tests for the credit-grant flows.
*
* Skipped unless TEST_DATABASE_URL is set. Run via:
* TEST_DATABASE_URL=postgres://… bun test src/services/feedback-credits.integration.test.ts
*
* Hits a real Postgres for cross-schema correctness (auth.users JOIN,
* feedback_grant_log rate-limit window, etc.) but mocks the HTTP call
* to mana-credits so the suite doesn't need that service running.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { eq, sql } from 'drizzle-orm';
import { FeedbackService, REWARD } from './feedback';
import {
HAVE_TEST_DB,
TEST_DATABASE_URL,
connectTestDb,
seedUser,
cleanupTestData,
mockCreditsFetch,
type TestDb,
type FetchMock,
} from '../test-helpers/db';
import { feedbackGrantLog, userFeedback, feedbackReactions } from '../db/schema/feedback';
const maybeDescribe = HAVE_TEST_DB ? describe : describe.skip;
maybeDescribe('feedback service — credit-grant integration', () => {
let client: ReturnType<typeof import('postgres')>;
let db!: TestDb;
let service!: FeedbackService;
let fetchMock!: FetchMock;
beforeAll(() => {
void TEST_DATABASE_URL;
const conn = connectTestDb();
client = conn.client;
db = conn.db;
});
beforeEach(() => {
fetchMock = mockCreditsFetch();
// Each test gets a fresh service instance, founder-whitelist
// configurable per test via re-instantiation.
service = new FeedbackService(
db,
'', // llmUrl unset → auto-title falls back to slice
'test-pseudonym-secret',
'http://mock-credits',
'test-service-key',
new Set<string>()
);
});
afterAll(async () => {
await cleanupTestData(db);
await client.end();
});
describe('submit bonus', () => {
it('grants +5 Credits when a 20+ char top-level wish is submitted', async () => {
const author = await seedUser(db);
await service.createFeedback(author.id, {
appId: 'mana',
feedbackText: 'I would really like X please, this would help me a lot',
});
// Fire-and-forget — give the void-promise a chance to land.
await new Promise((r) => setTimeout(r, 50));
expect(fetchMock.calls).toHaveLength(1);
expect(fetchMock.calls[0].userId).toBe(author.id);
expect(fetchMock.calls[0].amount).toBe(REWARD.submit);
expect(fetchMock.calls[0].reason).toBe('feedback_submit');
});
it('does NOT grant when feedback is too short (<20 chars)', async () => {
const author = await seedUser(db);
await service.createFeedback(author.id, {
appId: 'mana',
feedbackText: 'too short',
});
await new Promise((r) => setTimeout(r, 50));
expect(fetchMock.calls).toHaveLength(0);
});
it('does NOT grant for replies (parentId set)', async () => {
const author = await seedUser(db);
// Create the parent first (top-level).
const parent = await service.createFeedback(author.id, {
appId: 'mana',
feedbackText: 'I would like X please, top-level wish here',
});
await new Promise((r) => setTimeout(r, 50));
fetchMock.calls.length = 0; // clear
// Now post a reply — should not bonus.
await service.createFeedback(author.id, {
appId: 'mana',
feedbackText: 'agreed, this would be helpful for me too',
parentId: parent.id,
});
await new Promise((r) => setTimeout(r, 50));
expect(fetchMock.calls).toHaveLength(0);
});
it('does NOT grant for founder-whitelisted users', async () => {
const founder = await seedUser(db);
const founderService = new FeedbackService(
db,
'',
'test-pseudonym-secret',
'http://mock-credits',
'test-service-key',
new Set([founder.id])
);
await founderService.createFeedback(founder.id, {
appId: 'mana',
feedbackText: 'I am the founder posting a long enough wish here',
});
await new Promise((r) => setTimeout(r, 50));
expect(fetchMock.calls).toHaveLength(0);
});
it('rate-limits at 10 grants per 24h via feedback_grant_log', async () => {
const author = await seedUser(db);
// Pre-fill the grant-log with 10 entries to simulate 10 prior grants.
for (let i = 0; i < 10; i++) {
await db.insert(feedbackGrantLog).values({ userId: author.id, reason: 'feedback_submit' });
}
// 11th submit should NOT trigger a fetch.
await service.createFeedback(author.id, {
appId: 'mana',
feedbackText: 'eleventh wish today should hit the rate limit',
});
await new Promise((r) => setTimeout(r, 50));
expect(fetchMock.calls).toHaveLength(0);
});
it('does not log to grant_log when alreadyGranted is true (idempotent re-attempt)', async () => {
const author = await seedUser(db);
fetchMock.makeAlreadyGranted();
await service.createFeedback(author.id, {
appId: 'mana',
feedbackText: 'first submission of a long wish that landed already',
});
await new Promise((r) => setTimeout(r, 50));
// fetch was called, but the rate-limit-log row should NOT have been
// written because the grant was a no-op (alreadyGranted=true).
const [logCount] = await db
.select({ ct: sql<number>`count(*)::int` })
.from(feedbackGrantLog)
.where(eq(feedbackGrantLog.userId, author.id));
expect(logCount.ct).toBe(0);
});
});
describe('ship bonus', () => {
it('grants +500 to author + +25 to 👍/🚀 reactioners on completed transition', async () => {
const author = await seedUser(db);
const supporterA = await seedUser(db);
const supporterB = await seedUser(db);
const thinker = await seedUser(db);
const wish = await service.createFeedback(author.id, {
appId: 'mana',
feedbackText: 'a substantial wish that several people support strongly',
});
await new Promise((r) => setTimeout(r, 30));
fetchMock.calls.length = 0; // ignore submit grant
// Three supporters react with eligible emojis, one with 🤔 (excluded).
await service.toggleReaction(wish.id, supporterA.id, '👍');
await service.toggleReaction(wish.id, supporterB.id, '🚀');
await service.toggleReaction(wish.id, thinker.id, '🤔');
await service.adminUpdate(wish.id, { status: 'completed' });
await new Promise((r) => setTimeout(r, 100));
const grantsByUser = new Map<string, number>();
for (const c of fetchMock.calls) grantsByUser.set(c.userId, c.amount);
expect(grantsByUser.get(author.id)).toBe(REWARD.shipped);
expect(grantsByUser.get(supporterA.id)).toBe(REWARD.reactionMatch);
expect(grantsByUser.get(supporterB.id)).toBe(REWARD.reactionMatch);
expect(grantsByUser.has(thinker.id)).toBe(false); // 🤔 excluded
});
it('does not double-pay on status flapping (completed → in_progress → completed)', async () => {
const author = await seedUser(db);
const wish = await service.createFeedback(author.id, {
appId: 'mana',
feedbackText: 'a wish that we will toggle through statuses repeatedly',
});
await new Promise((r) => setTimeout(r, 30));
fetchMock.calls.length = 0;
await service.adminUpdate(wish.id, { status: 'completed' });
await new Promise((r) => setTimeout(r, 50));
const firstCallCount = fetchMock.calls.length;
// Flap: back to in_progress, then back to completed.
await service.adminUpdate(wish.id, { status: 'in_progress' });
await service.adminUpdate(wish.id, { status: 'completed' });
await new Promise((r) => setTimeout(r, 50));
// The second 'completed' transition fires the trigger again,
// but the grant is idempotent via referenceId — mock signals
// 'alreadyGranted' on second call, so net author bonus is one.
// The fetch count goes up (the call was made), but the LOG
// stays at one because alreadyGranted=true skips the log row.
expect(fetchMock.calls.length).toBeGreaterThanOrEqual(firstCallCount);
// Verify the second call had the same referenceId as the first
// for the author bonus (idempotency-key shape).
const authorCalls = fetchMock.calls.filter(
(c) => c.userId === author.id && c.reason === 'feedback_shipped'
);
const refIds = new Set(authorCalls.map((c) => c.referenceId));
expect(refIds.size).toBe(1); // same `${id}_shipped` for both
});
it('skips ship bonus for founder-authored wishes', async () => {
const founder = await seedUser(db);
const founderService = new FeedbackService(
db,
'',
'test-pseudonym-secret',
'http://mock-credits',
'test-service-key',
new Set([founder.id])
);
const wish = await founderService.createFeedback(founder.id, {
appId: 'mana',
feedbackText: 'founder-authored wish that ships and should NOT pay back',
});
await new Promise((r) => setTimeout(r, 30));
fetchMock.calls.length = 0;
await founderService.adminUpdate(wish.id, { status: 'completed' });
await new Promise((r) => setTimeout(r, 50));
const founderGrants = fetchMock.calls.filter((c) => c.userId === founder.id);
expect(founderGrants).toHaveLength(0);
});
});
describe('reaction-bonus', () => {
it('does not pay an author who reacted on their own item', async () => {
const author = await seedUser(db);
const supporter = await seedUser(db);
const wish = await service.createFeedback(author.id, {
appId: 'mana',
feedbackText: 'a wish that the author also reacts to themselves yay',
});
await new Promise((r) => setTimeout(r, 30));
await service.toggleReaction(wish.id, author.id, '👍'); // self-react
await service.toggleReaction(wish.id, supporter.id, '🚀');
fetchMock.calls.length = 0;
await service.adminUpdate(wish.id, { status: 'completed' });
await new Promise((r) => setTimeout(r, 100));
// Author gets +500 from the ship bonus, but NOT a separate
// +25 reaction-match (would be double-dipping). Supporter gets +25.
const calls = fetchMock.calls;
const authorReactionCalls = calls.filter(
(c) => c.userId === author.id && c.reason === 'feedback_reaction_match'
);
expect(authorReactionCalls).toHaveLength(0);
const supporterReactionCalls = calls.filter(
(c) => c.userId === supporter.id && c.reason === 'feedback_reaction_match'
);
expect(supporterReactionCalls).toHaveLength(1);
});
it('only pays each reactioner once even if they used multiple eligible emojis', async () => {
const author = await seedUser(db);
const enthusiast = await seedUser(db);
const wish = await service.createFeedback(author.id, {
appId: 'mana',
feedbackText: 'a wish that one supporter reacts to with multiple emojis',
});
await new Promise((r) => setTimeout(r, 30));
await service.toggleReaction(wish.id, enthusiast.id, '👍');
await service.toggleReaction(wish.id, enthusiast.id, '🚀');
fetchMock.calls.length = 0;
await service.adminUpdate(wish.id, { status: 'completed' });
await new Promise((r) => setTimeout(r, 100));
const enthusiastReactionCalls = fetchMock.calls.filter(
(c) => c.userId === enthusiast.id && c.reason === 'feedback_reaction_match'
);
expect(enthusiastReactionCalls).toHaveLength(1);
});
});
void userFeedback; // keep imports tree-shake-safe
void feedbackReactions;
});

View file

@ -0,0 +1,140 @@
/**
* Integration tests for the cross-schema karma flow.
*
* Karma lives on auth.users.community_karma; mana-analytics increments
* it inside toggleReaction. Tests verify the SQL path, the self-react
* skip, and the floor-at-zero clamp.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { FeedbackService } from './feedback';
import {
HAVE_TEST_DB,
connectTestDb,
seedUser,
cleanupTestData,
getKarma,
mockCreditsFetch,
type TestDb,
type FetchMock,
} from '../test-helpers/db';
const maybeDescribe = HAVE_TEST_DB ? describe : describe.skip;
maybeDescribe('feedback service — karma integration', () => {
let client: ReturnType<typeof import('postgres')>;
let db!: TestDb;
let service!: FeedbackService;
let fetchMock!: FetchMock;
beforeAll(() => {
const conn = connectTestDb();
client = conn.client;
db = conn.db;
});
beforeEach(() => {
fetchMock = mockCreditsFetch();
service = new FeedbackService(
db,
'',
'test-pseudonym-secret',
'http://mock-credits',
'test-service-key',
new Set<string>()
);
});
afterAll(async () => {
await cleanupTestData(db);
await client.end();
});
it('increments author karma by 1 when a different user reacts', async () => {
const author = await seedUser(db);
const reactor = await seedUser(db);
const wish = await service.createFeedback(author.id, {
appId: 'mana',
feedbackText: 'a wish that another user will react to with a thumbs up',
});
await new Promise((r) => setTimeout(r, 30));
expect(await getKarma(db, author.id)).toBe(0);
await service.toggleReaction(wish.id, reactor.id, '👍');
expect(await getKarma(db, author.id)).toBe(1);
});
it('decrements karma when the same user un-reacts', async () => {
const author = await seedUser(db);
const reactor = await seedUser(db);
const wish = await service.createFeedback(author.id, {
appId: 'mana',
feedbackText: 'a wish to react and un-react against, twenty plus chars',
});
await new Promise((r) => setTimeout(r, 30));
await service.toggleReaction(wish.id, reactor.id, '👍'); // +1
expect(await getKarma(db, author.id)).toBe(1);
await service.toggleReaction(wish.id, reactor.id, '👍'); // toggle off → -1
expect(await getKarma(db, author.id)).toBe(0);
});
it('does NOT change karma when the author reacts on their own post', async () => {
const author = await seedUser(db);
const wish = await service.createFeedback(author.id, {
appId: 'mana',
feedbackText: 'an item the author reacts to themselves which should not karma-up',
});
await new Promise((r) => setTimeout(r, 30));
await service.toggleReaction(wish.id, author.id, '👍');
expect(await getKarma(db, author.id)).toBe(0);
await service.toggleReaction(wish.id, author.id, '🚀');
expect(await getKarma(db, author.id)).toBe(0);
});
it('floor-clamps at 0 even with concurrent unreact noise', async () => {
const author = await seedUser(db);
const reactor = await seedUser(db);
const wish = await service.createFeedback(author.id, {
appId: 'mana',
feedbackText: 'tests the floor-at-zero clamp on the karma counter please',
});
await new Promise((r) => setTimeout(r, 30));
// Manually push karma to 0 via the service, then attempt an extra
// "unreact" — the row doesn't exist, so the toggle inserts it and
// karma = +1. The floor only matters if our SQL goes negative,
// which we test directly via the GREATEST() guard.
await service.toggleReaction(wish.id, reactor.id, '👍'); // +1
await service.toggleReaction(wish.id, reactor.id, '👍'); // -1 → 0
expect(await getKarma(db, author.id)).toBe(0);
// Re-toggle: should go back to +1, never below 0.
await service.toggleReaction(wish.id, reactor.id, '👍');
expect(await getKarma(db, author.id)).toBe(1);
});
it('counts each emoji separately on the same item', async () => {
const author = await seedUser(db);
const reactor = await seedUser(db);
const wish = await service.createFeedback(author.id, {
appId: 'mana',
feedbackText: 'wish that one user reacts to with multiple distinct emojis',
});
await new Promise((r) => setTimeout(r, 30));
await service.toggleReaction(wish.id, reactor.id, '👍'); // +1
await service.toggleReaction(wish.id, reactor.id, '❤️'); // +1
await service.toggleReaction(wish.id, reactor.id, '🚀'); // +1
expect(await getKarma(db, author.id)).toBe(3);
await service.toggleReaction(wish.id, reactor.id, '🚀'); // -1
expect(await getKarma(db, author.id)).toBe(2);
});
void fetchMock; // mock is set up just to swallow grant-calls
});

View file

@ -0,0 +1,192 @@
/**
* Integration tests for the loop-closure notifications.
*
* Verify that adminUpdate enqueues the right notifications, that
* mark-read scopes correctly to the requesting user, and that
* Reactioner-Bonus notifications land alongside the credit grants.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { eq } from 'drizzle-orm';
import { FeedbackService } from './feedback';
import { feedbackNotifications } from '../db/schema/feedback';
import {
HAVE_TEST_DB,
connectTestDb,
seedUser,
cleanupTestData,
mockCreditsFetch,
type TestDb,
type FetchMock,
} from '../test-helpers/db';
const maybeDescribe = HAVE_TEST_DB ? describe : describe.skip;
maybeDescribe('feedback service — notification integration', () => {
let client: ReturnType<typeof import('postgres')>;
let db!: TestDb;
let service!: FeedbackService;
let fetchMock!: FetchMock;
beforeAll(() => {
const conn = connectTestDb();
client = conn.client;
db = conn.db;
});
beforeEach(() => {
fetchMock = mockCreditsFetch();
service = new FeedbackService(
db,
'',
'test-pseudonym-secret',
'http://mock-credits',
'test-service-key',
new Set<string>()
);
});
afterAll(async () => {
await cleanupTestData(db);
await client.end();
});
it('enqueues an author notification on every status transition', async () => {
const author = await seedUser(db);
const wish = await service.createFeedback(author.id, {
appId: 'mana',
feedbackText: 'a wish to walk through every single status transition with',
});
await new Promise((r) => setTimeout(r, 30));
await service.adminUpdate(wish.id, { status: 'planned' });
await new Promise((r) => setTimeout(r, 30));
await service.adminUpdate(wish.id, { status: 'in_progress' });
await new Promise((r) => setTimeout(r, 30));
await service.adminUpdate(wish.id, { status: 'completed' });
await new Promise((r) => setTimeout(r, 50));
const notifs = await service.getNotifications(author.id);
const kinds = notifs.map((n) => n.kind);
expect(kinds).toContain('status_planned');
expect(kinds).toContain('status_in_progress');
expect(kinds).toContain('status_completed');
});
it('does NOT enqueue when status is unchanged', async () => {
const author = await seedUser(db);
const wish = await service.createFeedback(author.id, {
appId: 'mana',
feedbackText: 'a wish where adminUpdate touches non-status fields only',
});
await new Promise((r) => setTimeout(r, 30));
await service.adminUpdate(wish.id, { isPublic: false });
await new Promise((r) => setTimeout(r, 30));
const notifs = await service.getNotifications(author.id);
expect(notifs.filter((n) => n.kind.startsWith('status_'))).toHaveLength(0);
});
it('enqueues an admin_response notification when adminResponse is added', async () => {
const author = await seedUser(db);
const wish = await service.createFeedback(author.id, {
appId: 'mana',
feedbackText: 'a wish that the admin will respond to with a real reply text',
});
await new Promise((r) => setTimeout(r, 30));
await service.adminUpdate(wish.id, {
adminResponse: 'Danke für den Hinweis — wir schauen uns das an.',
});
await new Promise((r) => setTimeout(r, 30));
const notifs = await service.getNotifications(author.id);
const responseNotifs = notifs.filter((n) => n.kind === 'admin_response');
expect(responseNotifs).toHaveLength(1);
expect(responseNotifs[0].body).toContain('Danke für den Hinweis');
});
it('enqueues a reactioner_bonus notification on completed transition', async () => {
const author = await seedUser(db);
const supporter = await seedUser(db);
const wish = await service.createFeedback(author.id, {
appId: 'mana',
feedbackText: 'a wish that one supporter reacts to and watches it ship',
});
await new Promise((r) => setTimeout(r, 30));
await service.toggleReaction(wish.id, supporter.id, '🚀');
await service.adminUpdate(wish.id, { status: 'completed' });
await new Promise((r) => setTimeout(r, 100));
const supporterNotifs = await service.getNotifications(supporter.id);
const bonusNotifs = supporterNotifs.filter((n) => n.kind === 'reactioner_bonus');
expect(bonusNotifs).toHaveLength(1);
expect(bonusNotifs[0].creditsAwarded).toBe(25);
});
it('markNotificationRead scopes to the requesting user (cannot read someone else)', async () => {
const owner = await seedUser(db);
const stranger = await seedUser(db);
const wish = await service.createFeedback(owner.id, {
appId: 'mana',
feedbackText: 'wish whose notification we will try to read as a stranger',
});
await new Promise((r) => setTimeout(r, 30));
await service.adminUpdate(wish.id, { status: 'planned' });
await new Promise((r) => setTimeout(r, 30));
const ownerNotifs = await service.getNotifications(owner.id);
expect(ownerNotifs).toHaveLength(1);
const notif = ownerNotifs[0];
expect(notif.readAt).toBeNull();
// Stranger tries to mark the owner's notification — must be a no-op.
await service.markNotificationRead(notif.id, stranger.id);
const [unchanged] = await db
.select()
.from(feedbackNotifications)
.where(eq(feedbackNotifications.id, notif.id))
.limit(1);
expect(unchanged.readAt).toBeNull();
// Owner marks it — readAt is set.
await service.markNotificationRead(notif.id, owner.id);
const [marked] = await db
.select()
.from(feedbackNotifications)
.where(eq(feedbackNotifications.id, notif.id))
.limit(1);
expect(marked.readAt).not.toBeNull();
});
it('markAllNotificationsRead only touches the requesting users rows', async () => {
const userA = await seedUser(db);
const userB = await seedUser(db);
const wishA = await service.createFeedback(userA.id, {
appId: 'mana',
feedbackText: 'wish authored by user A that gets a status notification',
});
const wishB = await service.createFeedback(userB.id, {
appId: 'mana',
feedbackText: 'wish authored by user B that gets a status notification',
});
await new Promise((r) => setTimeout(r, 30));
await service.adminUpdate(wishA.id, { status: 'planned' });
await service.adminUpdate(wishB.id, { status: 'planned' });
await new Promise((r) => setTimeout(r, 30));
const result = await service.markAllNotificationsRead(userA.id);
expect(result.count).toBe(1);
const aNotifs = await service.getNotifications(userA.id);
const bNotifs = await service.getNotifications(userB.id);
expect(aNotifs.every((n) => n.readAt)).toBe(true);
expect(bNotifs.every((n) => n.readAt === null)).toBe(true);
});
void fetchMock; // mock swallows grant calls so they don't fail tests
});

View file

@ -0,0 +1,157 @@
/**
* 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; communityShowRealName: boolean; communityKarma: 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, community_show_real_name, community_karma)
VALUES (
${id},
${email},
${name},
${overrides.communityShowRealName ?? false},
${overrides.communityKarma ?? 0}
)
ON CONFLICT (id) DO NOTHING
`);
seededIds.add(id);
return { id, email, name };
}
/** Read auth.users.community_karma for a test user. */
export async function getKarma(db: TestDb, userId: string): Promise<number> {
const [row] = await db
.select({ karma: authUsers.communityKarma })
.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;
},
};
}