diff --git a/src/config/nexus.ts b/src/config/nexus.ts index 47a128ab39..b71dc5656a 100644 --- a/src/config/nexus.ts +++ b/src/config/nexus.ts @@ -8,3 +8,10 @@ export const NEXUS_NOTIFICATIONS_LIMIT = 30; export const NEXUS_POSTS_PER_PAGE = 10; // Number of posts to fetch per page in streams export const NEXUS_STREAM_MAX_LIMIT = 50; // Hard cap Nexus enforces on a single stream `limit`; requests above this are rejected export const NEXUS_USERS_PER_PAGE = 10; // Number of users to fetch per page in streams + +/** + * Nexus contract limit: `source=starter_pack` accepts 1-5 comma-separated interest tags + * (pubky/pubky-nexus#1024). Fixed by the backend — independent of the runtime-configurable + * `getMaxStreamTags()`, which may be set higher and must never widen this bound. + */ +export const STARTER_PACK_MAX_TAGS = 5; diff --git a/src/core/application/stream/users/users.test.ts b/src/core/application/stream/users/users.test.ts index 6792da371b..5a16f7699f 100644 --- a/src/core/application/stream/users/users.test.ts +++ b/src/core/application/stream/users/users.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { UserStreamApplication } from '@/application/stream/users/users'; import type { Pubky } from '@/models/models.types'; import { buildUserCompositeId } from '@/models/stream/user/userStream.helper'; -import { UserStreamTypes } from '@/models/stream/user/userStream.types'; +import { type UserStreamId, UserStreamTypes } from '@/models/stream/user/userStream.types'; import { UserDetailsModel } from '@/models/user/details/userDetails'; import { LocalStreamUsersService } from '@/services/local/stream/users/users'; import type { NexusUser } from '@/services/nexus/nexus.types'; @@ -289,6 +289,19 @@ describe('UserStreamApplication', () => { expect(result.nextPageIds).toEqual(['influencer-1', 'influencer-2', 'influencer-3']); }); + describe('starter pack caching', () => { + it('should persist reversed tag orders as distinct Dexie rows', async () => { + const forwardId = 'starter_pack:all:all:travel,music' as UserStreamId; + const reversedId = 'starter_pack:all:all:music,travel' as UserStreamId; + + await LocalStreamUsersService.upsert({ streamId: forwardId, stream: ['user-a'] }); + await LocalStreamUsersService.upsert({ streamId: reversedId, stream: ['user-b'] }); + + expect((await LocalStreamUsersService.findById(forwardId))?.stream).toEqual(['user-a']); + expect((await LocalStreamUsersService.findById(reversedId))?.stream).toEqual(['user-b']); + }); + }); + it('should pass viewerId to Nexus API for relationship data', async () => { const streamId = buildUserCompositeId({ userId: DEFAULT_USER_ID, reach: 'followers' }); const mockUserIds: Pubky[] = ['follower-1', 'follower-2']; diff --git a/src/core/models/stream/user/userStream.helper.test.ts b/src/core/models/stream/user/userStream.helper.test.ts new file mode 100644 index 0000000000..fa884664d7 --- /dev/null +++ b/src/core/models/stream/user/userStream.helper.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { AppError } from '@/libs/error/error'; +import { ValidationErrorCode } from '@/libs/error/error.codes'; +import { ErrorCategory } from '@/libs/error/error.types'; +import { buildStarterPackStreamId, USER_STREAM_TAG_DELIMITER } from './userStream.helper'; + +const expectValidationError = (fn: () => unknown) => { + try { + fn(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(AppError); + const appError = error as AppError; + expect(appError.category).toBe(ErrorCategory.Validation); + expect(appError.code).toBe(ValidationErrorCode.INVALID_INPUT); + } +}; + +describe('buildStarterPackStreamId', () => { + describe('ID construction', () => { + it('should join ordered tags under the starter_pack source', () => { + expect(buildStarterPackStreamId(['bitcoin', 'music'])).toBe('starter_pack:all:all:bitcoin,music'); + }); + + it('should preserve tag order (reversed lists yield distinct IDs)', () => { + const forward = buildStarterPackStreamId(['travel', 'music']); + const reversed = buildStarterPackStreamId(['music', 'travel']); + + expect(forward).toBe('starter_pack:all:all:travel,music'); + expect(reversed).toBe('starter_pack:all:all:music,travel'); + expect(forward).not.toBe(reversed); + }); + + it('should canonicalize labels so casing/whitespace variants map to one ID', () => { + const fromMixedCase = buildStarterPackStreamId(['Bitcoin ', 'MUSIC']); + const fromCanonical = buildStarterPackStreamId(['bitcoin', 'music']); + + expect(fromMixedCase).toBe(fromCanonical); + expect(fromMixedCase).toBe('starter_pack:all:all:bitcoin,music'); + }); + + it('should accept the maximum of 5 tags', () => { + expect(buildStarterPackStreamId(['a', 'b', 'c', 'd', 'e'])).toBe('starter_pack:all:all:a,b,c,d,e'); + }); + + it('should accept a single tag', () => { + expect(buildStarterPackStreamId(['bitcoin'])).toBe('starter_pack:all:all:bitcoin'); + }); + }); + + describe('validation', () => { + it('should reject an empty tag list', () => { + expectValidationError(() => buildStarterPackStreamId([])); + }); + + it('should reject more than 5 tags', () => { + expectValidationError(() => buildStarterPackStreamId(['a', 'b', 'c', 'd', 'e', 'f'])); + }); + + it('should reject empty and whitespace-only labels', () => { + expectValidationError(() => buildStarterPackStreamId([''])); + expectValidationError(() => buildStarterPackStreamId([' '])); + }); + + it('should reject labels with inner whitespace (banned characters)', () => { + expectValidationError(() => buildStarterPackStreamId(['rock music'])); + expectValidationError(() => buildStarterPackStreamId(['tab\there'])); + expectValidationError(() => buildStarterPackStreamId(['new\nline'])); + }); + + it('should reject labels containing the tag delimiter', () => { + expectValidationError(() => buildStarterPackStreamId([`bit${USER_STREAM_TAG_DELIMITER}coin`])); + }); + + it('should reject labels containing the stream ID delimiter', () => { + expectValidationError(() => buildStarterPackStreamId(['bit:coin'])); + }); + + it('should reject overlength labels (>20 chars)', () => { + expectValidationError(() => buildStarterPackStreamId(['a'.repeat(21)])); + }); + + it('should accept a label at exactly 20 chars', () => { + expect(buildStarterPackStreamId(['a'.repeat(20)])).toContain(`:${'a'.repeat(20)}`); + }); + }); +}); diff --git a/src/core/models/stream/user/userStream.helper.ts b/src/core/models/stream/user/userStream.helper.ts index 7119733c72..9c25e31f66 100644 --- a/src/core/models/stream/user/userStream.helper.ts +++ b/src/core/models/stream/user/userStream.helper.ts @@ -1,8 +1,19 @@ +import { STARTER_PACK_MAX_TAGS } from '@/config/nexus'; +import { ValidationErrorCode } from '@/libs/error/error.codes'; +import { Err } from '@/libs/error/error.factories'; +import { ErrorService } from '@/libs/error/error.types'; +import { isValidTagLabel } from '@/libs/utils/utils'; import type { Pubky } from '@/models/models.types'; import { UserStreamModelSchema } from './userStream.schema'; -import { UserStreamCompositeId, UserStreamId } from './userStream.types'; +import { + STARTER_PACK_STREAM_SOURCE, + StarterPackStreamId, + UserStreamCompositeId, + UserStreamId, +} from './userStream.types'; export const USER_STREAM_ID_DELIMITER = ':' as const; +export const USER_STREAM_TAG_DELIMITER = ',' as const; /** * Parts of a user stream composite ID @@ -46,6 +57,49 @@ export function parseUserCompositeId(compositeId: string): UserStreamIdParts { }; } +/** + * Build a starter pack stream ID from ordered interest tags. + * + * Labels are canonicalized (trimmed + lowercased) so 'Bitcoin' and 'bitcoin' resolve to the same + * Dexie row, then validated against the canonical tag contract (1-20 chars, no banned characters) + * and the Nexus starter pack limit (1-5 tags). Order is preserved: Nexus interleaves per-tag + * rankings in the order given, so ['travel','music'] and ['music','travel'] are different streams. + * + * @example + * buildStarterPackStreamId(['Bitcoin ', 'music']) + * // Returns: 'starter_pack:all:all:bitcoin,music' + */ +export function buildStarterPackStreamId(tags: string[]): StarterPackStreamId { + const canonical = tags.map((tag) => tag.trim().toLowerCase()); + + if (canonical.length === 0 || canonical.length > STARTER_PACK_MAX_TAGS) { + throw Err.validation( + ValidationErrorCode.INVALID_INPUT, + `Starter pack streams require 1-${STARTER_PACK_MAX_TAGS} tags`, + { + service: ErrorService.Nexus, + operation: 'buildStarterPackStreamId', + context: { tagCount: canonical.length }, + }, + ); + } + + const invalidLabels = canonical.filter((tag) => !isValidTagLabel(tag)); + if (invalidLabels.length > 0) { + throw Err.validation( + ValidationErrorCode.INVALID_INPUT, + 'Starter pack tags must be 1-20 characters without banned characters', + { + service: ErrorService.Nexus, + operation: 'buildStarterPackStreamId', + context: { invalidLabels }, + }, + ); + } + + return `${STARTER_PACK_STREAM_SOURCE}:all:all:${canonical.join(USER_STREAM_TAG_DELIMITER)}` as StarterPackStreamId; +} + export const createDefaultUserStream = (id: UserStreamId, stream: Pubky[] = []): UserStreamModelSchema => { return { id, diff --git a/src/core/models/stream/user/userStream.types.ts b/src/core/models/stream/user/userStream.types.ts index 8dd43ee557..aaed38bcc9 100644 --- a/src/core/models/stream/user/userStream.types.ts +++ b/src/core/models/stream/user/userStream.types.ts @@ -6,6 +6,10 @@ import type { UserStreamCompositeReach } from '@/services/nexus/nexus.types'; // - TIMEFRAME: today, this_week, this_month, all_time, all // - REACH (Supported in 'influencers' source): followers, following, friends, wot (u8), all // +// Starter pack IDs extend the pattern with a 4th ordered-tag segment: source:timeframe:reach:tag1,tag2 +// - SOURCE: starter_pack (pubky/pubky-nexus#1024) +// - Tag order matters: Nexus interleaves per-tag rankings in the order given. +// // Note: Different from PostStreamTypes pattern (sorting:source:kind) to optimize for user-centric queries export enum UserStreamTypes { // Bootstrap default lists: @@ -17,7 +21,13 @@ export enum UserStreamTypes { // TODO: Add all possible cases } +// Nexus stream source for starter packs (pubky/pubky-nexus#1024) +export const STARTER_PACK_STREAM_SOURCE = 'starter_pack' as const; + +// Starter pack ID format: source:all:all:tag1,tag2 (ordered, canonicalized tags) +export type StarterPackStreamId = `${typeof STARTER_PACK_STREAM_SOURCE}:all:all:${string}`; + // Composite ID format: userId:reach (e.g., 'user123:followers') export type UserStreamCompositeId = `${Pubky}:${UserStreamCompositeReach}`; -export type UserStreamId = UserStreamTypes | UserStreamCompositeId; +export type UserStreamId = UserStreamTypes | UserStreamCompositeId | StarterPackStreamId; diff --git a/src/core/services/nexus/stream/users/userStream.api.ts b/src/core/services/nexus/stream/users/userStream.api.ts index b66adf65e3..38316844f3 100644 --- a/src/core/services/nexus/stream/users/userStream.api.ts +++ b/src/core/services/nexus/stream/users/userStream.api.ts @@ -4,6 +4,7 @@ import { type TUserStreamInfluencersParams, type TUserStreamPostRepliesParams, type TUserStreamQueryParams, + type TUserStreamStarterPackParams, type TUserStreamUsernameParams, type TUserStreamUsersByIdsParams, type TUserStreamWithDepthParams, @@ -76,6 +77,10 @@ export const userStreamApi = { mostFollowed: (params: TUserStreamBase) => buildUserStreamUrl(params, UserStreamSource.MOST_FOLLOWED, USER_STREAM_PREFIX.USER_IDS), + // Starter pack: ranked users for 1-5 ordered interest tags (pubky/pubky-nexus#1024) + starterPack: (params: TUserStreamStarterPackParams) => + buildUserStreamUrl(params, UserStreamSource.STARTER_PACK, USER_STREAM_PREFIX.USER_IDS), + // Username search username: (params: TUserStreamUsernameParams) => buildUserStreamUrl(params, null, USER_STREAM_PREFIX.USERNAME), diff --git a/src/core/services/nexus/stream/users/userStream.test.ts b/src/core/services/nexus/stream/users/userStream.test.ts index d4a2712122..7821fcf0e2 100644 --- a/src/core/services/nexus/stream/users/userStream.test.ts +++ b/src/core/services/nexus/stream/users/userStream.test.ts @@ -1,10 +1,18 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Pubky } from '@/models/models.types'; import { buildUserCompositeId } from '@/models/stream/user/userStream.helper'; -import { UserStreamTypes } from '@/models/stream/user/userStream.types'; +import { type UserStreamId, UserStreamTypes } from '@/models/stream/user/userStream.types'; import { UserStreamReach, UserStreamTimeframe } from '@/services/nexus/nexus.types'; +import { queryNexus } from '@/services/nexus/nexus.utils'; import { asInvalid } from '@/test-utils/type-assertions'; +import { NexusUserStreamService } from './userStream'; import { buildUserStreamBodyUrl, userStreamApi } from './userStream.api'; +vi.mock('@/services/nexus/nexus.utils', async (importOriginal) => ({ + ...(await importOriginal()), + queryNexus: vi.fn(), +})); + describe('Users Stream API - Error Control', () => { const mockUserId = 'erztyis9oiaho93ckucetcf5xnxacecqwhbst5hnd7mmkf69dhby'; const mockViewerId = 'viewer-pubky-id'; @@ -290,9 +298,9 @@ describe('Users Stream API - Error Control', () => { }); describe('UserStreamApiEndpoint type', () => { - it('should have exactly 10 endpoints', () => { + it('should have exactly 11 endpoints', () => { const endpointKeys = Object.keys(userStreamApi); - expect(endpointKeys).toHaveLength(10); + expect(endpointKeys).toHaveLength(11); expect(endpointKeys).toContain('followers'); expect(endpointKeys).toContain('following'); expect(endpointKeys).toContain('friends'); @@ -301,6 +309,7 @@ describe('Users Stream API - Error Control', () => { expect(endpointKeys).toContain('postReplies'); expect(endpointKeys).toContain('friendsWithDepth'); expect(endpointKeys).toContain('mostFollowed'); + expect(endpointKeys).toContain('starterPack'); expect(endpointKeys).toContain('username'); expect(endpointKeys).toContain('usersByIds'); }); @@ -395,6 +404,32 @@ describe('NexusUserStreamService.fetch', () => { expect(url).toContain('source=recommended'); }); + + it('should generate correct starter pack URL with ordered comma-joined tags', () => { + const url = userStreamApi.starterPack({ + tags: 'bitcoin,music', + viewer_id: mockUserId, + skip: 0, + limit: 10, + }); + + expect(url).toContain('v0/stream/users/ids?'); + expect(url).toContain('source=starter_pack'); + // URLSearchParams encodes the comma; order must be preserved + expect(url).toContain('tags=bitcoin%2Cmusic'); + expect(url).toContain(`viewer_id=${mockUserId}`); + expect(url).toContain('skip=0'); + expect(url).toContain('limit=10'); + }); + + it('should generate distinct starter pack URLs for reversed tag orders', () => { + const forward = userStreamApi.starterPack({ tags: 'travel,music' }); + const reversed = userStreamApi.starterPack({ tags: 'music,travel' }); + + expect(forward).toContain('tags=travel%2Cmusic'); + expect(reversed).toContain('tags=music%2Ctravel'); + expect(forward).not.toBe(reversed); + }); }); describe('Parameter handling', () => { @@ -444,6 +479,26 @@ describe('NexusUserStreamService.fetch', () => { }); }); + describe('starter pack dispatch', () => { + beforeEach(() => { + vi.mocked(queryNexus).mockReset(); + vi.mocked(queryNexus).mockResolvedValue([]); + }); + + it('should dispatch starter pack IDs to source=starter_pack with ordered tags', async () => { + await NexusUserStreamService.fetch({ + streamId: 'starter_pack:all:all:bitcoin,music' as UserStreamId, + params: { skip: 0, limit: 10, viewer_id: 'viewer-abc' as Pubky }, + }); + + expect(queryNexus).toHaveBeenCalledTimes(1); + const { url } = vi.mocked(queryNexus).mock.calls[0][0]; + expect(url).toContain('source=starter_pack'); + expect(url).toContain('tags=bitcoin%2Cmusic'); + expect(url).toContain('viewer_id=viewer-abc'); + }); + }); + describe('URL structure validation', () => { it('should always start with v0/stream/users/ids?', () => { const url = userStreamApi.followers({ diff --git a/src/core/services/nexus/stream/users/userStream.ts b/src/core/services/nexus/stream/users/userStream.ts index 6c58c1d401..bc36929493 100644 --- a/src/core/services/nexus/stream/users/userStream.ts +++ b/src/core/services/nexus/stream/users/userStream.ts @@ -1,3 +1,6 @@ +import { ValidationErrorCode } from '@/libs/error/error.codes'; +import { Err } from '@/libs/error/error.factories'; +import { ErrorService } from '@/libs/error/error.types'; import { HttpMethod } from '@/libs/http/http.types'; import type { Pubky } from '@/models/models.types'; import type { NexusUser, NexusUserIdsStream } from '@/services/nexus/nexus.types'; @@ -7,6 +10,7 @@ import type { TFetchUserStreamParams, TUserStreamBase, TUserStreamInfluencersParams, + TUserStreamStarterPackParams, TUserStreamUsersByIdsParams, TUserStreamWithUserIdParams, } from '@/services/nexus/stream/users/userStream.types'; @@ -44,8 +48,21 @@ export class NexusUserStreamService { case 'most_followed': url = userStreamApi.mostFollowed(apiParams as TUserStreamBase); break; - default: - throw new Error(`Invalid reach type: ${reach}`); + case 'starter_pack': + url = userStreamApi.starterPack(apiParams as TUserStreamStarterPackParams); + break; + default: { + const exhaustiveCheck: never = reach; + throw Err.validation( + ValidationErrorCode.INVALID_INPUT, + `Unsupported user stream reach: ${String(exhaustiveCheck)}`, + { + service: ErrorService.Nexus, + operation: 'fetch', + context: { streamId }, + }, + ); + } } return await queryNexus({ url }); diff --git a/src/core/services/nexus/stream/users/userStream.types.ts b/src/core/services/nexus/stream/users/userStream.types.ts index 6e3e6ca07b..8f3050e01a 100644 --- a/src/core/services/nexus/stream/users/userStream.types.ts +++ b/src/core/services/nexus/stream/users/userStream.types.ts @@ -17,6 +17,7 @@ export enum UserStreamSource { RECOMMENDED = 'recommended', POST_REPLIES = 'post_replies', MOST_FOLLOWED = 'most_followed', + STARTER_PACK = 'starter_pack', } export type TUserStreamBase = TPaginationParams & { @@ -47,6 +48,13 @@ export type TUserStreamUsernameParams = Omit & { username: string; }; +// tags: comma-joined ordered interest labels (1-5); Nexus rejects `tags` on any other source. +// viewer_id (from TUserStreamBase) doubles as the excluded subject: Nexus drops that user and +// everyone they already follow when user_id is absent. +export type TUserStreamStarterPackParams = TUserStreamBase & { + tags: string; +}; + export type TUserStreamUsersByIdsParams = { user_ids: Pubky[]; viewer_id?: Pubky; @@ -73,4 +81,5 @@ export type TUserStreamQueryParams = | TUserStreamPostRepliesParams | TUserStreamWithDepthParams | TUserStreamBase - | TUserStreamUsernameParams; + | TUserStreamUsernameParams + | TUserStreamStarterPackParams; diff --git a/src/core/services/nexus/stream/users/userStream.utils.test.ts b/src/core/services/nexus/stream/users/userStream.utils.test.ts index cf0758e19e..9b601b18d9 100644 --- a/src/core/services/nexus/stream/users/userStream.utils.test.ts +++ b/src/core/services/nexus/stream/users/userStream.utils.test.ts @@ -1,14 +1,30 @@ import { describe, expect, it } from 'vitest'; +import { AppError } from '@/libs/error/error'; +import { ValidationErrorCode } from '@/libs/error/error.codes'; +import { ErrorCategory } from '@/libs/error/error.types'; import type { Pubky } from '@/models/models.types'; import { type UserStreamId, UserStreamTypes } from '@/models/stream/user/userStream.types'; import { userStreamApi } from '@/services/nexus/stream/users/userStream.api'; import type { TUserStreamBase, TUserStreamInfluencersParams, + TUserStreamStarterPackParams, TUserStreamWithUserIdParams, } from '@/services/nexus/stream/users/userStream.types'; import { createUserStreamParams, streamRequiresUserId } from './userStream.utils'; +const expectValidationError = (fn: () => unknown) => { + try { + fn(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(AppError); + const appError = error as AppError; + expect(appError.category).toBe(ErrorCategory.Validation); + expect(appError.code).toBe(ValidationErrorCode.INVALID_INPUT); + } +}; + describe('createUserStreamParams', () => { const baseParams: TUserStreamBase = { skip: 0, @@ -267,6 +283,105 @@ describe('createUserStreamParams', () => { }); }); + // ============================================================================ + // 4-Part Starter Pack IDs (source:timeframe:reach:tags) + // ============================================================================ + + describe('4-part starter pack IDs (source:timeframe:reach:tags)', () => { + it('should parse a live starter pack ID into starter_pack reach with a tags param', () => { + const streamId = 'starter_pack:all:all:bitcoin,music' as UserStreamId; + + const result = createUserStreamParams(streamId, baseParams); + + expect(result.reach).toBe('starter_pack'); + expect(result.apiParams).toEqual({ + skip: 0, + limit: 20, + tags: 'bitcoin,music', + }); + }); + + it('should preserve tag order in the tags param', () => { + const forward = createUserStreamParams('starter_pack:all:all:travel,music' as UserStreamId, baseParams); + const reversed = createUserStreamParams('starter_pack:all:all:music,travel' as UserStreamId, baseParams); + + expect((forward.apiParams as TUserStreamStarterPackParams).tags).toBe('travel,music'); + expect((reversed.apiParams as TUserStreamStarterPackParams).tags).toBe('music,travel'); + }); + + it('should spread baseParams including viewer_id for live starter pack IDs', () => { + const streamId = 'starter_pack:all:all:bitcoin' as UserStreamId; + const paramsWithViewer: TUserStreamBase = { + skip: 0, + limit: 20, + viewer_id: 'viewer-abc' as Pubky, + }; + + const result = createUserStreamParams(streamId, paramsWithViewer); + + expect(result.apiParams).toEqual({ + skip: 0, + limit: 20, + viewer_id: 'viewer-abc', + tags: 'bitcoin', + }); + }); + + it('should reject an empty tag segment', () => { + expectValidationError(() => createUserStreamParams('starter_pack:all:all:' as UserStreamId, baseParams)); + }); + + it('should reject more than 5 tags', () => { + expectValidationError(() => + createUserStreamParams('starter_pack:all:all:a,b,c,d,e,f' as UserStreamId, baseParams), + ); + }); + + it('should reject noncanonical labels instead of silently normalizing', () => { + expectValidationError(() => createUserStreamParams('starter_pack:all:all:Bitcoin' as UserStreamId, baseParams)); + }); + + it('should reject labels with whitespace or banned characters', () => { + expectValidationError(() => + createUserStreamParams('starter_pack:all:all:rock music' as UserStreamId, baseParams), + ); + expectValidationError(() => createUserStreamParams('starter_pack:all:all:a,,b' as UserStreamId, baseParams)); + }); + + it('should reject overlength labels (>20 chars)', () => { + expectValidationError(() => + createUserStreamParams(`starter_pack:all:all:${'a'.repeat(21)}` as UserStreamId, baseParams), + ); + }); + + it('should reject 4-part IDs whose source is not a starter pack variant', () => { + expectValidationError(() => createUserStreamParams('part1:part2:part3:part4' as UserStreamId, baseParams)); + expectValidationError(() => createUserStreamParams('most_followed:all:all:bitcoin' as UserStreamId, baseParams)); + }); + + it('should reject non-"all" timeframe segments', () => { + expectValidationError(() => createUserStreamParams('starter_pack:today:all:bitcoin' as UserStreamId, baseParams)); + expectValidationError(() => + createUserStreamParams('starter_pack:this_month:all:bitcoin' as UserStreamId, baseParams), + ); + }); + + it('should reject non-"all" reach segments', () => { + expectValidationError(() => + createUserStreamParams('starter_pack:all:friends:bitcoin' as UserStreamId, baseParams), + ); + expectValidationError(() => + createUserStreamParams('starter_pack:all:followers:bitcoin' as UserStreamId, baseParams), + ); + }); + + it('should reject IDs where both timeframe and reach are non-"all"', () => { + expectValidationError(() => + createUserStreamParams('starter_pack:today:friends:bitcoin' as UserStreamId, baseParams), + ); + }); + }); + // ============================================================================ // Edge Cases and Error Handling // ============================================================================ @@ -278,20 +393,19 @@ describe('createUserStreamParams', () => { ); }); - it('should throw error for invalid format (1 part)', () => { + it('should throw a validation error for invalid format (1 part)', () => { const streamId = 'invalid-stream-id' as UserStreamId; + expectValidationError(() => createUserStreamParams(streamId, baseParams)); expect(() => createUserStreamParams(streamId, baseParams)).toThrow( - 'Invalid stream ID: "invalid-stream-id". Expected 2 or 3 parts separated by ":"', + 'Invalid stream ID: expected 2, 3, or 4 parts separated by ":"', ); }); - it('should throw error for invalid format (4+ parts)', () => { - const streamId = 'part1:part2:part3:part4' as UserStreamId; + it('should throw a validation error for invalid format (5+ parts)', () => { + const streamId = 'part1:part2:part3:part4:part5' as UserStreamId; - expect(() => createUserStreamParams(streamId, baseParams)).toThrow( - 'Invalid stream ID: "part1:part2:part3:part4". Expected 2 or 3 parts separated by ":"', - ); + expectValidationError(() => createUserStreamParams(streamId, baseParams)); }); it('should throw error for empty streamId', () => { diff --git a/src/core/services/nexus/stream/users/userStream.utils.ts b/src/core/services/nexus/stream/users/userStream.utils.ts index 377ed52604..6db28a5093 100644 --- a/src/core/services/nexus/stream/users/userStream.utils.ts +++ b/src/core/services/nexus/stream/users/userStream.utils.ts @@ -1,12 +1,16 @@ +import { STARTER_PACK_MAX_TAGS } from '@/config/nexus'; import { ValidationErrorCode } from '@/libs/error/error.codes'; import { Err } from '@/libs/error/error.factories'; import { ErrorService } from '@/libs/error/error.types'; +import { isValidTagLabel } from '@/libs/utils/utils'; import type { Pubky } from '@/models/models.types'; -import type { UserStreamId } from '@/models/stream/user/userStream.types'; +import { USER_STREAM_TAG_DELIMITER } from '@/models/stream/user/userStream.helper'; +import { STARTER_PACK_STREAM_SOURCE, type UserStreamId } from '@/models/stream/user/userStream.types'; import type { UserStreamReach, UserStreamTimeframe } from '@/services/nexus/nexus.types'; import { type TUserStreamBase, type TUserStreamInfluencersParams, + type TUserStreamStarterPackParams, type TUserStreamWithUserIdParams, UserStreamSource, } from '@/services/nexus/stream/users/userStream.types'; @@ -102,7 +106,65 @@ export function createUserStreamParams( }; } - throw new Error(`Invalid stream ID: "${streamId}". Expected 2 or 3 parts separated by "${DELIMITER}"`); + // If we are dealing with source:timeframe:reach:tags format (starter pack only; tags cannot + // contain ':' so 4 parts is exact) + if (parts.length === 4) { + const [source, timeframe, reach, tagSegment] = parts; + + if (source !== STARTER_PACK_STREAM_SOURCE) { + throw Err.validation(ValidationErrorCode.INVALID_INPUT, 'Only starter pack stream IDs carry a tag segment', { + service: ErrorService.Nexus, + operation: 'createUserStreamParams', + context: { streamId }, + }); + } + + // Starter pack requests are always all-time/all-reach; accepting other values here would + // create misleading duplicate cache rows that all map to the same Nexus request + if (timeframe !== 'all' || reach !== 'all') { + throw Err.validation( + ValidationErrorCode.INVALID_INPUT, + 'Starter pack stream IDs require "all" timeframe and reach segments', + { + service: ErrorService.Nexus, + operation: 'createUserStreamParams', + context: { streamId }, + }, + ); + } + + const tags = tagSegment.split(USER_STREAM_TAG_DELIMITER); + const hasInvalidLabel = tags.some((label) => label !== label.trim().toLowerCase() || !isValidTagLabel(label)); + if (tags.length > STARTER_PACK_MAX_TAGS || hasInvalidLabel) { + throw Err.validation( + ValidationErrorCode.INVALID_INPUT, + `Starter pack stream IDs require 1-${STARTER_PACK_MAX_TAGS} canonical (trimmed, lowercase) tags`, + { + service: ErrorService.Nexus, + operation: 'createUserStreamParams', + context: { streamId }, + }, + ); + } + + return { + reach: 'starter_pack', + apiParams: { + ...baseParams, + tags: tags.join(USER_STREAM_TAG_DELIMITER), + }, + } as NexusParamsResult<'starter_pack'>; + } + + throw Err.validation( + ValidationErrorCode.INVALID_INPUT, + `Invalid stream ID: expected 2, 3, or 4 parts separated by "${DELIMITER}"`, + { + service: ErrorService.Nexus, + operation: 'createUserStreamParams', + context: { streamId }, + }, + ); } /** @@ -128,6 +190,7 @@ type UserStreamApiParamsMap = { recommended: TUserStreamWithUserIdParams; influencers: TUserStreamInfluencersParams; most_followed: TUserStreamBase; + starter_pack: TUserStreamStarterPackParams; }; type ReachType = keyof UserStreamApiParamsMap;