From c852bbe151b3dfa73de538f7ea3a87563e053ec3 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 22 Aug 2026 17:35:09 +0200 Subject: [PATCH 1/2] test: add handheld listing repository tests and fix search filters --- .../repositories/listings.repository.test.ts | 32 ++++ .../repositories/listings.repository.ts | 17 ++- tests/helpers/data-factory.ts | 4 +- tests/search.spec.ts | 140 ++++++++++++++++++ 4 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 src/server/repositories/listings.repository.test.ts diff --git a/src/server/repositories/listings.repository.test.ts b/src/server/repositories/listings.repository.test.ts new file mode 100644 index 000000000..2bb54d243 --- /dev/null +++ b/src/server/repositories/listings.repository.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { ApprovalStatus, Prisma, Role } from '@orm' +import { ListingsRepository } from './listings.repository' + +const USER_ID = 'user-123' + +describe('handheld listing repository query builder', () => { + it('keeps search and authenticated visibility filters conjunctive', () => { + const where = ListingsRepository.buildListWhere({ + userId: USER_ID, + userRole: Role.USER, + search: 'zelda', + }) + + expect(where).toMatchObject({ + AND: [ + { + OR: expect.arrayContaining([ + { game: { title: { contains: 'zelda', mode: Prisma.QueryMode.insensitive } } }, + ]), + }, + { + OR: [ + { status: ApprovalStatus.APPROVED }, + { status: ApprovalStatus.PENDING, authorId: USER_ID }, + ], + }, + ], + }) + expect(where).not.toHaveProperty('OR') + }) +}) diff --git a/src/server/repositories/listings.repository.ts b/src/server/repositories/listings.repository.ts index 8b9bfdd6b..4e769a061 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 f0c914988..9cfbcb0fc 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 6762fc161..7be8ada30 100644 --- a/tests/search.spec.ts +++ b/tests/search.spec.ts @@ -1,7 +1,147 @@ +import { randomUUID } from 'node:crypto' +import { createPrismaClient } from '@/server/prisma-client' +import { ApprovalStatus, Role } from '@orm' import { test, expect } from './fixtures' +import { withContext } from './helpers/data-factory' import { GamesPage } from './pages/GamesPage' import { ListingsPage } from './pages/ListingsPage' +type HandheldSearchFixture = { + searchTerm: string + matchingId: string + controlId: string + matchingPath: string + controlPath: string +} + +const SEARCH_ACCESS_CASES = [ + { label: 'anonymous', storageState: undefined }, + { label: Role.USER, storageState: 'tests/.auth/user.json' }, + { label: Role.AUTHOR, storageState: 'tests/.auth/author.json' }, + { label: Role.DEVELOPER, storageState: 'tests/.auth/developer.json' }, + { label: Role.MODERATOR, storageState: 'tests/.auth/moderator.json' }, + { label: Role.ADMIN, storageState: 'tests/.auth/admin.json' }, + { label: Role.SUPER_ADMIN, storageState: 'tests/.auth/super_admin.json' }, +] satisfies readonly { label: string; storageState: string | undefined }[] + +async function createHandheldSearchFixture(): Promise { + const prisma = createPrismaClient() + + try { + const author = await prisma.user.findUnique({ + where: { email: 'superadmin@emuready.com' }, + select: { id: true }, + }) + if (!author) throw new Error('Expected seeded super admin for listing search E2E') + + 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 searchTerm = `rolesearch${randomUUID().replaceAll('-', '')}` + const [matchingListing, controlListing] = await prisma.$transaction([ + prisma.listing.create({ + data: { + authorId: author.id, + gameId: game.id, + deviceId: device.id, + emulatorId: emulator.id, + performanceId: performance.id, + status: ApprovalStatus.APPROVED, + processedAt: new Date(), + notes: searchTerm, + }, + select: { id: true }, + }), + prisma.listing.create({ + data: { + authorId: author.id, + gameId: game.id, + deviceId: device.id, + emulatorId: emulator.id, + performanceId: performance.id, + status: ApprovalStatus.APPROVED, + processedAt: new Date(), + notes: `E2E listing search control ${randomUUID()}`, + }, + select: { id: true }, + }), + ]) + + return { + searchTerm, + matchingId: matchingListing.id, + controlId: controlListing.id, + matchingPath: `/listings/${matchingListing.id}`, + controlPath: `/listings/${controlListing.id}`, + } + } finally { + await prisma.$disconnect() + } +} + +async function deleteHandheldSearchFixture(fixture: HandheldSearchFixture): Promise { + const prisma = createPrismaClient() + + try { + await prisma.listing.deleteMany({ + where: { + id: { + in: [fixture.matchingId, fixture.controlId], + }, + }, + }) + } finally { + await prisma.$disconnect() + } +} + +async function withHandheldSearchFixture( + run: (fixture: HandheldSearchFixture) => Promise, +): Promise { + const fixture = await createHandheldSearchFixture() + + 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(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(page.locator(`a[href="${fixture.matchingPath}"]`).first()).toBeVisible() + await expect(listingsPage.listingItems).toHaveCount(1) + await expect(page.locator(`a[href="${fixture.controlPath}"]`)).toHaveCount(0) + }) + }) + }) + } +}) + test.describe('Search Functionality Tests', () => { test('should search for games by title', async ({ page }) => { const gamesPage = new GamesPage(page) From 43e7bdf38203355db0a01b2a4c899edcb8a080a9 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 22 Aug 2026 19:46:33 +0200 Subject: [PATCH 2/2] test: cover pending listing visibility by role --- .../repositories/listings.repository.test.ts | 22 +- tests/search.spec.ts | 219 +++++++++++++----- 2 files changed, 180 insertions(+), 61 deletions(-) diff --git a/src/server/repositories/listings.repository.test.ts b/src/server/repositories/listings.repository.test.ts index 2bb54d243..920bd4aed 100644 --- a/src/server/repositories/listings.repository.test.ts +++ b/src/server/repositories/listings.repository.test.ts @@ -1,28 +1,32 @@ import { describe, expect, it } from 'vitest' import { ApprovalStatus, Prisma, Role } from '@orm' -import { ListingsRepository } from './listings.repository' +import { ListingsRepository, type ListingFilters } from './listings.repository' -const USER_ID = 'user-123' +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({ - userId: USER_ID, - userRole: Role.USER, - search: 'zelda', - }) + const where = ListingsRepository.buildListWhere(FILTERS) expect(where).toMatchObject({ AND: [ { OR: expect.arrayContaining([ - { game: { title: { contains: 'zelda', mode: Prisma.QueryMode.insensitive } } }, + { + game: { + title: { contains: FILTERS.search, mode: Prisma.QueryMode.insensitive }, + }, + }, ]), }, { OR: [ { status: ApprovalStatus.APPROVED }, - { status: ApprovalStatus.PENDING, authorId: USER_ID }, + { status: ApprovalStatus.PENDING, authorId: FILTERS.userId }, ], }, ], diff --git a/tests/search.spec.ts b/tests/search.spec.ts index 7be8ada30..e7c1941bc 100644 --- a/tests/search.spec.ts +++ b/tests/search.spec.ts @@ -1,38 +1,121 @@ import { randomUUID } from 'node:crypto' import { createPrismaClient } from '@/server/prisma-client' -import { ApprovalStatus, Role } from '@orm' +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 - matchingId: string - controlId: string - matchingPath: string - controlPath: 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 }, - { label: Role.USER, storageState: 'tests/.auth/user.json' }, - { label: Role.AUTHOR, storageState: 'tests/.auth/author.json' }, - { label: Role.DEVELOPER, storageState: 'tests/.auth/developer.json' }, - { label: Role.MODERATOR, storageState: 'tests/.auth/moderator.json' }, - { label: Role.ADMIN, storageState: 'tests/.auth/admin.json' }, - { label: Role.SUPER_ADMIN, storageState: 'tests/.auth/super_admin.json' }, -] satisfies readonly { label: string; storageState: string | undefined }[] - -async function createHandheldSearchFixture(): Promise { + { + 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 author = await prisma.user.findUnique({ - where: { email: 'superadmin@emuready.com' }, - select: { id: true }, - }) - if (!author) throw new Error('Expected seeded super admin for listing search E2E') + 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 }, @@ -52,42 +135,65 @@ async function createHandheldSearchFixture(): Promise { 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 searchTerm = `rolesearch${randomUUID().replaceAll('-', '')}` - const [matchingListing, controlListing] = await prisma.$transaction([ + 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: { - authorId: author.id, - gameId: game.id, - deviceId: device.id, - emulatorId: emulator.id, - performanceId: performance.id, - status: ApprovalStatus.APPROVED, - processedAt: new Date(), - notes: searchTerm, - }, + data: createListing(otherAuthor.id, ApprovalStatus.APPROVED, searchTerm), select: { id: true }, }), prisma.listing.create({ - data: { - authorId: author.id, - gameId: game.id, - deviceId: device.id, - emulatorId: emulator.id, - performanceId: performance.id, - status: ApprovalStatus.APPROVED, - processedAt: new Date(), - notes: `E2E listing search control ${randomUUID()}`, - }, + 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, - matchingId: matchingListing.id, - controlId: controlListing.id, - matchingPath: `/listings/${matchingListing.id}`, - controlPath: `/listings/${controlListing.id}`, + listings, } } finally { await prisma.$disconnect() @@ -101,7 +207,7 @@ async function deleteHandheldSearchFixture(fixture: HandheldSearchFixture): Prom await prisma.listing.deleteMany({ where: { id: { - in: [fixture.matchingId, fixture.controlId], + in: Object.values(fixture.listings).map((listing) => listing.id), }, }, }) @@ -111,9 +217,10 @@ async function deleteHandheldSearchFixture(fixture: HandheldSearchFixture): Prom } async function withHandheldSearchFixture( + ownerEmail: string, run: (fixture: HandheldSearchFixture) => Promise, ): Promise { - const fixture = await createHandheldSearchFixture() + const fixture = await createHandheldSearchFixture(ownerEmail) try { await run(fixture) @@ -125,7 +232,7 @@ async function withHandheldSearchFixture( 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(async (fixture) => { + await withHandheldSearchFixture(accessCase.ownerEmail, async (fixture) => { await withContext(browser, accessCase.storageState, async (page) => { const listingsPage = new ListingsPage(page) await listingsPage.goto() @@ -133,9 +240,17 @@ test.describe('Handheld Report search visibility by role', () => { await listingsPage.searchListings(fixture.searchTerm) - await expect(page.locator(`a[href="${fixture.matchingPath}"]`).first()).toBeVisible() - await expect(listingsPage.listingItems).toHaveCount(1) - await expect(page.locator(`a[href="${fixture.controlPath}"]`)).toHaveCount(0) + 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) + } + } }) }) })