diff --git a/src/server/repositories/listings.repository.test.ts b/src/server/repositories/listings.repository.test.ts new file mode 100644 index 00000000..920bd4ae --- /dev/null +++ b/src/server/repositories/listings.repository.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { ApprovalStatus, Prisma, Role } from '@orm' +import { ListingsRepository, type ListingFilters } from './listings.repository' + +const FILTERS = { + userId: 'user-123', + userRole: Role.USER, + search: 'zelda', +} satisfies ListingFilters + +describe('handheld listing repository query builder', () => { + it('keeps search and authenticated visibility filters conjunctive', () => { + const where = ListingsRepository.buildListWhere(FILTERS) + + expect(where).toMatchObject({ + AND: [ + { + OR: expect.arrayContaining([ + { + game: { + title: { contains: FILTERS.search, mode: Prisma.QueryMode.insensitive }, + }, + }, + ]), + }, + { + OR: [ + { status: ApprovalStatus.APPROVED }, + { status: ApprovalStatus.PENDING, authorId: FILTERS.userId }, + ], + }, + ], + }) + expect(where).not.toHaveProperty('OR') + }) +}) diff --git a/src/server/repositories/listings.repository.ts b/src/server/repositories/listings.repository.ts index 8b9bfdd6..4e769a06 100644 --- a/src/server/repositories/listings.repository.ts +++ b/src/server/repositories/listings.repository.ts @@ -183,9 +183,8 @@ export class ListingsRepository extends BaseRepository { * Build the where clause for listing queries * @param filters - Listing filter options including search, IDs, and user context * @returns Prisma where clause object - * @private */ - private buildWhereClause(filters: ListingFilters): Prisma.ListingWhereInput { + static buildListWhere(filters: ListingFilters): Prisma.ListingWhereInput { const where: Prisma.ListingWhereInput = {} let gameFilter: Prisma.GameWhereInput = {} @@ -265,9 +264,15 @@ export class ListingsRepository extends BaseRepository { ) if (statusFilter) { if (Array.isArray(statusFilter)) { - where.OR = where.OR - ? [...(Array.isArray(where.OR) ? where.OR : [where.OR]), ...statusFilter] - : statusFilter + if (where.OR) { + const existingAnd = Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : [] + const existingOr = Array.isArray(where.OR) ? where.OR : [where.OR] + + where.AND = [...existingAnd, { OR: existingOr }, { OR: statusFilter }] + delete where.OR + } else { + where.OR = statusFilter + } } else { Object.assign(where, statusFilter) } @@ -300,7 +305,7 @@ export class ListingsRepository extends BaseRepository { const limit = filters.limit || 20 const offset = calculateOffset({ page: filters.page, offset: filters.offset }, limit) - const where = this.buildWhereClause(filters) + const where = ListingsRepository.buildListWhere(filters) // Build order by clause - now includes native success rate sorting! const orderBy = this.buildOrderBy(filters.sortField, filters.sortDirection) diff --git a/tests/helpers/data-factory.ts b/tests/helpers/data-factory.ts index f0c91498..9cfbcb0f 100644 --- a/tests/helpers/data-factory.ts +++ b/tests/helpers/data-factory.ts @@ -667,10 +667,10 @@ export async function expectOwnPcReportBlocked(page: Page): Promise { export async function withContext( browser: Browser, - storageState: string, + storageState: string | undefined, fn: (page: Page) => Promise, ) { - const ctx = await browser.newContext({ storageState }) + const ctx = storageState ? await browser.newContext({ storageState }) : await browser.newContext() await registerCookieConsent(ctx) const page = await ctx.newPage() await registerExternalServiceMocks(page) diff --git a/tests/search.spec.ts b/tests/search.spec.ts index 6762fc16..e7c1941b 100644 --- a/tests/search.spec.ts +++ b/tests/search.spec.ts @@ -1,7 +1,262 @@ +import { randomUUID } from 'node:crypto' +import { createPrismaClient } from '@/server/prisma-client' +import { ApprovalStatus, Role, type Prisma } from '@orm' import { test, expect } from './fixtures' +import { withContext } from './helpers/data-factory' import { GamesPage } from './pages/GamesPage' import { ListingsPage } from './pages/ListingsPage' +const SEARCH_LISTING_KEYS = [ + 'approvedMatch', + 'approvedControl', + 'ownerPendingMatch', + 'ownerPendingControl', + 'otherPendingMatch', +] as const + +type SearchListingKey = (typeof SEARCH_LISTING_KEYS)[number] + +type HandheldSearchFixture = { + searchTerm: string + listings: Record +} + +type SearchAccessCase = { + label: string + storageState: string | undefined + ownerEmail: string + expectedListings: readonly SearchListingKey[] +} + +const E2E_USERS = { + [Role.USER]: { + ownerEmail: 'user@emuready.com', + storageState: 'tests/.auth/user.json', + }, + [Role.AUTHOR]: { + ownerEmail: 'author@emuready.com', + storageState: 'tests/.auth/author.json', + }, + [Role.DEVELOPER]: { + ownerEmail: 'developer@emuready.com', + storageState: 'tests/.auth/developer.json', + }, + [Role.MODERATOR]: { + ownerEmail: 'moderator@emuready.com', + storageState: 'tests/.auth/moderator.json', + }, + [Role.ADMIN]: { + ownerEmail: 'admin@emuready.com', + storageState: 'tests/.auth/admin.json', + }, + [Role.SUPER_ADMIN]: { + ownerEmail: 'superadmin@emuready.com', + storageState: 'tests/.auth/super_admin.json', + }, +} satisfies Record + +const PUBLIC_RESULTS: readonly SearchListingKey[] = ['approvedMatch'] +const AUTHENTICATED_RESULTS: readonly SearchListingKey[] = ['approvedMatch', 'ownerPendingMatch'] +const MODERATOR_RESULTS: readonly SearchListingKey[] = [ + 'approvedMatch', + 'ownerPendingMatch', + 'otherPendingMatch', +] + +const SEARCH_ACCESS_CASES = [ + { + label: 'anonymous', + storageState: undefined, + ownerEmail: E2E_USERS[Role.USER].ownerEmail, + expectedListings: PUBLIC_RESULTS, + }, + { + label: Role.USER, + ...E2E_USERS[Role.USER], + expectedListings: AUTHENTICATED_RESULTS, + }, + { + label: Role.AUTHOR, + ...E2E_USERS[Role.AUTHOR], + expectedListings: AUTHENTICATED_RESULTS, + }, + { + label: Role.DEVELOPER, + ...E2E_USERS[Role.DEVELOPER], + expectedListings: AUTHENTICATED_RESULTS, + }, + { + label: Role.MODERATOR, + ...E2E_USERS[Role.MODERATOR], + expectedListings: MODERATOR_RESULTS, + }, + { + label: Role.ADMIN, + ...E2E_USERS[Role.ADMIN], + expectedListings: MODERATOR_RESULTS, + }, + { + label: Role.SUPER_ADMIN, + ...E2E_USERS[Role.SUPER_ADMIN], + expectedListings: MODERATOR_RESULTS, + }, +] satisfies readonly SearchAccessCase[] + +async function createHandheldSearchFixture(ownerEmail: string): Promise { + const prisma = createPrismaClient() + + try { + const otherAuthorEmail = + ownerEmail === E2E_USERS[Role.AUTHOR].ownerEmail + ? E2E_USERS[Role.USER].ownerEmail + : E2E_USERS[Role.AUTHOR].ownerEmail + const [owner, otherAuthor] = await Promise.all([ + prisma.user.findUnique({ where: { email: ownerEmail }, select: { id: true } }), + prisma.user.findUnique({ where: { email: otherAuthorEmail }, select: { id: true } }), + ]) + if (!owner) throw new Error(`Expected seeded listing owner: ${ownerEmail}`) + if (!otherAuthor) throw new Error(`Expected seeded listing author: ${otherAuthorEmail}`) + + const game = await prisma.game.findFirst({ + where: { status: ApprovalStatus.APPROVED, isErotic: false }, + select: { id: true, systemId: true }, + }) + if (!game) throw new Error('Expected an approved game for listing search E2E') + + const [device, emulator, performance] = await Promise.all([ + prisma.device.findFirst({ select: { id: true } }), + prisma.emulator.findFirst({ + where: { systems: { some: { id: game.systemId } } }, + select: { id: true }, + }), + prisma.performanceScale.findFirst({ select: { id: true } }), + ]) + if (!device) throw new Error('Expected a device for listing search E2E') + if (!emulator) throw new Error('Expected an emulator for listing search E2E') + if (!performance) throw new Error('Expected a performance scale for listing search E2E') + + const fixtureToken = randomUUID().replaceAll('-', '') + const searchTerm = `rolesearch${fixtureToken}` + const controlTerm = `rolecontrol${fixtureToken}` + const createListing = ( + authorId: string, + status: ApprovalStatus, + notes: string, + ): Prisma.ListingUncheckedCreateInput => ({ + authorId, + gameId: game.id, + deviceId: device.id, + emulatorId: emulator.id, + performanceId: performance.id, + status, + processedAt: status === ApprovalStatus.APPROVED ? new Date() : null, + notes, + }) + + const [ + approvedMatch, + approvedControl, + ownerPendingMatch, + ownerPendingControl, + otherPendingMatch, + ] = await prisma.$transaction([ + prisma.listing.create({ + data: createListing(otherAuthor.id, ApprovalStatus.APPROVED, searchTerm), + select: { id: true }, + }), + prisma.listing.create({ + data: createListing(otherAuthor.id, ApprovalStatus.APPROVED, controlTerm), + select: { id: true }, + }), + prisma.listing.create({ + data: createListing(owner.id, ApprovalStatus.PENDING, searchTerm), + select: { id: true }, + }), + prisma.listing.create({ + data: createListing(owner.id, ApprovalStatus.PENDING, controlTerm), + select: { id: true }, + }), + prisma.listing.create({ + data: createListing(otherAuthor.id, ApprovalStatus.PENDING, searchTerm), + select: { id: true }, + }), + ]) + + const toFixtureListing = (id: string) => ({ id, path: `/listings/${id}` }) + const listings: HandheldSearchFixture['listings'] = { + approvedMatch: toFixtureListing(approvedMatch.id), + approvedControl: toFixtureListing(approvedControl.id), + ownerPendingMatch: toFixtureListing(ownerPendingMatch.id), + ownerPendingControl: toFixtureListing(ownerPendingControl.id), + otherPendingMatch: toFixtureListing(otherPendingMatch.id), + } + + return { + searchTerm, + listings, + } + } finally { + await prisma.$disconnect() + } +} + +async function deleteHandheldSearchFixture(fixture: HandheldSearchFixture): Promise { + const prisma = createPrismaClient() + + try { + await prisma.listing.deleteMany({ + where: { + id: { + in: Object.values(fixture.listings).map((listing) => listing.id), + }, + }, + }) + } finally { + await prisma.$disconnect() + } +} + +async function withHandheldSearchFixture( + ownerEmail: string, + run: (fixture: HandheldSearchFixture) => Promise, +): Promise { + const fixture = await createHandheldSearchFixture(ownerEmail) + + try { + await run(fixture) + } finally { + await deleteHandheldSearchFixture(fixture) + } +} + +test.describe('Handheld Report search visibility by role', () => { + for (const accessCase of SEARCH_ACCESS_CASES) { + test(`filters results for ${accessCase.label}`, async ({ browser }) => { + await withHandheldSearchFixture(accessCase.ownerEmail, async (fixture) => { + await withContext(browser, accessCase.storageState, async (page) => { + const listingsPage = new ListingsPage(page) + await listingsPage.goto() + await listingsPage.verifyPageLoaded() + + await listingsPage.searchListings(fixture.searchTerm) + + await expect(listingsPage.listingItems).toHaveCount(accessCase.expectedListings.length) + + for (const key of SEARCH_LISTING_KEYS) { + const listing = fixture.listings[key] + const link = page.locator(`a[href="${listing.path}"]`) + if (accessCase.expectedListings.includes(key)) { + await expect(link.first()).toBeVisible() + } else { + await expect(link).toHaveCount(0) + } + } + }) + }) + }) + } +}) + test.describe('Search Functionality Tests', () => { test('should search for games by title', async ({ page }) => { const gamesPage = new GamesPage(page)