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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ PolyBuys/
- Quick start & daily commands: [QUICK_START.md](QUICK_START.md)
- Contribution process and workflow: [docs/contributing.md](docs/contributing.md)
- Self-hosted Convex setup: [docs/SELF_HOSTED_CONVEX.md](docs/SELF_HOSTED_CONVEX.md)
- Schema migration guide: [docs/SCHEMA_MIGRATION.md](docs/SCHEMA_MIGRATION.md)
- Backend specifics: [backend/convex/README.md](backend/convex/README.md)
- Issue/PR templates: [.github/ISSUE_TEMPLATE](.github/ISSUE_TEMPLATE) and [.github/pull_request_template.md](.github/pull_request_template.md)
- Architecture decisions: [docs/adr/](docs/adr/)
Expand Down
16 changes: 16 additions & 0 deletions __mocks__/@convex-dev/auth/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/* eslint-env jest */
// Mock for @convex-dev/auth/server
module.exports = {
getAuthUserId: jest.fn(async (ctx) => {
// In tests, the identity.subject is already the user ID
const identity = await ctx.auth.getUserIdentity();
return identity ? identity.subject : null;
}),
convexAuth: jest.fn(() => ({
auth: jest.fn(),
signIn: jest.fn(),
signOut: jest.fn(),
store: jest.fn(),
isAuthenticated: jest.fn(),
})),
};
214 changes: 214 additions & 0 deletions backend/convex/__tests__/listings-pagination.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// backend/convex/__tests__/listings-pagination.test.ts

/* eslint-disable @typescript-eslint/no-explicit-any */

import { convexTest } from 'convex-test';
import schema from '../schema';
import { api } from '../_generated/api';
import * as listingsModule from '../listings';
import * as profilesModule from '../profiles';
import * as apiModule from '../_generated/api';
import * as serverModule from '../_generated/server';

const modules = {
'../listings.ts': () => Promise.resolve(listingsModule),
'../profiles.ts': () => Promise.resolve(profilesModule),
'../_generated/api.ts': () => Promise.resolve(apiModule),
'../_generated/server.ts': () => Promise.resolve(serverModule),
} as any;

const baseArgs = {
title: 'Great textbook for CSC 202',
description: 'Gently used, highlights in a few chapters.',
price: 50,
category: 'textbooks' as const,
images: ['https://example.com/book1.png'],
condition: 'used' as const,
tags: ['csc202'],
};

const aliceIdentity = { name: 'Alice', subject: 'alice-id', email: 'alice@calpoly.edu' };

/**
* Helper to create a test instance with profile for Alice
*/
async function setupTestWithProfile() {
const t = convexTest(schema as any, modules);

// Create profile for Alice
await t.run(async (ctx: any) => {
await ctx.db.insert('profiles', {
userId: aliceIdentity.subject,
name: aliceIdentity.name,
email: aliceIdentity.email,
major: 'Computer Science',
year: 2025,
joinDate: Date.now(),
rating: 0,
review_count: 0,
});
});

return t;
}

