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
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ module.exports = {
transform: {
...tsJestTransformCfg,
},
roots: ["<rootDir>/src"],
setupFiles: ["./jest.setup.ts"],
};
99 changes: 99 additions & 0 deletions src/__tests__/integration/creator-list-concurrent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import supertest from 'supertest';
import app from '../../app';
import { prisma } from '../../utils/prisma.utils';

const FIXTURE_SIZE = 50;
const CONCURRENT_REQUESTS = 3;

interface PaginationMeta {
limit: number;
offset: number;
total: number;
hasMore: boolean;
}

interface ResponseEnvelope {
success: boolean;
data: {
items: unknown[];
meta: PaginationMeta;
};
}

describe('GET /api/v1/creators — concurrent requests return consistent results', () => {
beforeAll(async () => {
const usersToCreate = Array.from({ length: FIXTURE_SIZE }).map((_, i) => ({
id: `concurrent-test-user-${i}`,
email: `concurrent-test-user-${i}@example.com`,
passwordHash: 'dummy-hash',
firstName: 'Concurrent',
lastName: `TestUser ${i}`,
}));

await prisma.user.createMany({
data: usersToCreate,
skipDuplicates: true,
});

const creatorsToCreate = Array.from({ length: FIXTURE_SIZE }).map((_, i) => ({
userId: `concurrent-test-user-${i}`,
handle: `concurrent-test-creator-${i}`,
displayName: `Concurrent Test Creator ${i}`,
}));

await prisma.creatorProfile.createMany({
data: creatorsToCreate,
skipDuplicates: true,
});
});

afterAll(async () => {
await prisma.creatorProfile.deleteMany({
where: { handle: { startsWith: 'concurrent-test-creator-' } },
});

await prisma.user.deleteMany({
where: { id: { startsWith: 'concurrent-test-user-' } },
});

await prisma.$disconnect();
});

it('returns identical item sets and metadata across simultaneous requests', async () => {
const requests = Array.from({ length: CONCURRENT_REQUESTS }, () =>
supertest(app).get('/api/v1/creators?limit=20')
);

const responses = await Promise.all(requests);

for (const res of responses) {
expect(res.status).toBe(200);
}

const bodies = responses.map((r) => r.body as ResponseEnvelope);

for (let i = 1; i < bodies.length; i++) {
expect(bodies[i].success).toBe(bodies[0].success);
expect(bodies[i].data.items).toEqual(bodies[0].data.items);
expect(bodies[i].data.meta).toEqual(bodies[0].data.meta);
}
});

it('returns the same total count across all concurrent requests', async () => {
const requests = Array.from({ length: CONCURRENT_REQUESTS }, () =>
supertest(app).get('/api/v1/creators?limit=10')
);

const responses = await Promise.all(requests);

for (const res of responses) {
expect(res.status).toBe(200);
}

const bodies = responses.map((r) => r.body as ResponseEnvelope);

for (const body of bodies) {
expect(body.data.meta.total).toBe(FIXTURE_SIZE);
}
});
});
131 changes: 131 additions & 0 deletions src/__tests__/integration/creator-list-response-shape.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import supertest from 'supertest';
import app from '../../app';
import { prisma } from '../../utils/prisma.utils';

const FIXTURE_SIZE = 150;
const MAX_PAGE_SIZE = 100;

interface PaginationMeta {
limit: number;
offset: number;
total: number;
hasMore: boolean;
}

interface ResponseEnvelope {
success: boolean;
data: {
items: unknown[];
meta: PaginationMeta;
};
}

describe('GET /api/v1/creators — response shape consistency across page sizes', () => {
beforeAll(async () => {
const usersToCreate = Array.from({ length: FIXTURE_SIZE }).map((_, i) => ({
id: `shape-test-user-${i}`,
email: `shape-test-user-${i}@example.com`,
passwordHash: 'dummy-hash',
firstName: 'Shape',
lastName: `TestUser ${i}`,
}));

await prisma.user.createMany({
data: usersToCreate,
skipDuplicates: true,
});

const creatorsToCreate = Array.from({ length: FIXTURE_SIZE }).map((_, i) => ({
userId: `shape-test-user-${i}`,
handle: `shape-test-creator-${i}`,
displayName: `Shape Test Creator ${i}`,
}));

await prisma.creatorProfile.createMany({
data: creatorsToCreate,
skipDuplicates: true,
});
});

afterAll(async () => {
await prisma.creatorProfile.deleteMany({
where: { handle: { startsWith: 'shape-test-creator-' } },
});

await prisma.user.deleteMany({
where: { id: { startsWith: 'shape-test-user-' } },
});

await prisma.$disconnect();
});

async function fetchPage(limit: number): Promise<ResponseEnvelope> {
const res = await supertest(app).get(`/api/v1/creators?limit=${limit}`);
expect(res.status).toBe(200);
return res.body as ResponseEnvelope;
}

it('returns the same top-level response envelope shape for all page sizes', async () => {
const [page1, page10, page100] = await Promise.all([
fetchPage(1),
fetchPage(10),
fetchPage(MAX_PAGE_SIZE),
]);

const topLevelKeys = ['success', 'data'];
expect(Object.keys(page1).sort()).toEqual(topLevelKeys);
expect(Object.keys(page10).sort()).toEqual(topLevelKeys);
expect(Object.keys(page100).sort()).toEqual(topLevelKeys);

const dataKeys = ['items', 'meta'];
expect(Object.keys(page1.data).sort()).toEqual(dataKeys);
expect(Object.keys(page10.data).sort()).toEqual(dataKeys);
expect(Object.keys(page100.data).sort()).toEqual(dataKeys);

const metaKeys = ['limit', 'offset', 'total', 'hasMore'];
expect(Object.keys(page1.data.meta).sort()).toEqual(metaKeys);
expect(Object.keys(page10.data.meta).sort()).toEqual(metaKeys);
expect(Object.keys(page100.data.meta).sort()).toEqual(metaKeys);
});

it('returns items arrays whose lengths match the requested page size', async () => {
const [page1, page10, page100] = await Promise.all([
fetchPage(1),
fetchPage(10),
fetchPage(MAX_PAGE_SIZE),
]);

expect(page1.data.items).toHaveLength(1);
expect(page10.data.items).toHaveLength(10);
expect(page100.data.items).toHaveLength(MAX_PAGE_SIZE);
});

it('returns pagination metadata that reflects the requested page size', async () => {
const [page1, page10, page100] = await Promise.all([
fetchPage(1),
fetchPage(10),
fetchPage(MAX_PAGE_SIZE),
]);

expect(page1.data.meta).toMatchObject({
limit: 1,
offset: 0,
total: FIXTURE_SIZE,
hasMore: true,
});

expect(page10.data.meta).toMatchObject({
limit: 10,
offset: 0,
total: FIXTURE_SIZE,
hasMore: true,
});

expect(page100.data.meta).toMatchObject({
limit: MAX_PAGE_SIZE,
offset: 0,
total: FIXTURE_SIZE,
hasMore: true,
});
});
});
72 changes: 59 additions & 13 deletions src/middlewares/wallet-ownership.middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ const mockedCheck =
typeof walletOwnership.checkCreatorProfileOwnership
>;

const VALID_STELLAR_ADDRESS =
'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';

function buildRes() {
const json = jest.fn();
const status = jest.fn().mockImplementation(() => ({ json }));
Expand Down Expand Up @@ -49,8 +52,36 @@ describe('requireCreatorProfileOwnership', () => {
expect(mockedCheck).not.toHaveBeenCalled();
});

it('returns 400 when the wallet address has wrong length', async () => {
const req = buildReq({ address: 'GSHORT', creatorId: 'alice' });
const res = buildRes();
const next = jest.fn();

await requireCreatorProfileOwnership()(req, res, next as NextFunction);

expect(res.status).toHaveBeenCalledWith(400);
expect(next).not.toHaveBeenCalled();
expect(mockedCheck).not.toHaveBeenCalled();
});

it('returns 400 when the wallet address has invalid characters', async () => {
const req = buildReq({
address:
'G!!!!!AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
creatorId: 'alice',
});
const res = buildRes();
const next = jest.fn();

await requireCreatorProfileOwnership()(req, res, next as NextFunction);

expect(res.status).toHaveBeenCalledWith(400);
expect(next).not.toHaveBeenCalled();
expect(mockedCheck).not.toHaveBeenCalled();
});

it('returns 400 when the path parameter is missing', async () => {
const req = buildReq({ address: 'GABC' });
const req = buildReq({ address: VALID_STELLAR_ADDRESS });
const res = buildRes();
const next = jest.fn();

Expand All @@ -63,26 +94,26 @@ describe('requireCreatorProfileOwnership', () => {
it('returns 401 when the helper reports an unknown wallet', async () => {
mockedCheck.mockResolvedValue({
status: 'wallet_not_found',
address: 'GABC',
address: VALID_STELLAR_ADDRESS,
});
const req = buildReq({ address: 'GABC', creatorId: 'alice' });
const req = buildReq({ address: VALID_STELLAR_ADDRESS, creatorId: 'alice' });
const res = buildRes();
const next = jest.fn();

await requireCreatorProfileOwnership()(req, res, next as NextFunction);

expect(mockedCheck).toHaveBeenCalledWith('GABC', 'alice');
expect(mockedCheck).toHaveBeenCalledWith(VALID_STELLAR_ADDRESS, 'alice');
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});

it('returns 403 when the helper reports forbidden ownership', async () => {
mockedCheck.mockResolvedValue({
status: 'forbidden',
address: 'GABC',
address: VALID_STELLAR_ADDRESS,
ownerUserId: 'someone-else',
});
const req = buildReq({ address: 'GABC', creatorId: 'alice' });
const req = buildReq({ address: VALID_STELLAR_ADDRESS, creatorId: 'alice' });
const res = buildRes();
const next = jest.fn();

Expand All @@ -97,43 +128,58 @@ describe('requireCreatorProfileOwnership', () => {
status: 'granted',
ownerUserId: 'user-1',
});
const req = buildReq({ address: 'GABC', creatorId: 'alice' });
const req = buildReq({ address: VALID_STELLAR_ADDRESS, creatorId: 'alice' });
const res = buildRes();
const next = jest.fn();

await requireCreatorProfileOwnership()(req, res, next as NextFunction);

expect(next).toHaveBeenCalledWith();
expect((req as Request & { walletAddress?: string }).walletAddress).toBe(
'GABC'
VALID_STELLAR_ADDRESS
);
expect((req as Request & { ownerUserId?: string }).ownerUserId).toBe(
'user-1'
);
expect(res.status).not.toHaveBeenCalled();
});

it('uses the first value of an array-form wallet header', async () => {
it('uses the first value of an array-form wallet header when all are valid', async () => {
mockedCheck.mockResolvedValue({
status: 'granted',
ownerUserId: 'user-1',
});
const req = buildReq({
address: ['GFIRST', 'GSECOND'],
address: [VALID_STELLAR_ADDRESS, 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'],
creatorId: 'alice',
});
const res = buildRes();
const next = jest.fn();

await requireCreatorProfileOwnership()(req, res, next as NextFunction);

expect(mockedCheck).toHaveBeenCalledWith('GFIRST', 'alice');
expect(mockedCheck).toHaveBeenCalledWith(VALID_STELLAR_ADDRESS, 'alice');
expect(next).toHaveBeenCalledWith();
});

it('returns 500 when the helper throws', async () => {
it('returns 400 when the first of an array-form wallet header is invalid', async () => {
const req = buildReq({
address: ['GSHORT', VALID_STELLAR_ADDRESS],
creatorId: 'alice',
});
const res = buildRes();
const next = jest.fn();

await requireCreatorProfileOwnership()(req, res, next as NextFunction);

expect(res.status).toHaveBeenCalledWith(400);
expect(next).not.toHaveBeenCalled();
expect(mockedCheck).not.toHaveBeenCalled();
});

it('returns 500 when the helper throws with a valid address', async () => {
mockedCheck.mockRejectedValue(new Error('db down'));
const req = buildReq({ address: 'GABC', creatorId: 'alice' });
const req = buildReq({ address: VALID_STELLAR_ADDRESS, creatorId: 'alice' });
const res = buildRes();
const next = jest.fn();
const errorSpy = jest
Expand Down
Loading
Loading