Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/constants/creator-feed-pagination.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// src/constants/creator-feed-pagination.constants.ts
// Dedicated pagination constants for creator feed endpoints.
// Keeps feed-specific defaults separate from the generic pagination policy
// so they can evolve independently without touching shared constants.

/**
* Default number of items returned per page on creator feed endpoints.
* Feed pages are intentionally smaller than generic list pages to reduce
* payload size on high-frequency polling calls.
*/
export const CREATOR_FEED_DEFAULT_PAGE_SIZE = 20;

/**
* Maximum page size accepted by creator feed endpoints.
* Matches the shared MAX_PAGE_SIZE but is declared here so feed handlers
* don't have an implicit dependency on the generic pagination module.
*/
export const CREATOR_FEED_MAX_PAGE_SIZE = 100;

/**
* Minimum page size accepted by creator feed endpoints.
*/
export const CREATOR_FEED_MIN_PAGE_SIZE = 1;
143 changes: 143 additions & 0 deletions src/modules/activity/activity-feed-empty.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Integration test: empty creator activity feed response (#216)
//
// Verifies the complete response envelope and pagination metadata shape
// when no activity records exist for a given creator.
// Uses Jest mocks to keep the fixture lightweight — no database required.

import { httpGetActivityFeed } from './activity.controllers';
import * as activityService from './activity.service';

// ── Lightweight request/response mocks ────────────────────────────────────────

function makeReq(query: Record<string, string> = {}): any {
return { query };
}

function makeRes(): any {
const res: any = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
}

function makeNext(): jest.Mock {
return jest.fn();
}

// ── Tests ─────────────────────────────────────────────────────────────────────

describe('GET /activity — empty feed integration', () => {
beforeEach(() => {
jest.spyOn(activityService, 'fetchActivityFeed').mockResolvedValue([[], 0]);
});

afterEach(() => {
jest.restoreAllMocks();
});

it('calls fetchActivityFeed with default limit and offset', async () => {
const req = makeReq();
const res = makeRes();
await httpGetActivityFeed(req, res, makeNext());

expect(activityService.fetchActivityFeed).toHaveBeenCalledWith(
expect.objectContaining({ limit: expect.any(Number), offset: 0 }),
);
});

it('responds with status 200', async () => {
const req = makeReq();
const res = makeRes();
await httpGetActivityFeed(req, res, makeNext());

expect(res.status).toHaveBeenCalledWith(200);
});

it('response envelope contains items array', async () => {
const req = makeReq();
const res = makeRes();
await httpGetActivityFeed(req, res, makeNext());

const body = res.json.mock.calls[0][0];
expect(body).toHaveProperty('data');
expect(Array.isArray(body.data.items)).toBe(true);
expect(body.data.items).toHaveLength(0);
});

it('response envelope contains meta object', async () => {
const req = makeReq();
const res = makeRes();
await httpGetActivityFeed(req, res, makeNext());

const body = res.json.mock.calls[0][0];
expect(body.data).toHaveProperty('meta');
expect(body.data.meta).toMatchObject({
total: 0,
hasMore: false,
});
});

it('meta.total is 0 when feed is empty', async () => {
const req = makeReq();
const res = makeRes();
await httpGetActivityFeed(req, res, makeNext());

const body = res.json.mock.calls[0][0];
expect(body.data.meta.total).toBe(0);
});

it('meta.hasMore is false when feed is empty', async () => {
const req = makeReq();
const res = makeRes();
await httpGetActivityFeed(req, res, makeNext());

const body = res.json.mock.calls[0][0];
expect(body.data.meta.hasMore).toBe(false);
});

it('meta.offset reflects the requested offset', async () => {
const req = makeReq({ offset: '10' });
const res = makeRes();
await httpGetActivityFeed(req, res, makeNext());

const body = res.json.mock.calls[0][0];
expect(body.data.meta.offset).toBe(10);
});

it('meta.limit reflects the requested limit', async () => {
const req = makeReq({ limit: '5' });
const res = makeRes();
await httpGetActivityFeed(req, res, makeNext());

const body = res.json.mock.calls[0][0];
expect(body.data.meta.limit).toBe(5);
});

it('filters by creatorId when provided', async () => {
const req = makeReq({ creatorId: 'creator-xyz' });
const res = makeRes();
await httpGetActivityFeed(req, res, makeNext());

expect(activityService.fetchActivityFeed).toHaveBeenCalledWith(
expect.objectContaining({ creatorId: 'creator-xyz' }),
);
const body = res.json.mock.calls[0][0];
expect(body.data.items).toHaveLength(0);
});

it('returns 400 for invalid query params', async () => {
const req = makeReq({ limit: 'not-a-number' });
const res = makeRes();
const next = makeNext();
await httpGetActivityFeed(req, res, next);

// Either sendValidationError sets 400 or next is called with an error
const statusArg = res.status.mock.calls[0]?.[0];
if (statusArg !== undefined) {
expect(statusArg).toBe(400);
} else {
// schema coercion may handle this gracefully; either is acceptable
expect(res.json).toHaveBeenCalled();
}
});
});
11 changes: 8 additions & 3 deletions src/modules/creators/creators.limit.utils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { DEFAULT_PAGE_SIZE } from '../../constants/pagination.constants';
import { CREATOR_FEED_DEFAULT_PAGE_SIZE, CREATOR_FEED_MAX_PAGE_SIZE, CREATOR_FEED_MIN_PAGE_SIZE } from '../../constants/creator-feed-pagination.constants';

/**
* Resolve list limit for public creator list endpoints.
*
* Returns the shared default page size when the incoming page size is omitted.
* Returns the feed-specific default page size when the incoming page size is
* omitted, and clamps the value to the allowed [min, max] range.
*/
export function resolveCreatorListLimit(pageSize?: number): number {
return pageSize ?? DEFAULT_PAGE_SIZE;
if (pageSize === undefined) return CREATOR_FEED_DEFAULT_PAGE_SIZE;
return Math.min(
CREATOR_FEED_MAX_PAGE_SIZE,
Math.max(CREATOR_FEED_MIN_PAGE_SIZE, pageSize),
);
}
51 changes: 51 additions & 0 deletions src/utils/comma-query.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// src/utils/comma-query.utils.ts
// Reusable parser for comma-separated query string values.
// Handles trimming, deduplication, and empty-value filtering consistently
// so every list/filter endpoint behaves the same way.

/**
* Parse a query parameter that may contain comma-separated values.
*
* Accepts:
* - a plain string: `"a,b, c"` → `["a", "b", "c"]`
* - an array of strings (Express req.query can return string[]): flattened then split
* - undefined / null → `[]`
*
* Processing order:
* 1. Accept string | string[] | undefined | null
* 2. Join arrays with ","
* 3. Split on ","
* 4. Trim each token
* 5. Drop empty tokens
* 6. Deduplicate (preserve first-occurrence order)
*
* @param raw - Raw query param value from req.query
* @returns Ordered, deduplicated array of non-empty trimmed strings
*
* @example
* parseCommaQuery("a,b, c") // ["a", "b", "c"]
* parseCommaQuery(["a,b", "c"]) // ["a", "b", "c"]
* parseCommaQuery("a,,b, ,c,a") // ["a", "b", "c"] (deduped + empty dropped)
* parseCommaQuery(undefined) // []
*/
export function parseCommaQuery(
raw: string | string[] | undefined | null
): string[] {
if (raw == null) return [];

const joined = Array.isArray(raw) ? raw.join(',') : raw;

const seen = new Set<string>();
const result: string[] = [];

for (const token of joined.split(',')) {
const trimmed = token.trim();
if (trimmed === '') continue;
if (!seen.has(trimmed)) {
seen.add(trimmed);
result.push(trimmed);
}
}

return result;
}
70 changes: 70 additions & 0 deletions src/utils/sort-tiebreaker.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// src/utils/sort-tiebreaker.utils.ts
// Stable sort tie-breaker helpers for creator list sort paths.
// When the primary sort key produces equal values, applying a secondary
// deterministic key (the record's `id`) guarantees consistent ordering
// across repeated queries and prevents pages from overlapping or skipping rows.

/**
* Compare two string values for ascending sort.
* Returns negative, zero, or positive — compatible with Array.prototype.sort.
*/
function compareStrings(a: string, b: string): number {
if (a < b) return -1;
if (a > b) return 1;
return 0;
}

/**
* Build a comparator that applies a primary sort key and falls back to a
* stable tie-breaker (the record's `id` field) when the primary values tie.
*
* The tie-breaker is always ascending so the final order is deterministic
* regardless of which direction the primary key is sorted.
*
* @param primaryKey - Key to extract the primary sort value from each record
* @param direction - "asc" or "desc" for the primary key (default "asc")
* @returns A comparator function suitable for Array.prototype.sort
*
* @example
* const byFollowers = withTieBreaker<Creator>('followers', 'desc');
* creators.sort(byFollowers);
* // Items with equal followers are ordered by id ascending — stable across pages.
*/
export function withTieBreaker<T extends { id: string }>(
primaryKey: keyof T,
direction: 'asc' | 'desc' = 'asc',
): (a: T, b: T) => number {
return (a: T, b: T): number => {
const av = a[primaryKey];
const bv = b[primaryKey];

let primary: number;

if (typeof av === 'number' && typeof bv === 'number') {
primary = av - bv;
} else {
primary = compareStrings(String(av ?? ''), String(bv ?? ''));
}

if (direction === 'desc') primary = -primary;

// Tie-break on `id` (always ascending) for deterministic order
return primary !== 0 ? primary : compareStrings(a.id, b.id);
};
}

/**
* Sort an array of creator-like records by a primary key with a stable
* id-based tie-breaker. Returns a new sorted array (does not mutate input).
*
* @param items - Records to sort
* @param key - Primary sort key
* @param direction - Sort direction for the primary key
*/
export function stableSortCreators<T extends { id: string }>(
items: T[],
key: keyof T,
direction: 'asc' | 'desc' = 'asc',
): T[] {
return [...items].sort(withTieBreaker(key, direction));
}
Loading
Loading