describe('Filtered pagination correctness', () => {
it('searchAndFilterListings with condition filter returns only matching condition', async () => {
const t = await setupTestWithProfile();
const asUser = t.withIdentity(aliceIdentity);

// Create listings with different conditions
await asUser.mutation(api.listings.createListing, {
...baseArgs,
title: 'New Book',
condition: 'new',
price: 100,
});
await asUser.mutation(api.listings.createListing, {
...baseArgs,
title: 'Used Book',
condition: 'used',
price: 50,
});
await asUser.mutation(api.listings.createListing, {
...baseArgs,
title: 'Refurbished Book',
condition: 'refurbished',
price: 75,
});

// Search with price sort (uses price index) and condition filter
const result = await t.query(api.listings.searchAndFilterListings, {
filters: { condition: 'used', sortBy: 'price_asc' },
paginationOpts: { numItems: 10, cursor: null },
});

expect(result.page.length).toBe(1);
expect(result.page[0].condition).toBe('used');
expect(result.page[0].title).toBe('Used Book');
});

it('searchAndFilterListings with condition in price_desc sort enforces filter', async () => {
const t = await setupTestWithProfile();
const asUser = t.withIdentity(aliceIdentity);

await asUser.mutation(api.listings.createListing, {
...baseArgs,
title: 'Expensive New',
condition: 'new',
price: 200,
});
await asUser.mutation(api.listings.createListing, {
...baseArgs,
title: 'Expensive Used',
condition: 'used',
price: 150,
});

const result = await t.query(api.listings.searchAndFilterListings, {
filters: { condition: 'new', sortBy: 'price_desc' },
paginationOpts: { numItems: 10, cursor: null },
});

expect(result.page.length).toBe(1);
expect(result.page[0].condition).toBe('new');
expect(result.page[0].price).toBe(200);
});

it('getListings pagination cursor advances correctly with tag filtering', async () => {
const t = await setupTestWithProfile();
const asUser = t.withIdentity(aliceIdentity);

// Create multiple listings with same tag
for (let i = 0; i < 5; i++) {
await asUser.mutation(api.listings.createListing, {
...baseArgs,
title: `Tagged Listing ${i}`,
tags: ['test-tag'],
});
}

// Create listings without the tag
for (let i = 0; i < 5; i++) {
await asUser.mutation(api.listings.createListing, {
...baseArgs,
title: `Untagged Listing ${i}`,
tags: ['other-tag'],
});
}

// Fetch first page
const page1 = await t.query(api.listings.getListings, {
tags: ['test-tag'],
paginationOpts: { numItems: 2, cursor: null },
});

expect(page1.page.length).toBe(2);
expect(page1.isDone).toBe(false);
expect(page1.continueCursor).not.toBeNull();

// All results should have the tag
page1.page.forEach((listing) => {
expect(listing.tags).toContain('test-tag');
});

// Fetch second page
const page2 = await t.query(api.listings.getListings, {
tags: ['test-tag'],
paginationOpts: { numItems: 2, cursor: page1.continueCursor },
});

expect(page2.page.length).toBe(2);
page2.page.forEach((listing) => {
expect(listing.tags).toContain('test-tag');
});

// Verify no duplicates between pages
const page1Ids = page1.page.map((l) => l._id);
const page2Ids = page2.page.map((l) => l._id);
const intersection = page1Ids.filter((id) => page2Ids.includes(id));
expect(intersection.length).toBe(0);
});

it('searchAndFilterListings pagination with maxPrice does not skip results', async () => {
const t = await setupTestWithProfile();
const asUser = t.withIdentity(aliceIdentity);

// Create listings at various prices
for (let i = 1; i <= 10; i++) {
await asUser.mutation(api.listings.createListing, {
...baseArgs,
title: `Listing ${i}`,
price: i * 10,
});
}

// Fetch with maxPrice filter, paginated
const page1 = await t.query(api.listings.searchAndFilterListings, {
filters: { maxPrice: 50 },
paginationOpts: { numItems: 2, cursor: null },
});

expect(page1.page.length).toBe(2);
page1.page.forEach((listing) => {
expect(listing.price).toBeLessThanOrEqual(50);
});

// Fetch next page
const page2 = await t.query(api.listings.searchAndFilterListings, {
filters: { maxPrice: 50 },
paginationOpts: { numItems: 2, cursor: page1.continueCursor },
});

expect(page2.page.length).toBe(2);
page2.page.forEach((listing) => {
expect(listing.price).toBeLessThanOrEqual(50);
});

// Verify no duplicates
const page1Ids = page1.page.map((l) => l._id);
const page2Ids = page2.page.map((l) => l._id);
const intersection = page1Ids.filter((id) => page2Ids.includes(id));
expect(intersection.length).toBe(0);
});
});
Loading