From d775971cbb703a3ac1aee305034c7cd86325d98c Mon Sep 17 00:00:00 2001 From: Ajibose Date: Sun, 30 Aug 2026 18:39:56 +0300 Subject: [PATCH] refactor(events): consolidate wallet event list query logic (#1271) Extract the duplicated "paginated stream events by sender/recipient wallet" query logic from GET /v1/events (events.routes.ts) and GET /v1/users/:publicKey/events (user.controller.ts) into a single listEventsForWallet helper in the new backend/src/repositories/streamEvent.repository.ts. The shared repository also exposes parseEventTypeFilter, resolveEventsPageSize, and resolveEventsOffset, and now supports the union of both endpoints' prior feature sets: comma-separated `type` filtering (previously only on /v1/events) and an optional includeStream flag that embeds the related stream (previously always-on and only on /v1/users/:publicKey/events). Each endpoint keeps its historical includeStream default (false for /v1/events, true for /v1/users/:publicKey/events) so existing consumers see no response-shape change. stream.controller.ts is updated to import DEFAULT_EVENTS_PAGE_SIZE / MAX_EVENTS_PAGE_SIZE from the new repository module instead of from events.routes.ts, where they no longer live. Adds streamEvent.repository.test.ts covering the new helpers, and extends user.controller.test.ts and events-list.test.ts with includeStream/type-filter coverage plus parity tests asserting both endpoints build identical where-clauses via the shared helper. --- backend/src/controllers/stream.controller.ts | 2 +- backend/src/controllers/user.controller.ts | 83 ++++---- .../repositories/streamEvent.repository.ts | 157 ++++++++++++++ backend/src/routes/v1/events.routes.ts | 92 +++------ backend/tests/integration/events-list.test.ts | 106 ++++++++++ backend/tests/streamEvent.repository.test.ts | 192 ++++++++++++++++++ backend/tests/user.controller.test.ts | 47 +++++ 7 files changed, 577 insertions(+), 102 deletions(-) create mode 100644 backend/src/repositories/streamEvent.repository.ts create mode 100644 backend/tests/streamEvent.repository.test.ts diff --git a/backend/src/controllers/stream.controller.ts b/backend/src/controllers/stream.controller.ts index 0ba2a383..4d43ebb2 100644 --- a/backend/src/controllers/stream.controller.ts +++ b/backend/src/controllers/stream.controller.ts @@ -17,7 +17,7 @@ import { parseStreamId } from "../lib/stream-id.js"; import { DEFAULT_EVENTS_PAGE_SIZE, MAX_EVENTS_PAGE_SIZE, -} from "../routes/v1/events.routes.js"; +} from "../repositories/streamEvent.repository.js"; const DEFAULT_STREAM_PAGE_SIZE = 20; const MAX_STREAM_PAGE_SIZE = 100; diff --git a/backend/src/controllers/user.controller.ts b/backend/src/controllers/user.controller.ts index 439db5d8..2dc16b0f 100644 --- a/backend/src/controllers/user.controller.ts +++ b/backend/src/controllers/user.controller.ts @@ -7,9 +7,11 @@ import { } from "../validators/user.validator.js"; import type { AuthenticatedRequest } from "../types/auth.types.js"; import { - DEFAULT_EVENTS_PAGE_SIZE, - MAX_EVENTS_PAGE_SIZE, -} from "../routes/v1/events.routes.js"; + listEventsForWallet, + parseEventTypeFilter, + resolveEventsOffset, + resolveEventsPageSize, +} from "../repositories/streamEvent.repository.js"; import * as exportService from "../services/export.service.js"; /** @@ -128,7 +130,17 @@ export const getUser = async ( }; /** - * Get user events (history) + * Get user events (history) - paginated list of stream events where the + * given wallet was either the sender or recipient. + * + * Query params: + * - type: optional comma-separated list of event types to filter by + * (e.g. "PAUSED,RESUMED"); unknown values are ignored, and a filter + * consisting entirely of unknown values is rejected with 400. + * - limit, offset, page: pagination (see repositories/streamEvent.repository.ts) + * - includeStream: set to "false" to omit the related `stream` object + * from each event (included by default, matching this endpoint's + * historical behavior). */ export const getUserEvents = async ( req: Request, @@ -146,47 +158,38 @@ export const getUserEvents = async ( .json({ error: "Invalid Stellar public key format" }); } - const rawLimit = req.query["limit"]; - const rawOffset = req.query["offset"]; - - const limit = Math.min( - rawLimit && typeof rawLimit === "string" - ? Number.parseInt(rawLimit, 10) || DEFAULT_EVENTS_PAGE_SIZE - : DEFAULT_EVENTS_PAGE_SIZE, - MAX_EVENTS_PAGE_SIZE, - ); - const offset = - rawOffset && typeof rawOffset === "string" - ? Math.max(0, Number.parseInt(rawOffset, 10) || 0) - : 0; - - const whereClause = { - stream: { - OR: [{ sender: publicKey }, { recipient: publicKey }], - }, - }; + const { requested, types } = parseEventTypeFilter(req.query["type"]); + if (requested.length > 0 && types.length === 0) { + return res + .status(400) + .json({ error: "No valid event types in `type` filter" }); + } - const [events, total] = await Promise.all([ - prisma.streamEvent.findMany({ - where: whereClause, - orderBy: { timestamp: "desc" }, - take: limit, - skip: offset, - include: { - stream: true, - }, - }), - prisma.streamEvent.count({ where: whereClause }), - ]); + const limit = resolveEventsPageSize(req.query["limit"]); + const offset = resolveEventsOffset({ + rawOffset: req.query["offset"], + rawPage: req.query["page"], + limit, + }); - const hasMore = offset + events.length < total; + // Preserve this endpoint's historical behavior of always embedding the + // related stream, unless the caller opts out. + const includeStream = req.query["includeStream"] !== "false"; - return res.status(200).json({ - data: events, - total, - hasMore, + const result = await listEventsForWallet({ + address: publicKey, + types, limit, offset, + includeStream, + }); + + return res.status(200).json({ + data: result.events, + total: result.total, + hasMore: result.hasMore, + limit: result.limit, + offset: result.offset, }); } catch (error) { return next(error); diff --git a/backend/src/repositories/streamEvent.repository.ts b/backend/src/repositories/streamEvent.repository.ts new file mode 100644 index 00000000..7817d5a8 --- /dev/null +++ b/backend/src/repositories/streamEvent.repository.ts @@ -0,0 +1,157 @@ +import { prisma } from "../lib/prisma.js"; +import type { Prisma } from "../generated/prisma/index.js"; + +/** + * Shared query logic for "paginated stream events by sender/recipient + * wallet", previously duplicated between: + * - GET /v1/users/:publicKey/events (backend/src/controllers/user.controller.ts) + * - GET /v1/events (backend/src/routes/v1/events.routes.ts) + * + * See issue #1271. + */ + +/** Event types recognized by the `type`/`eventType` filter. */ +export const EVENT_TYPES = new Set([ + "CREATED", + "TOPPED_UP", + "WITHDRAWN", + "CANCELLED", + "COMPLETED", + "PAUSED", + "RESUMED", + "FEE_COLLECTED", + "FEE_CONFIG_UPDATED", + "ADMIN_TRANSFERRED", +]); + +export const MAX_EVENTS_PAGE_SIZE = 200; +export const DEFAULT_EVENTS_PAGE_SIZE = 50; + +/** + * Parse a raw, comma-separated `type` query parameter (e.g. "paused,resumed") + * into the set of recognized, upper-cased event types. + * + * `requested` is every token the caller asked for (trimmed/upper-cased), + * `types` is the subset of those that are recognized. Comparing the two + * lets a caller distinguish "no filter requested" from "a filter was + * requested but none of the values were valid" (the latter is a 400 in + * both existing endpoints). + */ +export function parseEventTypeFilter(rawType: unknown): { + requested: string[]; + types: string[]; +} { + const raw = typeof rawType === "string" ? rawType : ""; + const requested = raw + .split(",") + .map((t) => t.trim().toUpperCase()) + .filter(Boolean); + const types = requested.filter((t) => EVENT_TYPES.has(t)); + return { requested, types }; +} + +/** + * Resolve a validated page size from a raw `limit` query value, clamped to + * [1, MAX_EVENTS_PAGE_SIZE] and falling back to DEFAULT_EVENTS_PAGE_SIZE + * when missing or invalid. + */ +export function resolveEventsPageSize(rawLimit: unknown): number { + const parsed = Number.parseInt( + typeof rawLimit === "string" ? rawLimit : String(rawLimit ?? ""), + 10, + ); + return Number.isFinite(parsed) && parsed > 0 + ? Math.min(parsed, MAX_EVENTS_PAGE_SIZE) + : DEFAULT_EVENTS_PAGE_SIZE; +} + +/** + * Resolve a validated `offset` from raw `offset`/`page` query values. + * An explicit, valid, non-negative `offset` always wins; otherwise a + * 1-based `page` is converted to an offset using `limit`; otherwise 0. + */ +export function resolveEventsOffset(params: { + rawOffset: unknown; + rawPage?: unknown; + limit: number; +}): number { + const { rawOffset, rawPage, limit } = params; + + const hasOffset = + rawOffset !== undefined && rawOffset !== null && rawOffset !== ""; + if (hasOffset) { + const parsedOffset = Number.parseInt( + typeof rawOffset === "string" ? rawOffset : String(rawOffset), + 10, + ); + if (Number.isFinite(parsedOffset) && parsedOffset >= 0) { + return parsedOffset; + } + } + + const parsedPage = Number.parseInt( + typeof rawPage === "string" ? rawPage : String(rawPage ?? ""), + 10, + ); + const page = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1; + return Math.max(0, (page - 1) * limit); +} + +export interface ListEventsForWalletParams { + /** Stellar public key to match as either the stream sender or recipient. */ + address: string; + /** Already-validated, upper-cased event types to filter by (see parseEventTypeFilter). Empty/omitted = no filter. */ + types?: string[]; + /** Page size (already clamped by the caller, e.g. via resolveEventsPageSize). */ + limit: number; + /** Row offset (already resolved by the caller, e.g. via resolveEventsOffset). */ + offset: number; + /** When true, each event includes its related `stream`. Defaults to false. */ + includeStream?: boolean; +} + +export interface ListEventsForWalletResult { + events: unknown[]; + total: number; + limit: number; + offset: number; + hasMore: boolean; +} + +/** + * List stream events where the given wallet was either the sender or the + * recipient of the underlying stream, most recent first. + */ +export async function listEventsForWallet( + params: ListEventsForWalletParams, +): Promise { + const { address, types = [], limit, offset, includeStream = false } = params; + + const where: Prisma.StreamEventWhereInput = { + stream: { + OR: [{ sender: address }, { recipient: address }], + }, + }; + if (types.length > 0) { + where.eventType = { in: types }; + } + + const [events, total] = await Promise.all([ + prisma.streamEvent.findMany({ + where, + orderBy: { timestamp: "desc" }, + skip: offset, + take: limit, + ...(includeStream ? { include: { stream: true } } : {}), + }), + prisma.streamEvent.count({ where }), + ]); + + return { + events, + total, + limit, + offset, + hasMore: offset + events.length < total, + }; +} diff --git a/backend/src/routes/v1/events.routes.ts b/backend/src/routes/v1/events.routes.ts index a29927bb..06ef510a 100644 --- a/backend/src/routes/v1/events.routes.ts +++ b/backend/src/routes/v1/events.routes.ts @@ -4,27 +4,16 @@ import { subscribe } from '../../controllers/sse.controller.js'; import { sseService } from '../../services/sse.service.js'; import { requireAdmin, requireAuth } from '../../middleware/auth.js'; import type { AuthenticatedRequest } from '../../types/auth.types.js'; -import { prisma } from '../../lib/prisma.js'; import logger from '../../logger.js'; +import { + listEventsForWallet, + parseEventTypeFilter, + resolveEventsOffset, + resolveEventsPageSize, +} from '../../repositories/streamEvent.repository.js'; const router = Router(); -const EVENT_TYPES = new Set([ - 'CREATED', - 'TOPPED_UP', - 'WITHDRAWN', - 'CANCELLED', - 'COMPLETED', - 'PAUSED', - 'RESUMED', - 'FEE_COLLECTED', - 'FEE_CONFIG_UPDATED', - 'ADMIN_TRANSFERRED', -]); - -export const MAX_EVENTS_PAGE_SIZE = 200; -export const DEFAULT_EVENTS_PAGE_SIZE = 50; - /** * @openapi * /v1/events: @@ -62,6 +51,11 @@ export const DEFAULT_EVENTS_PAGE_SIZE = 50; * required: false * schema: { type: integer, default: 1 } * description: Optional 1-based page index. Ignored when offset is set. + * - in: query + * name: includeStream + * required: false + * schema: { type: boolean, default: false } + * description: When true, each event includes its related `stream`. * responses: * 200: * description: Paginated event list @@ -81,58 +75,34 @@ router.get('/', requireAuth, async (req: Request, res: Response, next: NextFunct return; } - const rawType = typeof req.query.type === 'string' ? req.query.type : ''; - const requested = rawType - .split(',') - .map((t) => t.trim().toUpperCase()) - .filter(Boolean); - const types = requested.filter((t) => EVENT_TYPES.has(t)); + const { requested, types } = parseEventTypeFilter(req.query.type); if (requested.length > 0 && types.length === 0) { res.status(400).json({ error: 'No valid event types in `type` filter' }); return; } - const parsedLimit = Number.parseInt(String(req.query.limit ?? ''), 10); - const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 - ? Math.min(parsedLimit, MAX_EVENTS_PAGE_SIZE) - : DEFAULT_EVENTS_PAGE_SIZE; - - const hasOffset = req.query.offset !== undefined; - const parsedOffset = Number.parseInt(String(req.query.offset ?? ''), 10); - const parsedPage = Number.parseInt(String(req.query.page ?? ''), 10); - const page = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1; - const offset = hasOffset && Number.isFinite(parsedOffset) && parsedOffset >= 0 - ? parsedOffset - : (page - 1) * limit; - - const where: { - stream: { OR: Array<{ sender: string } | { recipient: string }> }; - eventType?: { in: string[] }; - } = { - stream: { - OR: [{ sender: address }, { recipient: address }], - }, - }; - if (types.length > 0) { - where.eventType = { in: types }; - } - - const [events, total] = await Promise.all([ - prisma.streamEvent.findMany({ - where, - orderBy: { timestamp: 'desc' }, - skip: offset, - take: limit, - }), - prisma.streamEvent.count({ where }), - ]); + const limit = resolveEventsPageSize(req.query.limit); + const offset = resolveEventsOffset({ + rawOffset: req.query.offset, + rawPage: req.query.page, + limit, + }); + const includeStream = req.query.includeStream === 'true'; - res.json({ - events, - total, + const result = await listEventsForWallet({ + address, + types, limit, offset, - hasMore: offset + events.length < total, + includeStream, + }); + + res.json({ + events: result.events, + total: result.total, + limit: result.limit, + offset: result.offset, + hasMore: result.hasMore, }); } catch (err) { logger.error('GET /v1/events failed:', err); diff --git a/backend/tests/integration/events-list.test.ts b/backend/tests/integration/events-list.test.ts index 460710c6..49a315b1 100644 --- a/backend/tests/integration/events-list.test.ts +++ b/backend/tests/integration/events-list.test.ts @@ -176,4 +176,110 @@ describe('GET /v1/events', () => { }; expect(callArgs.skip).toBe(30); }); + + it('does not include the related stream by default', async () => { + mocks.prisma.streamEvent.findMany.mockResolvedValueOnce([]); + mocks.prisma.streamEvent.count.mockResolvedValueOnce(0); + + await request(app) + .get(`/v1/events?address=${ADDR}`) + .set('Authorization', `Bearer ${token}`); + + const callArgs = mocks.prisma.streamEvent.findMany.mock.calls[0]![0] as { + include?: unknown; + }; + expect(callArgs.include).toBeUndefined(); + }); + + it('includes the related stream when includeStream=true', async () => { + mocks.prisma.streamEvent.findMany.mockResolvedValueOnce([]); + mocks.prisma.streamEvent.count.mockResolvedValueOnce(0); + + await request(app) + .get(`/v1/events?address=${ADDR}&includeStream=true`) + .set('Authorization', `Bearer ${token}`); + + const callArgs = mocks.prisma.streamEvent.findMany.mock.calls[0]![0] as { + include?: unknown; + }; + expect(callArgs.include).toEqual({ stream: true }); + }); +}); + +/** + * Feature-parity tests: GET /v1/users/:publicKey/events and GET /v1/events + * both sit on top of the shared listEventsForWallet helper + * (backend/src/repositories/streamEvent.repository.ts) and must therefore + * apply identical sender/recipient and type-filtering logic, even though + * they differ in auth model (unauthenticated + :publicKey param vs. + * requireAuth + session address) and response envelope field name + * (`data` vs `events`). + */ +describe('GET /v1/users/:publicKey/events and GET /v1/events filtering parity', () => { + // GET /v1/users/:publicKey/events validates the Stellar public key format + // (^G[A-Z2-7]{55}$), unlike GET /v1/events (whose `address` comes from an + // already-authenticated session), so parity tests need a well-formed key. + const VALID_ADDR = 'GD2XP6FNWL6IWULVMPNA2RV2T7GLCJHK3RH75GBCY7TSVIWDITJN4FXJ'; + const validAddrNow = Math.floor(Date.now() / 1000); + const validAddrToken = signJwt({ + sub: VALID_ADDR, + iat: validAddrNow, + exp: validAddrNow + 3600, + iss: 'flowfi-api', + aud: 'flowfi-api', + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('apply the same sender/recipient OR where-clause for a given wallet', async () => { + mocks.prisma.streamEvent.findMany.mockResolvedValue([]); + mocks.prisma.streamEvent.count.mockResolvedValue(0); + + await request(app) + .get(`/v1/events?address=${VALID_ADDR}`) + .set('Authorization', `Bearer ${validAddrToken}`); + const eventsWhere = mocks.prisma.streamEvent.findMany.mock.calls[0]![0].where; + + vi.clearAllMocks(); + mocks.prisma.streamEvent.findMany.mockResolvedValue([]); + mocks.prisma.streamEvent.count.mockResolvedValue(0); + + await request(app).get(`/v1/users/${VALID_ADDR}/events`); + const userEventsWhere = mocks.prisma.streamEvent.findMany.mock.calls[0]![0].where; + + expect(userEventsWhere.stream).toEqual(eventsWhere.stream); + }); + + it('apply the same comma-separated `type` filter', async () => { + mocks.prisma.streamEvent.findMany.mockResolvedValue([]); + mocks.prisma.streamEvent.count.mockResolvedValue(0); + + await request(app) + .get(`/v1/events?address=${VALID_ADDR}&type=PAUSED,RESUMED`) + .set('Authorization', `Bearer ${validAddrToken}`); + const eventsWhere = mocks.prisma.streamEvent.findMany.mock.calls[0]![0].where; + + vi.clearAllMocks(); + mocks.prisma.streamEvent.findMany.mockResolvedValue([]); + mocks.prisma.streamEvent.count.mockResolvedValue(0); + + await request(app).get(`/v1/users/${VALID_ADDR}/events?type=PAUSED,RESUMED`); + const userEventsWhere = mocks.prisma.streamEvent.findMany.mock.calls[0]![0].where; + + expect(userEventsWhere.eventType).toEqual(eventsWhere.eventType); + expect(userEventsWhere.eventType).toEqual({ in: ['PAUSED', 'RESUMED'] }); + }); + + it('both reject a type filter with no valid values', async () => { + const eventsRes = await request(app) + .get(`/v1/events?address=${VALID_ADDR}&type=BOGUS`) + .set('Authorization', `Bearer ${validAddrToken}`); + const userEventsRes = await request(app).get(`/v1/users/${VALID_ADDR}/events?type=BOGUS`); + + expect(eventsRes.status).toBe(400); + expect(userEventsRes.status).toBe(400); + expect(mocks.prisma.streamEvent.findMany).not.toHaveBeenCalled(); + }); }); diff --git a/backend/tests/streamEvent.repository.test.ts b/backend/tests/streamEvent.repository.test.ts new file mode 100644 index 00000000..37bcd0b9 --- /dev/null +++ b/backend/tests/streamEvent.repository.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + listEventsForWallet, + parseEventTypeFilter, + resolveEventsOffset, + resolveEventsPageSize, + DEFAULT_EVENTS_PAGE_SIZE, + MAX_EVENTS_PAGE_SIZE, +} from '../src/repositories/streamEvent.repository.js'; +import { prisma } from '../src/lib/prisma.js'; + +vi.mock('../src/lib/prisma.js', () => ({ + prisma: { + streamEvent: { + findMany: vi.fn(), + count: vi.fn(), + }, + }, +})); + +describe('streamEvent.repository', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('parseEventTypeFilter', () => { + it('returns no requested/types for an empty filter', () => { + expect(parseEventTypeFilter(undefined)).toEqual({ requested: [], types: [] }); + expect(parseEventTypeFilter('')).toEqual({ requested: [], types: [] }); + }); + + it('parses a comma-separated, case-insensitive list of valid types', () => { + const result = parseEventTypeFilter('paused, resumed ,Withdrawn'); + expect(result.requested).toEqual(['PAUSED', 'RESUMED', 'WITHDRAWN']); + expect(result.types).toEqual(['PAUSED', 'RESUMED', 'WITHDRAWN']); + }); + + it('drops unknown types from `types` while keeping them in `requested`', () => { + const result = parseEventTypeFilter('PAUSED,BOGUS,RESUMED'); + expect(result.requested).toEqual(['PAUSED', 'BOGUS', 'RESUMED']); + expect(result.types).toEqual(['PAUSED', 'RESUMED']); + }); + + it('returns an empty types list when every requested value is unknown', () => { + const result = parseEventTypeFilter('BOGUS,ALSO_BOGUS'); + expect(result.requested).toEqual(['BOGUS', 'ALSO_BOGUS']); + expect(result.types).toEqual([]); + }); + + it('ignores non-string input', () => { + expect(parseEventTypeFilter(undefined)).toEqual({ requested: [], types: [] }); + expect(parseEventTypeFilter(['PAUSED'])).toEqual({ requested: [], types: [] }); + }); + }); + + describe('resolveEventsPageSize', () => { + it('falls back to the default when limit is missing or invalid', () => { + expect(resolveEventsPageSize(undefined)).toBe(DEFAULT_EVENTS_PAGE_SIZE); + expect(resolveEventsPageSize('not-a-number')).toBe(DEFAULT_EVENTS_PAGE_SIZE); + expect(resolveEventsPageSize('0')).toBe(DEFAULT_EVENTS_PAGE_SIZE); + expect(resolveEventsPageSize('-5')).toBe(DEFAULT_EVENTS_PAGE_SIZE); + }); + + it('uses the requested limit when within bounds', () => { + expect(resolveEventsPageSize('25')).toBe(25); + }); + + it('clamps to MAX_EVENTS_PAGE_SIZE', () => { + expect(resolveEventsPageSize('999999')).toBe(MAX_EVENTS_PAGE_SIZE); + }); + }); + + describe('resolveEventsOffset', () => { + it('defaults to 0 with no offset or page', () => { + expect(resolveEventsOffset({ rawOffset: undefined, limit: 10 })).toBe(0); + }); + + it('uses an explicit non-negative offset', () => { + expect(resolveEventsOffset({ rawOffset: '20', limit: 10 })).toBe(20); + }); + + it('falls back to page-based offset when offset is missing', () => { + expect( + resolveEventsOffset({ rawOffset: undefined, rawPage: '4', limit: 10 }), + ).toBe(30); + }); + + it('falls back to page-based offset when offset is invalid', () => { + expect( + resolveEventsOffset({ rawOffset: '-1', rawPage: '3', limit: 10 }), + ).toBe(20); + }); + }); + + describe('listEventsForWallet', () => { + const ADDR = 'GADDR123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA'; + + it('builds a sender/recipient OR where-clause scoped to the wallet', async () => { + (prisma.streamEvent.findMany as any).mockResolvedValue([]); + (prisma.streamEvent.count as any).mockResolvedValue(0); + + await listEventsForWallet({ address: ADDR, limit: 10, offset: 0 }); + + expect(prisma.streamEvent.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { stream: { OR: [{ sender: ADDR }, { recipient: ADDR }] } }, + orderBy: { timestamp: 'desc' }, + take: 10, + skip: 0, + }), + ); + expect(prisma.streamEvent.count).toHaveBeenCalledWith({ + where: { stream: { OR: [{ sender: ADDR }, { recipient: ADDR }] } }, + }); + }); + + it('adds an eventType `in` filter when types are provided', async () => { + (prisma.streamEvent.findMany as any).mockResolvedValue([]); + (prisma.streamEvent.count as any).mockResolvedValue(0); + + await listEventsForWallet({ + address: ADDR, + types: ['PAUSED', 'RESUMED'], + limit: 10, + offset: 0, + }); + + const callArgs = (prisma.streamEvent.findMany as any).mock.calls[0][0]; + expect(callArgs.where.eventType).toEqual({ in: ['PAUSED', 'RESUMED'] }); + }); + + it('omits the eventType filter when no types are provided', async () => { + (prisma.streamEvent.findMany as any).mockResolvedValue([]); + (prisma.streamEvent.count as any).mockResolvedValue(0); + + await listEventsForWallet({ address: ADDR, types: [], limit: 10, offset: 0 }); + + const callArgs = (prisma.streamEvent.findMany as any).mock.calls[0][0]; + expect(callArgs.where.eventType).toBeUndefined(); + }); + + it('applies skip/take for pagination', async () => { + (prisma.streamEvent.findMany as any).mockResolvedValue([]); + (prisma.streamEvent.count as any).mockResolvedValue(0); + + await listEventsForWallet({ address: ADDR, limit: 5, offset: 15 }); + + expect(prisma.streamEvent.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 5, skip: 15 }), + ); + }); + + it('does not include the related stream by default', async () => { + (prisma.streamEvent.findMany as any).mockResolvedValue([]); + (prisma.streamEvent.count as any).mockResolvedValue(0); + + await listEventsForWallet({ address: ADDR, limit: 10, offset: 0 }); + + const callArgs = (prisma.streamEvent.findMany as any).mock.calls[0][0]; + expect(callArgs.include).toBeUndefined(); + }); + + it('includes the related stream when includeStream is true', async () => { + (prisma.streamEvent.findMany as any).mockResolvedValue([]); + (prisma.streamEvent.count as any).mockResolvedValue(0); + + await listEventsForWallet({ address: ADDR, limit: 10, offset: 0, includeStream: true }); + + const callArgs = (prisma.streamEvent.findMany as any).mock.calls[0][0]; + expect(callArgs.include).toEqual({ stream: true }); + }); + + it('computes hasMore from offset, returned count, and total', async () => { + (prisma.streamEvent.findMany as any).mockResolvedValue(Array(10).fill({})); + (prisma.streamEvent.count as any).mockResolvedValue(25); + + const result = await listEventsForWallet({ address: ADDR, limit: 10, offset: 0 }); + + expect(result.total).toBe(25); + expect(result.hasMore).toBe(true); + }); + + it('returns hasMore false at the end of results', async () => { + (prisma.streamEvent.findMany as any).mockResolvedValue(Array(5).fill({})); + (prisma.streamEvent.count as any).mockResolvedValue(20); + + const result = await listEventsForWallet({ address: ADDR, limit: 10, offset: 15 }); + + expect(result.hasMore).toBe(false); + }); + }); +}); diff --git a/backend/tests/user.controller.test.ts b/backend/tests/user.controller.test.ts index 6efeda19..5adb3a3e 100644 --- a/backend/tests/user.controller.test.ts +++ b/backend/tests/user.controller.test.ts @@ -179,6 +179,53 @@ describe('User Controller', () => { offset: 0 })); }); + + it('includes the related stream by default', async () => { + req.params = { publicKey: 'GD2XP6FNWL6IWULVMPNA2RV2T7GLCJHK3RH75GBCY7TSVIWDITJN4FXJ' }; + req.query = {}; + (prisma.streamEvent.findMany as any).mockResolvedValue([]); + (prisma.streamEvent.count as any).mockResolvedValue(0); + + await getUserEvents(req as Request, res as Response, next); + + expect(prisma.streamEvent.findMany).toHaveBeenCalledWith( + expect.objectContaining({ include: { stream: true } }), + ); + }); + + it('omits the related stream when includeStream=false', async () => { + req.params = { publicKey: 'GD2XP6FNWL6IWULVMPNA2RV2T7GLCJHK3RH75GBCY7TSVIWDITJN4FXJ' }; + req.query = { includeStream: 'false' }; + (prisma.streamEvent.findMany as any).mockResolvedValue([]); + (prisma.streamEvent.count as any).mockResolvedValue(0); + + await getUserEvents(req as Request, res as Response, next); + + const callArgs = (prisma.streamEvent.findMany as any).mock.calls[0][0]; + expect(callArgs.include).toBeUndefined(); + }); + + it('forwards a comma-separated type filter to the shared query helper', async () => { + req.params = { publicKey: 'GD2XP6FNWL6IWULVMPNA2RV2T7GLCJHK3RH75GBCY7TSVIWDITJN4FXJ' }; + req.query = { type: 'PAUSED,RESUMED' }; + (prisma.streamEvent.findMany as any).mockResolvedValue([]); + (prisma.streamEvent.count as any).mockResolvedValue(0); + + await getUserEvents(req as Request, res as Response, next); + + const callArgs = (prisma.streamEvent.findMany as any).mock.calls[0][0]; + expect(callArgs.where.eventType).toEqual({ in: ['PAUSED', 'RESUMED'] }); + }); + + it('returns 400 when the type filter has no valid values', async () => { + req.params = { publicKey: 'GD2XP6FNWL6IWULVMPNA2RV2T7GLCJHK3RH75GBCY7TSVIWDITJN4FXJ' }; + req.query = { type: 'BOGUS' }; + + await getUserEvents(req as Request, res as Response, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(prisma.streamEvent.findMany).not.toHaveBeenCalled(); + }); }); describe('getCurrentUser', () => {