From 915949426da483fc121e20efa3cc95b1c5a256f7 Mon Sep 17 00:00:00 2001 From: Damilorlar Date: Thu, 27 Aug 2026 15:42:53 +0100 Subject: [PATCH 1/3] feat: distinguish no-results search state from empty list Add a field ( | | ) and an optional to the creator list response envelope so clients can render a tailored no-results message referencing the search term instead of the generic empty state. Includes an integration test covering the zzznomatch zero-result search scenario. --- ...list-no-results-search.integration.test.ts | 109 ++++++++++++++++++ src/modules/creators/creators.controllers.ts | 3 +- src/modules/creators/creators.serializers.ts | 59 ++++++++-- 3 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 src/modules/creators/creator-list-no-results-search.integration.test.ts diff --git a/src/modules/creators/creator-list-no-results-search.integration.test.ts b/src/modules/creators/creator-list-no-results-search.integration.test.ts new file mode 100644 index 00000000..21749886 --- /dev/null +++ b/src/modules/creators/creator-list-no-results-search.integration.test.ts @@ -0,0 +1,109 @@ +// Integration test: creator list no-results state for an unmatched search term +// +// Verifies that when a search query returns zero creators the list response +// exposes a distinct `noResults` state with a message that references the +// search term, and that this state is separate from the unfiltered empty +// state (which uses `state: 'empty'` and no message). Uses Jest mocks so no +// database is required. + +import { httpListCreators } from './creators.controllers'; +import * as creatorsUtils from './creators.utils'; + +// ── Lightweight request/response mocks ──────────────────────────────────────── + +const SEARCH_TERM = 'zzznomatch'; + +function makeReq(query: Record = {}): any { + return { query }; +} + +function makeRes(): any { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.set = jest.fn().mockReturnValue(res); + return res; +} + +function makeNext(): jest.Mock { + return jest.fn(); +} + +function getBody(res: any) { + return res.json.mock.calls[0][0]; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /api/v1/creators — no-results state for unmatched search', () => { + beforeEach(() => { + // Mock the creator search to return zero results for every query. + jest + .spyOn(creatorsUtils, 'fetchCreatorList') + .mockResolvedValue([[], 0]); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns a no-results message that references the search term', async () => { + const req = makeReq({ search: SEARCH_TERM }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = getBody(res); + expect(body.data.state).toBe('noResults'); + expect(body.data.message).toEqual( + expect.stringContaining(SEARCH_TERM) + ); + }); + + it('keeps the no-results state distinct from the unfiltered empty state', async () => { + const searchRes = makeRes(); + await httpListCreators(makeReq({ search: SEARCH_TERM }), searchRes, makeNext()); + const noResultsBody = getBody(searchRes); + + const emptyRes = makeRes(); + await httpListCreators(makeReq(), emptyRes, makeNext()); + const emptyBody = getBody(emptyRes); + + // Search with zero matches → noResults + message + expect(noResultsBody.data.state).toBe('noResults'); + expect(noResultsBody.data.message).toBeDefined(); + + // Unfiltered empty list → empty, no message + expect(emptyBody.data.state).toBe('empty'); + expect(emptyBody.data.message).toBeUndefined(); + + // The two states must differ + expect(noResultsBody.data.state).not.toBe(emptyBody.data.state); + }); + + it('removes the no-results state when the search input is cleared', async () => { + const searchRes = makeRes(); + await httpListCreators(makeReq({ search: SEARCH_TERM }), searchRes, makeNext()); + expect(getBody(searchRes).data.state).toBe('noResults'); + + // Clearing the search returns the unfiltered empty state + const clearedRes = makeRes(); + await httpListCreators(makeReq(), clearedRes, makeNext()); + const clearedBody = getBody(clearedRes); + + expect(clearedBody.data.state).toBe('empty'); + expect(clearedBody.data.message).toBeUndefined(); + }); + + it('still reports zero total creators for the no-results search', async () => { + const req = makeReq({ search: SEARCH_TERM }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = getBody(res); + expect(body.data.items).toEqual([]); + expect(body.data.meta.total).toBe(0); + expect(body.data.meta.hasMore).toBe(false); + }); +}); diff --git a/src/modules/creators/creators.controllers.ts b/src/modules/creators/creators.controllers.ts index 31a40f7a..ab056536 100644 --- a/src/modules/creators/creators.controllers.ts +++ b/src/modules/creators/creators.controllers.ts @@ -76,7 +76,8 @@ export const httpListCreators: AsyncController = async (req, res, next) => { limit: validatedQuery.limit, offset: validatedQuery.offset, total, - }) + }), + { search: validatedQuery.search } ); attachTimestampHeader(res); diff --git a/src/modules/creators/creators.serializers.ts b/src/modules/creators/creators.serializers.ts index e24419e3..8e804689 100644 --- a/src/modules/creators/creators.serializers.ts +++ b/src/modules/creators/creators.serializers.ts @@ -164,13 +164,31 @@ export function serializeCreatorListOffsetMeta( }; } +/** + * Distinguishes an empty list from a "no matches" search result so that + * clients can render a tailored no-results message instead of the generic + * empty state. + * + * - `results` — at least one creator was returned. + * - `empty` — no creators exist and no search/filter narrowed the list. + * - `noResults`— a search term was supplied but matched zero creators. + */ +export type CreatorListState = 'results' | 'empty' | 'noResults'; + /** * Paginated creator list response body (offset pagination metadata). + * + * Adds `state` and an optional `message` so clients can differentiate the + * unfiltered empty list from a zero-result search and surface a message that + * references the search term. */ export type CreatorListResponse = PublicCreatorListEnvelope< CreatorListItem, OffsetPaginationMeta ->; +> & { + state: CreatorListState; + message?: string; +}; /** * Cursor-aware creator list response body. @@ -184,16 +202,43 @@ export type CreatorCursorListResponse = PublicCreatorListEnvelope< * Serializes a standard offset-paginated creator list response. * * This centralizes the wrapping of creators and metadata to ensure - * a consistent public response shape (envelope). + * a consistent public response shape (envelope). When the result set is + * empty, `state` distinguishes an unfiltered empty list (`empty`) from a + * zero-result search (`noResults`); the latter includes a `message` that + * references the supplied search term so clients can render a tailored + * no-results state. + * + * @param profiles - Creator profiles (null/undefined treated as empty) + * @param meta - Offset pagination metadata + * @param options - Serialization context (e.g. the active search term) */ export function serializeCreatorListResponse( profiles: CreatorProfile[], - meta: OffsetPaginationMeta + meta: OffsetPaginationMeta, + options: { search?: string } = {} ): CreatorListResponse { - return wrapPublicCreatorListResponse( - serializeCreatorList(profiles), - serializeCreatorListOffsetMeta(meta) - ); + const items = serializeCreatorList(profiles); + + let state: CreatorListState; + let message: string | undefined; + + if (meta.total > 0) { + state = 'results'; + } else if (options.search) { + state = 'noResults'; + message = `No creators match "${options.search}". Try a different search term.`; + } else { + state = 'empty'; + } + + return { + ...wrapPublicCreatorListResponse( + items, + serializeCreatorListOffsetMeta(meta) + ), + state, + ...(message ? { message } : {}), + }; } /** From 46b170bbf8ea2270b4469be0d238559892559ab4 Mon Sep 17 00:00:00 2001 From: Damilorlar Date: Thu, 27 Aug 2026 20:10:15 +0100 Subject: [PATCH 2/3] Fix leftover merge conflict artifacts in creators controllers/serializers --- src/modules/creators/creators.controllers.ts | 9 +++------ src/modules/creators/creators.serializers.ts | 9 --------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/modules/creators/creators.controllers.ts b/src/modules/creators/creators.controllers.ts index 2895238e..c7aad47c 100644 --- a/src/modules/creators/creators.controllers.ts +++ b/src/modules/creators/creators.controllers.ts @@ -76,15 +76,13 @@ export const httpListCreators: AsyncController = async (req, res, next) => { limit: validatedQuery.limit, offset: validatedQuery.offset, total, -no-results-state }), - { search: validatedQuery.search } - + { + search: validatedQuery.search, ...(validatedQuery.search !== undefined && total === 0 ? { searchTerm: validatedQuery.search } : {}), - }) -main + } ); attachTimestampHeader(res); @@ -93,7 +91,6 @@ main next(error); } }; - /** * Categorize a parse error based on the validation details. * diff --git a/src/modules/creators/creators.serializers.ts b/src/modules/creators/creators.serializers.ts index 556e9efe..4787b10d 100644 --- a/src/modules/creators/creators.serializers.ts +++ b/src/modules/creators/creators.serializers.ts @@ -216,7 +216,6 @@ export type CreatorCursorListResponse = PublicCreatorListEnvelope< */ export async function serializeCreatorListResponse( profiles: CreatorProfile[], -no-results-state meta: OffsetPaginationMeta, options: { search?: string } = {} ): CreatorListResponse { @@ -242,14 +241,6 @@ no-results-state state, ...(message ? { message } : {}), }; - - meta: OffsetPaginationMeta -): Promise { - return wrapPublicCreatorListResponse( - await serializeCreatorList(profiles), - serializeCreatorListOffsetMeta(meta) - ); - main } /** From a5f7e9f111e2fb8d110e04a7a9489ffa74f49d02 Mon Sep 17 00:00:00 2001 From: Damilorlar Date: Fri, 28 Aug 2026 13:18:13 +0100 Subject: [PATCH 3/3] fix: resolve Vercel build TypeScript errors across schema, config, and modules --- package.json | 1 + prisma/schema/follow.prisma | 11 +++++ prisma/schema/ownership.prisma | 5 +- src/config.schema.ts | 25 ++++++++++ .../audit-log-endpoint.integration.test.ts | 1 - .../admin/audit-log.integration.test.ts | 1 - .../admin/key-sync.integration.test.ts | 12 +++-- src/modules/creators/creators.serializers.ts | 4 +- .../dividend-endpoint.integration.test.ts | 4 ++ src/modules/investor/dividend.service.ts | 2 +- src/modules/keys/keys.routes.ts | 2 + src/modules/keys/price-moved.redis.ts | 6 ++- .../notifications/notification.service.ts | 8 +++- .../subscriptions/subscription.service.ts | 48 ++++++++++++------- src/modules/wallets/wallets.routes.ts | 2 + .../whitelist/whitelist.integration.test.ts | 8 ++-- src/utils/redis.utils.ts | 9 ++++ src/utils/sequencer-lock.utils.ts | 5 ++ src/utils/server.utils.ts | 12 +++++ src/utils/supply-drift-guard.utils.ts | 3 ++ 20 files changed, 135 insertions(+), 34 deletions(-) create mode 100644 src/utils/server.utils.ts diff --git a/package.json b/package.json index fcb38906..d920c9b7 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "preinstall": "node -e \"const ua = process.env.npm_config_user_agent || ''; if (!ua.includes('pnpm')) { console.error('Use pnpm for this repository. Run: pnpm install'); process.exit(1); }\"", "dev": "nodemon", "start": "node dist/server.js", + "prebuild": "prisma generate", "build": "tsc", "test": "jest", "format": "prettier --write .", diff --git a/prisma/schema/follow.prisma b/prisma/schema/follow.prisma index f05ad9fe..079aabf9 100644 --- a/prisma/schema/follow.prisma +++ b/prisma/schema/follow.prisma @@ -12,3 +12,14 @@ model Follow { @@index([creatorId]) @@index([followerAddress]) } + +model WalletCreatorFollow { + id String @id @default(cuid()) + walletAddress String + creatorId String + createdAt DateTime @default(now()) + + @@unique([walletAddress, creatorId]) + @@index([walletAddress]) + @@index([creatorId]) +} diff --git a/prisma/schema/ownership.prisma b/prisma/schema/ownership.prisma index 705d50a5..a5a24a31 100644 --- a/prisma/schema/ownership.prisma +++ b/prisma/schema/ownership.prisma @@ -15,7 +15,10 @@ model KeyOwnership { /// When the current lockup window ends for this holding, if any. lockupExpiresAt DateTime? - + + /// ISO timestamp of the holder's most recent buy, null when never bought. + lastBuyAt DateTime? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/config.schema.ts b/src/config.schema.ts index 433f0226..b2b51158 100644 --- a/src/config.schema.ts +++ b/src/config.schema.ts @@ -106,6 +106,7 @@ export const envSchema = z .int() .positive() .default(900), + JWT_EXPIRES_IN: z.string().default('1h'), // Redis cache REDIS_URL: z.string().default('redis://localhost:6379'), @@ -172,6 +173,10 @@ export const envSchema = z 'STELLAR_SOROBAN_RPC_URL must be a valid URL (e.g. https://soroban-testnet.stellar.org)' ) .default('https://soroban-testnet.stellar.org'), + STELLAR_AUTH_SECRET: z + .string() + .min(32, 'STELLAR_AUTH_SECRET should be at least 32 characters') + .default('accesslayer_default_development_stellar_auth_secret_32b'), // Ownership snapshot cleanup job OWNERSHIP_SNAPSHOT_TABLE_NAME: z @@ -240,6 +245,26 @@ export const envSchema = z .positive() .default(5000), SSE_REPLAY_MAX_EVENTS: z.coerce.number().int().positive().default(100), + SSE_MAX_CONNECTIONS_PER_WALLET: z.coerce + .number() + .int() + .positive() + .default(5), + SSE_MAX_SUBSCRIPTIONS_PER_WALLET: z.coerce + .number() + .int() + .positive() + .default(10), + SSE_SUBSCRIPTION_TTL_MS: z.coerce + .number() + .int() + .positive() + .default(300000), + SSE_THROTTLE_DURATION_MS: z.coerce + .number() + .int() + .positive() + .default(1000), }) .superRefine((data, ctx) => { if (data.MODE === 'production' && data.STELLAR_NETWORK === 'testnet') { diff --git a/src/modules/admin/audit-log-endpoint.integration.test.ts b/src/modules/admin/audit-log-endpoint.integration.test.ts index b6c3a3f4..17c325ab 100644 --- a/src/modules/admin/audit-log-endpoint.integration.test.ts +++ b/src/modules/admin/audit-log-endpoint.integration.test.ts @@ -1,4 +1,3 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import request from 'supertest'; import { createServer } from '../../utils/server.utils'; import { prisma } from '../../utils/prisma.utils'; diff --git a/src/modules/admin/audit-log.integration.test.ts b/src/modules/admin/audit-log.integration.test.ts index 9a3b7f07..f60aeb51 100644 --- a/src/modules/admin/audit-log.integration.test.ts +++ b/src/modules/admin/audit-log.integration.test.ts @@ -1,4 +1,3 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { prisma } from '../../utils/prisma.utils'; import { createAuditEntry, getAuditLogs } from './audit-log.service'; diff --git a/src/modules/admin/key-sync.integration.test.ts b/src/modules/admin/key-sync.integration.test.ts index 2c32caa5..f75e3a93 100644 --- a/src/modules/admin/key-sync.integration.test.ts +++ b/src/modules/admin/key-sync.integration.test.ts @@ -1,4 +1,3 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import request from 'supertest'; import { createServer } from '../../utils/server.utils'; import { prisma } from '../../utils/prisma.utils'; @@ -27,6 +26,9 @@ describe('Key Sync Integration Tests', () => { const user = await prisma.user.create({ data: { email: `test-${Date.now()}@example.com`, + passwordHash: 'hash', + firstName: 'Test', + lastName: 'User', stellarWallet: { create: { address: 'GBTEST0001' } }, }, }); @@ -46,9 +48,9 @@ describe('Key Sync Integration Tests', () => { // Create price snapshot await prisma.creatorPriceSnapshot.create({ data: { - creatorId, - price: 100, - priceUpdatedAt: new Date(), + creatorId: testCreatorId, + currentPrice: 100n, + lastTradeAt: new Date(), }, }); @@ -57,7 +59,7 @@ describe('Key Sync Integration Tests', () => { await prisma.keyOwnership.create({ data: { ownerAddress: `GHOLDER${String(i).padStart(52, '0')}`, - creatorId, + creatorId: testCreatorId, balance: 100, }, }); diff --git a/src/modules/creators/creators.serializers.ts b/src/modules/creators/creators.serializers.ts index 4787b10d..0ce5f188 100644 --- a/src/modules/creators/creators.serializers.ts +++ b/src/modules/creators/creators.serializers.ts @@ -218,8 +218,8 @@ export async function serializeCreatorListResponse( profiles: CreatorProfile[], meta: OffsetPaginationMeta, options: { search?: string } = {} -): CreatorListResponse { - const items = serializeCreatorList(profiles); +): Promise { + const items = await serializeCreatorList(profiles); let state: CreatorListState; let message: string | undefined; diff --git a/src/modules/dividends/dividend-endpoint.integration.test.ts b/src/modules/dividends/dividend-endpoint.integration.test.ts index cdaabb07..edb3e937 100644 --- a/src/modules/dividends/dividend-endpoint.integration.test.ts +++ b/src/modules/dividends/dividend-endpoint.integration.test.ts @@ -1,4 +1,5 @@ import request from 'supertest'; +import { createServer } from '../../utils/server.utils'; import { prisma } from '../../utils/prisma.utils'; import { processDividendEvents } from '../indexer/dividend-indexer.service'; import { IndexerChainEvent } from '../../utils/indexer-event-processor.utils'; @@ -234,6 +235,9 @@ describe('Dividend Endpoints Integration Tests', () => { const user = await prisma.user.create({ data: { email: `test2-${Date.now()}@example.com`, + passwordHash: 'hash123', + firstName: 'Test', + lastName: 'User', stellarWallet: { create: { address: 'GBTEST0002' } }, }, }); diff --git a/src/modules/investor/dividend.service.ts b/src/modules/investor/dividend.service.ts index c1936177..6c8abe6d 100644 --- a/src/modules/investor/dividend.service.ts +++ b/src/modules/investor/dividend.service.ts @@ -14,7 +14,7 @@ export async function getInvestorDividends( } const items = await prisma.dividendDistribution.findMany({ where, - orderBy: { distributedAt: 'desc' }, + orderBy: { createdAt: 'desc' }, take: limit + 1, }); const hasMore = items.length > limit; diff --git a/src/modules/keys/keys.routes.ts b/src/modules/keys/keys.routes.ts index b17b7b33..4c4caf83 100644 --- a/src/modules/keys/keys.routes.ts +++ b/src/modules/keys/keys.routes.ts @@ -14,6 +14,8 @@ import { PRICE_HISTORY_INTERVALS, } from './key-price-history.service'; import { getKeyFees, KeyNotFoundError } from './key-fees.service'; +import { getKeyProposals, KeyNotFoundError as ProposalKeyNotFoundError } from './key-proposals.service'; +import { getKeySupply, KeyNotFoundError as SupplyKeyNotFoundError } from './key-supply.service'; import { KeySearchQueryTooShortError, searchKeys } from './key-search.service'; import { KEY_SEARCH_MIN_QUERY_LENGTH } from '../../constants/notifications.constants'; import dividendRouter from '../dividends/dividend.routes'; diff --git a/src/modules/keys/price-moved.redis.ts b/src/modules/keys/price-moved.redis.ts index ad53ea84..cc0666ce 100644 --- a/src/modules/keys/price-moved.redis.ts +++ b/src/modules/keys/price-moved.redis.ts @@ -8,6 +8,7 @@ import { export async function writePriceMovedKeys(keyIds: string[]): Promise { const redis = getRedis(); + if (!redis) return; const pipeline = redis.pipeline(); pipeline.del(REDIS_KEYS.priceMovedSet); if (keyIds.length > 0) { @@ -18,7 +19,9 @@ export async function writePriceMovedKeys(keyIds: string[]): Promise { } export async function getPriceMovedKeyIds(): Promise { - return getRedis().smembers(REDIS_KEYS.priceMovedSet); + const redis = getRedis(); + if (!redis) return []; + return redis.smembers(REDIS_KEYS.priceMovedSet); } export async function markPriceMovedDelivered( @@ -26,6 +29,7 @@ export async function markPriceMovedDelivered( walletAddress: string ): Promise { const redis = getRedis(); + if (!redis) return; const deliveredKey = REDIS_KEYS.priceMovedDelivered(keyId); await redis.sadd(deliveredKey, walletAddress); await redis.expire(deliveredKey, PRICE_MOVED_SET_TTL_SECONDS); diff --git a/src/modules/notifications/notification.service.ts b/src/modules/notifications/notification.service.ts index 11cbda06..5ad38dde 100644 --- a/src/modules/notifications/notification.service.ts +++ b/src/modules/notifications/notification.service.ts @@ -13,7 +13,9 @@ import { import { NotificationItem } from './notification.types'; async function getLastReadAt(walletAddress: string): Promise { - const raw = await getRedis().get( + const redis = getRedis(); + if (!redis) return null; + const raw = await redis.get( REDIS_KEYS.notificationsReadAt(walletAddress) ); if (!raw) { @@ -182,7 +184,9 @@ export async function markAllNotificationsRead( walletAddress: string, now: Date = new Date() ): Promise { - await getRedis().set( + const redis = getRedis(); + if (!redis) return; + await redis.set( REDIS_KEYS.notificationsReadAt(walletAddress), now.toISOString() ); diff --git a/src/modules/subscriptions/subscription.service.ts b/src/modules/subscriptions/subscription.service.ts index 2049d691..5bf89a44 100644 --- a/src/modules/subscriptions/subscription.service.ts +++ b/src/modules/subscriptions/subscription.service.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'crypto'; +import { Redis } from 'ioredis'; import { getRedis } from '../../utils/redis.utils'; import { envConfig } from '../../config'; import { @@ -36,14 +37,27 @@ function walletSubsKey(walletAddress: string): string { } function generateSubscriptionId(): string { - return `sub_${randomUUID().replace(/-/g, '').slice(0, 24)}`; + return `sub_${randomUUID().replace(/-/g, '').slice(0, 24)}`; +} + +/** + * Resolve the shared Redis client, throwing if it is unavailable. The + * subscription/SSE layer is fundamentally Redis-backed, so operating without + * it is an error rather than a degradable cache miss. + */ +function assertRedis(): Redis { + const client = getRedis(); + if (!client) { + throw new Error('Redis is not available; subscriptions require Redis'); + } + return client; } export async function createSubscription( walletAddress: string, topics: SubscriptionTopic[] ): Promise { - const redis = getRedis(); + const redis = assertRedis(); const walletKey = walletSubsKey(walletAddress); @@ -86,7 +100,7 @@ export async function createSubscription( export async function getSubscription( subscriptionId: string ): Promise { - const redis = getRedis(); + const redis = assertRedis(); const data = await redis.hgetall(subKey(subscriptionId)); if (!data || !data.walletAddress) return null; @@ -99,7 +113,7 @@ export async function getSubscription( } export async function deleteSubscription(subscriptionId: string): Promise { - const redis = getRedis(); + const redis = assertRedis(); const sub = await getSubscription(subscriptionId); if (!sub) return; @@ -111,14 +125,14 @@ export async function deleteSubscription(subscriptionId: string): Promise } export async function touchSubscription(subscriptionId: string): Promise { - const redis = getRedis(); + const redis = assertRedis(); await redis.expire(subKey(subscriptionId), SUBSCRIPTION_TTL_S); } export async function getLastCursor( subscriptionId: string ): Promise { - const redis = getRedis(); + const redis = assertRedis(); return redis.get(cursorKey(subscriptionId)); } @@ -126,25 +140,25 @@ export async function saveCursor( subscriptionId: string, cursor: string ): Promise { - const redis = getRedis(); + const redis = assertRedis(); await redis.set(cursorKey(subscriptionId), cursor); } export async function isThrottled(walletAddress: string): Promise { - const redis = getRedis(); + const redis = assertRedis(); const exists = await redis.exists(throttledKey(walletAddress)); return exists === 1; } export async function setThrottled(walletAddress: string): Promise { - const redis = getRedis(); + const redis = assertRedis(); await redis.setex(throttledKey(walletAddress), THROTTLE_DURATION_S, '1'); } export async function incrementConnectionCount( walletAddress: string ): Promise { - const redis = getRedis(); + const redis = assertRedis(); const count = await redis.incr(connectionCountKey(walletAddress)); await redis.expire(connectionCountKey(walletAddress), 60); return count; @@ -153,18 +167,18 @@ export async function incrementConnectionCount( export async function decrementConnectionCount( walletAddress: string ): Promise { - const redis = getRedis(); + const redis = assertRedis(); await redis.decr(connectionCountKey(walletAddress)); } export async function getWalletSubscriptions( walletAddress: string ): Promise { - const redis = getRedis(); + const redis = assertRedis(); const ids = await redis.zrange( walletSubsKey(walletAddress), - 0, - -1 + '0', + '-1' ); const subs: Subscription[] = []; @@ -178,7 +192,7 @@ export async function getWalletSubscriptions( export async function getSubscriptionsByTopic( topic: string ): Promise { - const redis = getRedis(); + const redis = assertRedis(); const ids = await redis.keys(`${SUBSCRIPTION_KEY_PREFIX}*`); const subs: Subscription[] = []; @@ -201,12 +215,12 @@ export async function getSubscriptionsByTopic( } export async function pruneExpiredSubscriptions(): Promise { - const redis = getRedis(); + const redis = assertRedis(); const walletKeys = await redis.keys(`${WALLET_SUBSCRIPTIONS_KEY_PREFIX}*`); let pruned = 0; for (const wk of walletKeys) { - const ids = await redis.zrange(wk, 0, -1); + const ids = await redis.zrange(wk, '0', '-1'); for (const id of ids) { const exists = await redis.exists(subKey(id)); if (exists === 0) { diff --git a/src/modules/wallets/wallets.routes.ts b/src/modules/wallets/wallets.routes.ts index b9d42318..fde9c020 100644 --- a/src/modules/wallets/wallets.routes.ts +++ b/src/modules/wallets/wallets.routes.ts @@ -1,9 +1,11 @@ import { Router } from "express"; import { httpGetWalletActivity } from "./wallet-activity.controllers"; import { httpGetWalletHoldings } from "./wallet-holdings.controllers"; +import { httpGetWalletFollowing } from "./wallet-following.controllers"; import { cacheControl } from "../../middlewares/cache-control.middleware"; import { ACTIVITY_FEED_CACHE_PRESET } from "../../constants/activity-feed-cache.constants"; import { requireWalletParamMatch } from "../../middlewares/jwt-auth.middleware"; +import { jwtAuth } from "../../middlewares/jwt.middleware"; const walletsRouter = Router(); diff --git a/src/modules/whitelist/whitelist.integration.test.ts b/src/modules/whitelist/whitelist.integration.test.ts index f8afcc10..bfa3737d 100644 --- a/src/modules/whitelist/whitelist.integration.test.ts +++ b/src/modules/whitelist/whitelist.integration.test.ts @@ -1,4 +1,3 @@ -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import request from 'supertest'; import { createServer } from '../../utils/server.utils'; import { prisma } from '../../utils/prisma.utils'; @@ -21,6 +20,9 @@ describe('Whitelist Endpoint Integration Tests', () => { const user = await prisma.user.create({ data: { email: `test-${Date.now()}@example.com`, + passwordHash: 'hash', + firstName: 'Test', + lastName: 'User', stellarWallet: { create: { address: 'GBTEST0001' } }, }, }); @@ -239,8 +241,8 @@ describe('Whitelist Endpoint Integration Tests', () => { // Note: This test requires Redis to be available // We spy on the caching functions to verify they're called - const cacheGetSpy = vi.spyOn(cacheUtils, 'cacheGetJson'); - const cacheSetSpy = vi.spyOn(cacheUtils, 'cacheSetJson'); + const cacheGetSpy = jest.spyOn(cacheUtils, 'cacheGetJson'); + const cacheSetSpy = jest.spyOn(cacheUtils, 'cacheSetJson'); // First request should miss cache and populate it const response1 = await request(app) diff --git a/src/utils/redis.utils.ts b/src/utils/redis.utils.ts index 644eac40..92030bdc 100644 --- a/src/utils/redis.utils.ts +++ b/src/utils/redis.utils.ts @@ -214,5 +214,14 @@ export async function disconnectRedis(): Promise { } } +/** + * Ensure the shared Redis client is initialised. The client connects eagerly + * on creation (lazyConnect is disabled), so simply touching the singleton is + * enough to "connect". No-op when caching is disabled (returns null). + */ +export async function connectRedis(): Promise { + getRedisClient(); +} + export const getRedis = getRedisClient; export const redis = getRedisClient; diff --git a/src/utils/sequencer-lock.utils.ts b/src/utils/sequencer-lock.utils.ts index 04e9652f..f5651094 100644 --- a/src/utils/sequencer-lock.utils.ts +++ b/src/utils/sequencer-lock.utils.ts @@ -23,6 +23,11 @@ export async function acquireSequencerLock( creatorWallet: string ): Promise<{ release: () => Promise }> { const redis = getRedis(); + if (!redis) { + throw new SequencerContentionError( + `Cannot acquire sequencer lock for ${creatorWallet}: Redis is not available` + ); + } const key = lockKey(creatorWallet); const lockValue = `${process.pid}:${Date.now()}`; const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS; diff --git a/src/utils/server.utils.ts b/src/utils/server.utils.ts new file mode 100644 index 00000000..737ddbc7 --- /dev/null +++ b/src/utils/server.utils.ts @@ -0,0 +1,12 @@ +// src/utils/server.utils.ts +// Builds the Express app for use in integration tests without binding a port. +import app from '../app'; + +/** + * Returns the configured Express application instance. Used by integration + * tests so they can drive the full HTTP stack via supertest without starting + * a listening server. + */ +export async function createServer() { + return app; +} diff --git a/src/utils/supply-drift-guard.utils.ts b/src/utils/supply-drift-guard.utils.ts index 90604e2f..c38dd81c 100644 --- a/src/utils/supply-drift-guard.utils.ts +++ b/src/utils/supply-drift-guard.utils.ts @@ -16,6 +16,7 @@ function driftKey(creatorWallet: string): string { export async function isDriftHalted(creatorWallet: string): Promise { const redis = getRedis(); + if (!redis) return false; const exists = await redis.exists(driftKey(creatorWallet)); return exists === 1; } @@ -46,6 +47,7 @@ export async function verifySupplyAndGuard( ); const redis = getRedis(); + if (!redis) return false; await redis.set(driftKey(creatorWallet), '1'); return false; } @@ -55,6 +57,7 @@ export async function verifySupplyAndGuard( export async function clearDrift(creatorWallet: string): Promise { const redis = getRedis(); + if (!redis) return; await redis.del(driftKey(creatorWallet)); logger.info( { creator_wallet: creatorWallet },