mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
a842537191
commit
e773e44cdf
7 changed files with 901 additions and 15 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export * from './feedback';
|
||||
export * from './auth-users';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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
|
||||
});
|
||||
|
|
@ -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 user’s 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
|
||||
});
|
||||
157
services/mana-analytics/src/test-helpers/db.ts
Normal file
157
services/mana-analytics/src/test-helpers/db.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue