I'm building RunHop in public — a social + event platform for running races, built on NestJS.
Today I finished the organization and membership e2e test suites. Along the way, I spent an embarrassing amount of time on a TypeError that came down to one wrong mock.
The Crash
// organization.service.ts
async list(cursor?: string, take: number = 20) {
const args: Prisma.OrganizationFindManyArgs = {
take,
where: { deletedAt: null },
orderBy: { createdAt: 'desc' }
};
if (cursor) {
args.skip = 1;
args.cursor = { id: cursor }
}
const result = await this.prisma.organization.findMany(args);
const nextCursor = result.at(-1)?.id;
return { data: result, meta: { cursor: nextCursor } };
}
The unit test:
it('should return orgs without cursor', async () => {
mockPrisma.organization.findMany.mockResolvedValue(
{ data: [mockOrg], nextCursor: mockOrg.id } // <-- the problem
);
const result = await service.list();
// TypeError: result.at is not a function
});
result.at(-1) crashed because the mock returns an object, not an array. And .at() is an Array method.
Why This Happens
The mock was shaped like the API response — { data: [...], nextCursor: '...' }. That's what the frontend sees. But Prisma's findMany returns a plain array. Always. The { data, meta } wrapping happens later, in a completely different layer.
Here's the actual data flow in a NestJS app with a response interceptor:
Prisma findMany → returns [org1, org2, org3] (plain array)
↓
Service → returns { data: [...], meta: {} } (structured response)
↓
Controller → returns service result as-is
↓
TransformInterceptor → sees data + meta keys → passes through untouched
↓
HTTP Response → { data: [...], meta: { cursor: '...' } }
For non-paginated endpoints, the interceptor wraps automatically:
Service → returns { id: '123', name: 'Org' } (plain object)
↓
TransformInterceptor → no data/meta keys → wraps in { data: response }
↓
HTTP Response → { data: { id: '123', name: 'Org' } }
The interceptor code is straightforward:
// transform.interceptor.ts
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, any> {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((response) => {
if (!response) return { data: null };
if (response.data && response.meta) return response;
return { data: response };
})
);
}
}
When you write a unit test for the service, you're testing everything above the controller. The mock replaces Prisma — so it should return what Prisma returns:
mockPrisma.organization.findMany.mockResolvedValue([mockOrg]); // plain array
The Pagination Test That Compared a List to Itself
While I was at it, I wrote a pagination e2e test that verified page 2 doesn't overlap with page 1. First attempt:
const newList = result.body.data;
const bIds = new Set(newList.map((item) => item.id));
const same = listOrgs.length === newList.length
&& newList.every((item) => bIds.has(item.id));
expect(same).toBe(false);
This always passes. bIds is built from newList, and then we check if every item in newList is in bIds. Of course it is — we're comparing the list against itself.
The fix:
const page2Ids = new Set(newList.map((item) => item.id));
const hasOverlap = listOrgs.some((item) => page2Ids.has(item.id));
expect(hasOverlap).toBe(false);
Now we're checking: do any page 1 items appear in page 2? That's the actual invariant.
Membership E2E: Permission Boundaries
The org-membership suite tests 6 scenarios from the plan:
describe('Add a member', () => {
it('should create and return org membership'); // 201
it('should reject duplicate member'); // 409
});
describe('Update Member', () => {
it('should update member role as owner'); // 200
it('should reject role update as admin'); // 403
});
describe('Remove member', () => {
it('should remove a member'); // 200
it('should reject removing owner'); // 403
});
Setup is three users with distinct roles: owner creates the org (gets OWNER automatically via the transaction in create()), admin gets added with ADMIN role, member gets added with default MEMBER role. Tests run sequentially because membership state carries between them — the admin added in the update suite's beforeAll is still there for the remove tests.
The permission model: the controller calls verifyRole(userId, orgId, minRole) before every mutation. The service checks the caller's role against a hierarchy map:
const ROLE_HIERARCHY: Record<string, number> = {
MEMBER: 1,
ADMIN: 2,
OWNER: 3
};
OWNER can do everything. ADMIN can add and remove members but not change roles. MEMBER can't mutate anything.
Takeaway
Mock the layer you're replacing, not the layer you're consuming. If you're mocking Prisma, return what Prisma returns. If you're mocking a service, return what the service returns. The moment your mock matches a different layer's shape, your test is lying to you — and you'll chase TypeErrors that have nothing to do with your actual logic.
Top comments (0)