mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 04:41:24 +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 +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