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
36 changes: 36 additions & 0 deletions src/server/repositories/listings.repository.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
17 changes: 11 additions & 6 deletions src/server/repositories/listings.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions tests/helpers/data-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,10 +667,10 @@ export async function expectOwnPcReportBlocked(page: Page): Promise<void> {

export async function withContext(
browser: Browser,
storageState: string,
storageState: string | undefined,
fn: (page: Page) => Promise<void>,
) {
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)
Expand Down
255 changes: 255 additions & 0 deletions tests/search.spec.ts
Original file line number Diff line number Diff line change
@@ -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<SearchListingKey, { id: string; path: string }>
}

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<Role, { ownerEmail: string; storageState: string }>

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<HandheldSearchFixture> {
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<void> {
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<void>,
): Promise<void> {
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)
Expand Down
Loading