diff --git a/src/components/notifications/NotificationList.tsx b/src/components/notifications/NotificationList.tsx
index ac73c4b71..a1695486a 100644
--- a/src/components/notifications/NotificationList.tsx
+++ b/src/components/notifications/NotificationList.tsx
@@ -36,7 +36,10 @@ function NotificationList(props: Props) {
if (props.notifications.length === 0) {
return (
-
+
@@ -49,6 +52,7 @@ function NotificationList(props: Props) {
props.onNotificationClick(notification)}
+ data-testid="notification-item"
className={cn(
'p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors cursor-pointer',
!notification.isRead && 'bg-blue-50 dark:bg-blue-900/20',
diff --git a/src/server/api/routers/listings/core.test.ts b/src/server/api/routers/listings/core.test.ts
index 3a8d54798..cda0bb305 100644
--- a/src/server/api/routers/listings/core.test.ts
+++ b/src/server/api/routers/listings/core.test.ts
@@ -12,10 +12,12 @@ const mockReverseLogAction = vi.fn().mockResolvedValue(undefined)
vi.mock('@/lib/trust/service', () => ({
applyTrustAction: (...args: unknown[]) => mockApplyTrustAction(...args),
- TrustService: vi.fn().mockImplementation(() => ({
- logAction: mockLogAction,
- reverseLogAction: mockReverseLogAction,
- })),
+ TrustService: vi.fn().mockImplementation(function MockTrustService() {
+ return {
+ logAction: mockLogAction,
+ reverseLogAction: mockReverseLogAction,
+ }
+ }),
}))
vi.mock('@/server/utils/vote-trust-effects', () => ({
@@ -68,9 +70,11 @@ vi.mock('@/server/utils/security-validation', () => ({
}))
vi.mock('@/server/repositories/listings.repository', () => ({
- ListingsRepository: vi.fn().mockImplementation(() => ({
- getExistingVote: vi.fn().mockResolvedValue(null),
- })),
+ ListingsRepository: vi.fn().mockImplementation(function MockListingsRepository() {
+ return {
+ getExistingVote: vi.fn().mockResolvedValue(null),
+ }
+ }),
}))
const { coreRouter } = await import('./core')
diff --git a/src/server/api/routers/mobile/games.benchmark.test.ts b/src/server/api/routers/mobile/games.benchmark.test.ts
deleted file mode 100644
index 60451dc67..000000000
--- a/src/server/api/routers/mobile/games.benchmark.test.ts
+++ /dev/null
@@ -1,345 +0,0 @@
-/**
- * Benchmark tests for batch Steam App ID lookup endpoint
- *
- * These tests measure:
- * - Response time for different batch sizes
- * - Database query count
- * - Memory usage
- * - Cache effectiveness
- * - Cost estimates
- *
- * Run with: npm test -- games.benchmark.test.ts
- */
-
-import { describe, it, beforeEach, expect, vi, beforeAll, afterAll } from 'vitest'
-import { GamesRepository } from '@/server/repositories/games.repository'
-import { steamBatchQueryCache } from '@/server/utils/cache'
-import { matchSteamAppIdsToNames, validateSteamAppIds } from '@/server/utils/steamGameBatcher'
-import * as steamGameSearch from '@/server/utils/steamGameSearch'
-import { type PrismaClient } from '@orm'
-
-vi.mock('@orm', async () => {
- const actual = await import('@orm')
- return {
- ...actual,
- Prisma: {
- QueryMode: {
- insensitive: 'insensitive',
- },
- SortOrder: {
- asc: 'asc',
- desc: 'desc',
- },
- },
- }
-})
-
-interface BenchmarkResult {
- batchSize: number
- duration: number
- queriesExecuted: number
- memoryUsed: number
- cacheHit: boolean
-}
-
-describe('Batch Steam App ID Lookup - Benchmarks', () => {
- let queryCount = 0
- let mockPrisma: PrismaClient
-
- beforeAll(() => {
- // Mock Prisma to count queries
- mockPrisma = {
- game: {
- findMany: vi.fn(async () => {
- queryCount++
- // Simulate realistic query time
- await new Promise((resolve) => setTimeout(resolve, 50))
- return []
- }),
- count: vi.fn(async () => {
- queryCount++
- return 0
- }),
- },
- } as unknown as PrismaClient
- })
-
- beforeEach(() => {
- queryCount = 0
- steamBatchQueryCache.clear()
- vi.clearAllMocks()
- })
-
- afterAll(() => {
- vi.restoreAllMocks()
- })
-
- async function benchmarkBatchLookup(
- steamAppIds: string[],
- cacheEnabled = true,
- ): Promise {
- const startTime = performance.now()
- const startMemory = process.memoryUsage().heapUsed
-
- const cacheKey = cacheEnabled ? `batch:${steamAppIds.sort().join(',')}:all:10:false` : null
-
- const cacheHit = cacheKey ? steamBatchQueryCache.get(cacheKey) !== undefined : false
-
- if (!cacheHit || !cacheEnabled) {
- queryCount = 0
-
- // Validation
- const validation = validateSteamAppIds(steamAppIds)
- expect(validation.valid).toBe(true)
-
- // Match Steam App IDs to names
- vi.spyOn(steamGameSearch, 'getSteamGamesData').mockResolvedValue(
- steamAppIds.map((id, i) => ({ appid: Number(id), name: `Game ${i}` })),
- )
-
- const matchResults = await matchSteamAppIdsToNames(steamAppIds)
- const steamAppIdToName = new Map()
- for (const match of matchResults) {
- if (match.gameName) {
- steamAppIdToName.set(match.steamAppId, match.gameName)
- }
- }
-
- // Database lookup
- const repository = new GamesRepository(mockPrisma)
- const results = await repository.batchBySteamAppIds(steamAppIdToName, {
- maxListingsPerGame: 10,
- showNsfw: false,
- })
-
- // Cache result if enabled
- if (cacheEnabled && cacheKey) {
- steamBatchQueryCache.set(cacheKey, {
- success: true,
- results,
- totalRequested: steamAppIds.length,
- totalFound: results.filter((r) => r.game !== null).length,
- totalNotFound: results.filter((r) => r.game === null).length,
- })
- }
- }
-
- const endTime = performance.now()
- const endMemory = process.memoryUsage().heapUsed
-
- return {
- batchSize: steamAppIds.length,
- duration: endTime - startTime,
- queriesExecuted: queryCount,
- memoryUsed: endMemory - startMemory,
- cacheHit,
- }
- }
-
- it('Benchmark: 10 Steam App IDs (cold cache)', async () => {
- const steamAppIds = Array.from({ length: 10 }, (_, i) => String(i + 1))
- const result = await benchmarkBatchLookup(steamAppIds, false)
-
- console.log('\nš Benchmark: 10 IDs (cold cache)')
- console.log(` Duration: ${result.duration.toFixed(2)}ms`)
- console.log(` Queries: ${result.queriesExecuted}`)
- console.log(` Memory: ${(result.memoryUsed / 1024 / 1024).toFixed(2)}MB`)
-
- expect(result.queriesExecuted).toBe(1)
- expect(result.duration).toBeLessThan(1000)
- })
-
- it('Benchmark: 10 Steam App IDs (warm cache)', async () => {
- const steamAppIds = Array.from({ length: 10 }, (_, i) => String(i + 1))
-
- // First call to populate cache
- await benchmarkBatchLookup(steamAppIds, true)
-
- // Second call should hit cache
- const result = await benchmarkBatchLookup(steamAppIds, true)
-
- console.log('\nš Benchmark: 10 IDs (warm cache)')
- console.log(` Duration: ${result.duration.toFixed(2)}ms`)
- console.log(` Queries: ${result.queriesExecuted}`)
- console.log(` Cache hit: ${result.cacheHit ? 'YES ā
' : 'NO ā'}`)
-
- expect(result.cacheHit).toBe(true)
- expect(result.queriesExecuted).toBeLessThanOrEqual(1)
- expect(result.duration).toBeLessThan(50)
- })
-
- it('Benchmark: 100 Steam App IDs (cold cache)', async () => {
- const steamAppIds = Array.from({ length: 100 }, (_, i) => String(i + 1))
- const result = await benchmarkBatchLookup(steamAppIds, false)
-
- console.log('\nš Benchmark: 100 IDs (cold cache)')
- console.log(` Duration: ${result.duration.toFixed(2)}ms`)
- console.log(` Queries: ${result.queriesExecuted}`)
- console.log(` Memory: ${(result.memoryUsed / 1024 / 1024).toFixed(2)}MB`)
-
- expect(result.queriesExecuted).toBe(1)
- expect(result.duration).toBeLessThan(2000)
- })
-
- it('Benchmark: 500 Steam App IDs (cold cache)', async () => {
- const steamAppIds = Array.from({ length: 500 }, (_, i) => String(i + 1))
- const result = await benchmarkBatchLookup(steamAppIds, false)
-
- console.log('\nš Benchmark: 500 IDs (cold cache)')
- console.log(` Duration: ${result.duration.toFixed(2)}ms`)
- console.log(` Queries: ${result.queriesExecuted}`)
- console.log(` Memory: ${(result.memoryUsed / 1024 / 1024).toFixed(2)}MB`)
-
- expect(result.queriesExecuted).toBe(1)
- expect(result.duration).toBeLessThan(5000)
- })
-
- it('Benchmark: 900 Steam App IDs (cold cache)', async () => {
- const steamAppIds = Array.from({ length: 900 }, (_, i) => String(i + 1))
- const result = await benchmarkBatchLookup(steamAppIds, false)
-
- console.log('\nš Benchmark: 900 IDs (cold cache)')
- console.log(` Duration: ${result.duration.toFixed(2)}ms`)
- console.log(` Queries: ${result.queriesExecuted}`)
- console.log(` Memory: ${(result.memoryUsed / 1024 / 1024).toFixed(2)}MB`)
-
- expect(result.queriesExecuted).toBe(1)
- expect(result.duration).toBeLessThan(10000)
- })
-
- it('Benchmark: 900 Steam App IDs (warm cache)', async () => {
- const steamAppIds = Array.from({ length: 900 }, (_, i) => String(i + 1))
-
- // First call to populate cache
- await benchmarkBatchLookup(steamAppIds, true)
-
- // Second call should hit cache
- const result = await benchmarkBatchLookup(steamAppIds, true)
-
- console.log('\nš Benchmark: 900 IDs (warm cache)')
- console.log(` Duration: ${result.duration.toFixed(2)}ms`)
- console.log(` Queries: ${result.queriesExecuted}`)
- console.log(` Cache hit: ${result.cacheHit ? 'YES ā
' : 'NO ā'}`)
-
- expect(result.cacheHit).toBe(true)
- expect(result.queriesExecuted).toBeLessThanOrEqual(1)
- expect(result.duration).toBeLessThan(100)
- })
-
- it('Cost Analysis: Estimate database costs', async () => {
- const batchSizes = [10, 100, 500, 900]
- const results: BenchmarkResult[] = []
-
- for (const size of batchSizes) {
- const steamAppIds = Array.from({ length: size }, (_, i) => String(i + 1))
- const result = await benchmarkBatchLookup(steamAppIds, false)
- results.push(result)
- }
-
- console.log('\nš° Cost Analysis (per request)')
- console.log(' Assumptions:')
- console.log(' - PostgreSQL on Vercel: ~$0.000001 per query')
- console.log(' - 5-minute cache TTL = 80% cache hit rate')
- console.log(' - Database query cost: ~$0.000001 per complex query\n')
-
- for (const result of results) {
- const costPerRequest = result.queriesExecuted * 0.000001
- const costWith80PercentCache = costPerRequest * 0.2
-
- console.log(` ${result.batchSize} IDs:`)
- console.log(` Queries: ${result.queriesExecuted}`)
- console.log(` Cost (no cache): $${costPerRequest.toFixed(8)}`)
- console.log(` Cost (80% cache): $${costWith80PercentCache.toFixed(8)}`)
- console.log(` Duration: ${result.duration.toFixed(2)}ms\n`)
- }
-
- console.log(' Projected costs at scale:')
- console.log(' - 1,000 requests/day (900 IDs each, 80% cache):')
- const dailyCost = 1000 * 0.000001 * 0.2
- console.log(` Daily: $${dailyCost.toFixed(6)}`)
- console.log(` Monthly: $${(dailyCost * 30).toFixed(6)}`)
- console.log(` Yearly: $${(dailyCost * 365).toFixed(4)}`)
-
- console.log('\n - 10,000 requests/day (900 IDs each, 80% cache):')
- const dailyCost10k = 10000 * 0.000001 * 0.2
- console.log(` Daily: $${dailyCost10k.toFixed(5)}`)
- console.log(` Monthly: $${(dailyCost10k * 30).toFixed(4)}`)
- console.log(` Yearly: $${(dailyCost10k * 365).toFixed(3)}`)
-
- console.log('\n - 100,000 requests/day (900 IDs each, 80% cache):')
- const dailyCost100k = 100000 * 0.000001 * 0.2
- console.log(` Daily: $${dailyCost100k.toFixed(4)}`)
- console.log(` Monthly: $${(dailyCost100k * 30).toFixed(3)}`)
- console.log(` Yearly: $${(dailyCost100k * 365).toFixed(2)}\n`)
-
- // Assert costs are reasonable
- expect(dailyCost100k).toBeLessThan(0.1) // Less than 10 cents per day for 100k requests
- })
-
- it('Cache Effectiveness: Measure hit rate improvement', async () => {
- const steamAppIds = Array.from({ length: 100 }, (_, i) => String(i + 1))
- const iterations = 10
-
- let cacheHits = 0
- let totalQueries = 0
-
- for (let i = 0; i < iterations; i++) {
- const result = await benchmarkBatchLookup(steamAppIds, true)
- if (result.cacheHit) cacheHits++
- totalQueries += result.queriesExecuted
- }
-
- const cacheHitRate = (cacheHits / iterations) * 100
-
- console.log('\nš Cache Effectiveness (100 IDs, 10 iterations)')
- console.log(` Cache hits: ${cacheHits}/${iterations}`)
- console.log(` Hit rate: ${cacheHitRate.toFixed(1)}%`)
- console.log(` Total queries: ${totalQueries}`)
- console.log(` Queries saved: ${iterations - totalQueries}`)
-
- expect(cacheHitRate).toBeGreaterThan(80)
- })
-
- it('Memory Efficiency: Ensure memory usage is bounded', async () => {
- const initialMemory = process.memoryUsage().heapUsed
-
- // Simulate multiple large requests
- for (let i = 0; i < 10; i++) {
- const steamAppIds = Array.from({ length: 900 }, (_, j) => String(i * 1000 + j))
- await benchmarkBatchLookup(steamAppIds, true)
- }
-
- const finalMemory = process.memoryUsage().heapUsed
- const memoryGrowth = (finalMemory - initialMemory) / 1024 / 1024
-
- console.log('\nš§ Memory Efficiency (10 requests Ć 900 IDs)')
- console.log(` Initial memory: ${(initialMemory / 1024 / 1024).toFixed(2)}MB`)
- console.log(` Final memory: ${(finalMemory / 1024 / 1024).toFixed(2)}MB`)
- console.log(` Memory growth: ${memoryGrowth.toFixed(2)}MB`)
- console.log(` Cache size limit: 100 entries`)
-
- // Memory growth should be bounded by cache size
- expect(memoryGrowth).toBeLessThan(50) // Less than 50MB growth
- })
-
- it('Query Optimization: Verify single query per request', async () => {
- const testCases = [
- { size: 10, label: '10 IDs' },
- { size: 100, label: '100 IDs' },
- { size: 500, label: '500 IDs' },
- { size: 900, label: '900 IDs' },
- ]
-
- console.log('\nš Query Count Verification')
-
- for (const testCase of testCases) {
- const steamAppIds = Array.from({ length: testCase.size }, (_, i) => String(i + 1))
- const result = await benchmarkBatchLookup(steamAppIds, false)
-
- console.log(` ${testCase.label}: ${result.queriesExecuted} query`)
-
- // Should ALWAYS be exactly 1 query regardless of batch size
- expect(result.queriesExecuted).toBe(1)
- }
- })
-})
diff --git a/src/server/api/routers/mobile/listings.test.ts b/src/server/api/routers/mobile/listings.test.ts
index d3d24cf5e..4acebb150 100644
--- a/src/server/api/routers/mobile/listings.test.ts
+++ b/src/server/api/routers/mobile/listings.test.ts
@@ -14,7 +14,9 @@ vi.mock('@/schemas/apiAccess', () => ({
}))
vi.mock('@/server/repositories/api-keys.repository', () => ({
- ApiKeysRepository: vi.fn().mockImplementation(() => ({})),
+ ApiKeysRepository: vi.fn().mockImplementation(function MockApiKeysRepository() {
+ return {}
+ }),
}))
const mockApplyTrustAction = vi.fn().mockResolvedValue(undefined)
@@ -24,7 +26,9 @@ const mockLogAction = vi.fn().mockResolvedValue(undefined)
vi.mock('@/lib/trust/service', () => ({
applyTrustAction: (...args: unknown[]) => mockApplyTrustAction(...args),
- TrustService: vi.fn().mockImplementation(() => ({ logAction: mockLogAction })),
+ TrustService: vi.fn().mockImplementation(function MockTrustService() {
+ return { logAction: mockLogAction }
+ }),
}))
vi.mock('@/server/utils/vote-trust-effects', () => ({
@@ -43,9 +47,11 @@ vi.mock('@/lib/analytics', () => ({
}))
vi.mock('@/server/repositories/comments.repository', () => ({
- CommentsRepository: vi.fn().mockImplementation(() => ({
- listByListing: vi.fn().mockResolvedValue([]),
- })),
+ CommentsRepository: vi.fn().mockImplementation(function MockCommentsRepository() {
+ return {
+ listByListing: vi.fn().mockResolvedValue([]),
+ }
+ }),
}))
const mockEmitNotificationEvent = vi.fn()
diff --git a/src/server/api/routers/pcListings.test.ts b/src/server/api/routers/pcListings.test.ts
index c07ce6ba1..e13578f58 100644
--- a/src/server/api/routers/pcListings.test.ts
+++ b/src/server/api/routers/pcListings.test.ts
@@ -11,7 +11,9 @@ const mockLogAction = vi.fn().mockResolvedValue(undefined)
vi.mock('@/lib/trust/service', () => ({
applyTrustAction: (...args: unknown[]) => mockApplyTrustAction(...args),
- TrustService: vi.fn().mockImplementation(() => ({ logAction: mockLogAction })),
+ TrustService: vi.fn().mockImplementation(function MockTrustService() {
+ return { logAction: mockLogAction }
+ }),
}))
vi.mock('@/server/utils/vote-trust-effects', () => ({
@@ -90,20 +92,24 @@ const mockRepositoryGetExistingVote = vi.fn()
const mockIsDeveloperVerified = vi.fn()
vi.mock('@/server/repositories/pc-listings.repository', () => ({
- PcListingsRepository: vi.fn().mockImplementation(() => ({
- create: mockRepositoryCreate,
- getById: mockRepositoryGetById,
- approve: mockRepositoryApprove,
- reject: mockRepositoryReject,
- getExistingVote: mockRepositoryGetExistingVote,
- isDeveloperVerifiedForEmulator: mockIsDeveloperVerified,
- list: vi.fn().mockResolvedValue({ pcListings: [], pagination: {} }),
- getUserVote: vi.fn().mockResolvedValue(null),
- })),
+ PcListingsRepository: vi.fn().mockImplementation(function MockPcListingsRepository() {
+ return {
+ create: mockRepositoryCreate,
+ getById: mockRepositoryGetById,
+ approve: mockRepositoryApprove,
+ reject: mockRepositoryReject,
+ getExistingVote: mockRepositoryGetExistingVote,
+ isDeveloperVerifiedForEmulator: mockIsDeveloperVerified,
+ list: vi.fn().mockResolvedValue({ pcListings: [], pagination: {} }),
+ getUserVote: vi.fn().mockResolvedValue(null),
+ }
+ }),
}))
vi.mock('@/server/repositories/user-pc-presets.repository', () => ({
- UserPcPresetsRepository: vi.fn().mockImplementation(() => ({})),
+ UserPcPresetsRepository: vi.fn().mockImplementation(function MockUserPcPresetsRepository() {
+ return {}
+ }),
}))
const { pcListingsRouter } = await import('./pcListings')
diff --git a/src/server/api/routers/trust.test.ts b/src/server/api/routers/trust.test.ts
index 5961ca722..ce7207bbe 100644
--- a/src/server/api/routers/trust.test.ts
+++ b/src/server/api/routers/trust.test.ts
@@ -9,9 +9,11 @@ const mockApplyManualAdjustment = vi.fn().mockResolvedValue(undefined)
vi.mock('@/lib/trust/service', () => ({
applyMonthlyActiveBonus: (...args: unknown[]) => mockApplyMonthlyActiveBonus(...args),
- TrustService: vi.fn().mockImplementation(() => ({
- applyManualAdjustment: (...args: unknown[]) => mockApplyManualAdjustment(...args),
- })),
+ TrustService: vi.fn().mockImplementation(function MockTrustService() {
+ return {
+ applyManualAdjustment: (...args: unknown[]) => mockApplyManualAdjustment(...args),
+ }
+ }),
}))
const mockPrismaTrustActionLogFindMany = vi.fn().mockResolvedValue([])
diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts
index f84e4f123..4bca27096 100644
--- a/src/server/api/trpc.ts
+++ b/src/server/api/trpc.ts
@@ -97,9 +97,7 @@ async function createSessionFromClerkUserId(userId: string): Promise
+ handleNotificationEvent(eventData: NotificationEventData): Promise
+ notifyMatchingHardwareUsers(
+ eventData: NotificationEventData,
+ alreadyNotifiedIds: string[],
+ ): Promise
+ notifyGameFollowers(
+ eventData: NotificationEventData,
+ alreadyNotifiedIds: string[],
+ type: 'listing' | 'pcListing',
+ ): Promise
+ mapEventToNotificationType(eventType: string): NotificationType | null
+ enrichContextWithData(
+ eventData: NotificationEventData,
+ notificationType: NotificationType,
+ ): Promise
+}
+
+interface ListingMockOptions {
+ id?: string
+ authorId?: string
+ deviceId?: string
+ gameId?: string
+ gameTitle?: string
+ deviceModelName?: string
+ deviceBrandName?: string
+ socId?: string | null
+ emulatorId?: string
+ emulatorName?: string
+}
-// āā Mocks āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+function getNotificationServiceInternals(
+ service: NotificationService,
+): NotificationServiceInternals {
+ return service as unknown as NotificationServiceInternals
+}
vi.mock('@orm', async () => {
const actual = await import('@orm')
@@ -77,7 +124,9 @@ vi.mock('@/lib/logger', () => ({
const { mockIterateFollowerUserIds, mockScheduleNotification } = vi.hoisted(() => ({
mockIterateFollowerUserIds: vi.fn(),
- mockScheduleNotification: vi.fn().mockReturnValue('batch-id-1'),
+ mockScheduleNotification: vi
+ .fn<(data: NotificationData, scheduledFor?: Date, maxAttempts?: number) => string>()
+ .mockReturnValue('batch-id-1'),
}))
vi.mock('@/server/repositories/game-follow.repository', () => {
class MockGameFollowRepository {
@@ -87,10 +136,14 @@ vi.mock('@/server/repositories/game-follow.repository', () => {
})
vi.mock('@/server/repositories/notification-preferences.repository', () => ({
- NotificationPreferencesRepository: vi.fn().mockImplementation(() => ({
- getByType: vi.fn().mockResolvedValue(null),
- getListingPreference: vi.fn().mockResolvedValue(null),
- })),
+ NotificationPreferencesRepository: vi
+ .fn()
+ .mockImplementation(function MockNotificationPreferencesRepository() {
+ return {
+ getByType: vi.fn().mockResolvedValue(null),
+ getListingPreference: vi.fn().mockResolvedValue(null),
+ }
+ }),
}))
// āā Helpers āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
@@ -120,6 +173,29 @@ function makeEvent(overrides: Partial = {}): Notification
}
}
+function makeListingRecord(options: ListingMockOptions = {}) {
+ const id = options.id ?? 'listing-1'
+ const deviceId = options.deviceId ?? 'device-1'
+ const gameId = options.gameId ?? 'game-1'
+ const gameTitle = options.gameTitle ?? 'Test Game'
+ const emulatorId = options.emulatorId ?? 'emu-1'
+ const emulatorName = options.emulatorName ?? 'Emu'
+
+ return {
+ id,
+ authorId: options.authorId ?? 'author-1',
+ deviceId,
+ game: { id: gameId, title: gameTitle },
+ device: {
+ id: deviceId,
+ modelName: options.deviceModelName ?? 'RP4',
+ brand: { name: options.deviceBrandName ?? 'Retroid' },
+ socId: options.socId ?? null,
+ },
+ emulator: { id: emulatorId, name: emulatorName },
+ }
+}
+
function mockGameFollowIterator(userIds: string[]) {
mockIterateFollowerUserIds.mockImplementation(async function* () {
yield userIds
@@ -127,34 +203,33 @@ function mockGameFollowIterator(userIds: string[]) {
}
function getScheduledUserIds(): string[] {
- return mockScheduleNotification.mock.calls.map((call: { userId: string }[]) => call[0].userId)
+ return mockScheduleNotification.mock.calls.map((call) => call[0].userId)
}
-// āā Tests āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
-
describe('NotificationService', () => {
let service: NotificationService
+ let serviceInternals: NotificationServiceInternals
+ let consoleErrorSpy: ReturnType
beforeEach(async () => {
resetMocks()
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const { NotificationService } = await import('./service')
service = new NotificationService()
+ serviceInternals = getNotificationServiceInternals(service)
})
afterEach(() => {
+ expect(consoleErrorSpy).not.toHaveBeenCalled()
+ consoleErrorSpy.mockRestore()
vi.clearAllMocks()
})
describe('getUsersForEvent', () => {
it('listing.approved returns the listing author', async () => {
- mockPrisma.listing.findUnique.mockResolvedValue({
- authorId: 'author-1',
- deviceId: 'device-1',
- device: { socId: null },
- })
+ mockPrisma.listing.findUnique.mockResolvedValue(makeListingRecord())
- // @ts-expect-error accessing private method for testing
- const users = await service.getUsersForEvent(makeEvent())
+ const users = await serviceInternals.getUsersForEvent(makeEvent())
expect(users).toContain('author-1')
})
@@ -168,8 +243,7 @@ describe('NotificationService', () => {
payload: { pcListingId: 'pc-listing-1' },
})
- // @ts-expect-error accessing private method for testing
- const users = await service.getUsersForEvent(event)
+ const users = await serviceInternals.getUsersForEvent(event)
expect(users).toContain('pc-author-1')
})
@@ -183,46 +257,34 @@ describe('NotificationService', () => {
payload: { pcListingId: 'pc-listing-1' },
})
- // @ts-expect-error accessing private method for testing
- const users = await service.getUsersForEvent(event)
+ const users = await serviceInternals.getUsersForEvent(event)
expect(users).toContain('pc-author-1')
})
it('excludes the actor from recipients', async () => {
- mockPrisma.listing.findUnique.mockResolvedValue({
- authorId: 'admin-1',
- deviceId: 'device-1',
- device: { socId: null },
- })
+ mockPrisma.listing.findUnique.mockResolvedValue(makeListingRecord({ authorId: 'admin-1' }))
- // @ts-expect-error accessing private method for testing
- const users = await service.getUsersForEvent(makeEvent({ triggeredBy: 'admin-1' }))
+ const users = await serviceInternals.getUsersForEvent(makeEvent({ triggeredBy: 'admin-1' }))
expect(users).not.toContain('admin-1')
})
it('filters out banned users', async () => {
- mockPrisma.listing.findUnique.mockResolvedValue({
- authorId: 'banned-author',
- deviceId: 'device-1',
- device: { socId: null },
- })
+ mockPrisma.listing.findUnique.mockResolvedValue(
+ makeListingRecord({ authorId: 'banned-author' }),
+ )
mockPrisma.userBan.findMany.mockResolvedValue([{ userId: 'banned-author' }])
- // @ts-expect-error accessing private method for testing
- const users = await service.getUsersForEvent(makeEvent())
+ const users = await serviceInternals.getUsersForEvent(makeEvent())
expect(users).not.toContain('banned-author')
})
it('filters out users who blocked the triggering user', async () => {
- mockPrisma.listing.findUnique.mockResolvedValue({
- authorId: 'blocker-user',
- deviceId: 'device-1',
- device: { socId: null },
- })
+ mockPrisma.listing.findUnique.mockResolvedValue(
+ makeListingRecord({ authorId: 'blocker-user' }),
+ )
mockPrisma.userRelationship.findMany.mockResolvedValue([{ senderId: 'blocker-user' }])
- // @ts-expect-error accessing private method for testing
- const users = await service.getUsersForEvent(makeEvent())
+ const users = await serviceInternals.getUsersForEvent(makeEvent())
expect(users).not.toContain('blocker-user')
})
@@ -232,8 +294,7 @@ describe('NotificationService', () => {
NOTIFICATION_EVENTS.FOLLOWED_GAME_NEW_PC_LISTING,
]) {
const event = makeEvent({ eventType, payload: { gameId: 'game-1' } })
- // @ts-expect-error accessing private method for testing
- const users = await service.getUsersForEvent(event)
+ const users = await serviceInternals.getUsersForEvent(event)
expect(users).toEqual([])
}
})
@@ -241,23 +302,16 @@ describe('NotificationService', () => {
describe('handleNotificationEvent', () => {
it('listing.approved triggers notifyGameFollowers for handheld', async () => {
- mockPrisma.listing.findUnique.mockResolvedValue({
- authorId: 'author-1',
- deviceId: 'device-1',
- device: { socId: null },
- game: { id: 'game-1', title: 'Test Game' },
- emulator: { name: 'Emu', id: 'emu-1' },
- })
+ mockPrisma.listing.findUnique.mockResolvedValue(makeListingRecord())
mockPrisma.user.findUnique.mockResolvedValue({ name: 'Admin' })
mockPrisma.user.findMany.mockResolvedValue([])
mockPrisma.device.findUnique.mockResolvedValue(null)
mockPrisma.notification.create.mockResolvedValue({ id: 'notif-1' })
mockPrisma.notification.findUnique.mockResolvedValue({ id: 'notif-1' })
- const spy = vi.spyOn(service as never, 'notifyGameFollowers').mockResolvedValue(undefined)
+ const spy = vi.spyOn(serviceInternals, 'notifyGameFollowers').mockResolvedValue(undefined)
- // @ts-expect-error accessing private method for testing
- await service.handleNotificationEvent(makeEvent())
+ await serviceInternals.handleNotificationEvent(makeEvent())
expect(spy).toHaveBeenCalledWith(makeEvent(), ['author-1'], 'listing')
})
@@ -271,7 +325,7 @@ describe('NotificationService', () => {
mockPrisma.notification.create.mockResolvedValue({ id: 'notif-2' })
mockPrisma.notification.findUnique.mockResolvedValue({ id: 'notif-2' })
- const spy = vi.spyOn(service as never, 'notifyGameFollowers').mockResolvedValue(undefined)
+ const spy = vi.spyOn(serviceInternals, 'notifyGameFollowers').mockResolvedValue(undefined)
const event = makeEvent({
eventType: NOTIFICATION_EVENTS.PC_LISTING_APPROVED,
@@ -280,31 +334,25 @@ describe('NotificationService', () => {
payload: { pcListingId: 'pc-listing-1' },
})
- // @ts-expect-error accessing private method for testing
- await service.handleNotificationEvent(event)
+ await serviceInternals.handleNotificationEvent(event)
expect(spy).toHaveBeenCalledWith(event, ['pc-author-1'], 'pcListing')
})
it('listing.approved triggers notifyMatchingHardwareUsers', async () => {
- mockPrisma.listing.findUnique.mockResolvedValue({
- authorId: 'author-1',
- deviceId: 'device-1',
- device: { socId: 'soc-1' },
- game: { id: 'game-1', title: 'Test' },
- emulator: { name: 'Emu', id: 'emu-1' },
- })
+ mockPrisma.listing.findUnique.mockResolvedValue(
+ makeListingRecord({ gameTitle: 'Test', socId: 'soc-1' }),
+ )
mockPrisma.user.findUnique.mockResolvedValue({ name: 'Admin' })
mockPrisma.user.findMany.mockResolvedValue([])
mockPrisma.device.findUnique.mockResolvedValue(null)
mockPrisma.notification.create.mockResolvedValue({ id: 'notif-1' })
mockPrisma.notification.findUnique.mockResolvedValue({ id: 'notif-1' })
- vi.spyOn(service as never, 'notifyGameFollowers').mockResolvedValue(undefined)
- const spy = vi.spyOn(service as never, 'notifyMatchingHardwareUsers')
+ vi.spyOn(serviceInternals, 'notifyGameFollowers').mockResolvedValue(undefined)
+ const spy = vi.spyOn(serviceInternals, 'notifyMatchingHardwareUsers')
- // @ts-expect-error accessing private method for testing
- await service.handleNotificationEvent(makeEvent())
+ await serviceInternals.handleNotificationEvent(makeEvent())
expect(spy).toHaveBeenCalledWith(makeEvent(), ['author-1'])
})
@@ -318,8 +366,8 @@ describe('NotificationService', () => {
mockPrisma.notification.create.mockResolvedValue({ id: 'notif-2' })
mockPrisma.notification.findUnique.mockResolvedValue({ id: 'notif-2' })
- vi.spyOn(service as never, 'notifyGameFollowers').mockResolvedValue(undefined)
- const spy = vi.spyOn(service as never, 'notifyMatchingHardwareUsers')
+ vi.spyOn(serviceInternals, 'notifyGameFollowers').mockResolvedValue(undefined)
+ const spy = vi.spyOn(serviceInternals, 'notifyMatchingHardwareUsers')
const event = makeEvent({
eventType: NOTIFICATION_EVENTS.PC_LISTING_APPROVED,
@@ -328,33 +376,30 @@ describe('NotificationService', () => {
payload: { pcListingId: 'pc-listing-1' },
})
- // @ts-expect-error accessing private method for testing
- await service.handleNotificationEvent(event)
+ await serviceInternals.handleNotificationEvent(event)
expect(spy).not.toHaveBeenCalled()
})
it('does not crash on unknown event types', async () => {
await expect(
- // @ts-expect-error accessing private method for testing
- service.handleNotificationEvent(makeEvent({ eventType: 'unknown.event', payload: {} })),
+ serviceInternals.handleNotificationEvent(
+ makeEvent({ eventType: 'unknown.event', payload: {} }),
+ ),
).resolves.not.toThrow()
})
})
describe('notifyGameFollowers', () => {
it('creates notifications for game followers (handheld)', async () => {
- mockPrisma.listing.findUnique.mockResolvedValue({
- game: { id: 'game-1', title: 'Zelda' },
- })
+ mockPrisma.listing.findUnique.mockResolvedValue(makeListingRecord({ gameTitle: 'Zelda' }))
mockGameFollowIterator(['follower-1', 'follower-2'])
mockPrisma.user.findUnique.mockResolvedValue({ name: 'Admin' })
mockPrisma.game.findUnique.mockResolvedValue({ id: 'game-1', title: 'Zelda' })
mockPrisma.notification.create.mockResolvedValue({ id: 'notif-1' })
mockPrisma.notification.findUnique.mockResolvedValue({ id: 'notif-1' })
- // @ts-expect-error accessing private method for testing
- await service.notifyGameFollowers(
+ await serviceInternals.notifyGameFollowers(
makeEvent({ payload: { listingId: 'listing-1' } }),
['author-1'],
'listing',
@@ -382,8 +427,7 @@ describe('NotificationService', () => {
payload: { pcListingId: 'pc-listing-1' },
})
- // @ts-expect-error accessing private method for testing
- await service.notifyGameFollowers(event, [], 'pcListing')
+ await serviceInternals.notifyGameFollowers(event, [], 'pcListing')
const call = mockScheduleNotification.mock.calls[0][0] as {
userId: string
@@ -394,17 +438,14 @@ describe('NotificationService', () => {
})
it('excludes already-notified users', async () => {
- mockPrisma.listing.findUnique.mockResolvedValue({
- game: { id: 'game-1', title: 'Zelda' },
- })
+ mockPrisma.listing.findUnique.mockResolvedValue(makeListingRecord({ gameTitle: 'Zelda' }))
mockGameFollowIterator(['author-1', 'follower-1'])
mockPrisma.user.findUnique.mockResolvedValue({ name: 'Admin' })
mockPrisma.game.findUnique.mockResolvedValue({ id: 'game-1', title: 'Zelda' })
mockPrisma.notification.create.mockResolvedValue({ id: 'notif-1' })
mockPrisma.notification.findUnique.mockResolvedValue({ id: 'notif-1' })
- // @ts-expect-error accessing private method for testing
- await service.notifyGameFollowers(
+ await serviceInternals.notifyGameFollowers(
makeEvent({ payload: { listingId: 'listing-1' } }),
['author-1'],
'listing',
@@ -416,17 +457,14 @@ describe('NotificationService', () => {
})
it('excludes the triggering user', async () => {
- mockPrisma.listing.findUnique.mockResolvedValue({
- game: { id: 'game-1', title: 'Zelda' },
- })
+ mockPrisma.listing.findUnique.mockResolvedValue(makeListingRecord({ gameTitle: 'Zelda' }))
mockGameFollowIterator(['admin-1', 'follower-1'])
mockPrisma.user.findUnique.mockResolvedValue({ name: 'Admin' })
mockPrisma.game.findUnique.mockResolvedValue({ id: 'game-1', title: 'Zelda' })
mockPrisma.notification.create.mockResolvedValue({ id: 'notif-1' })
mockPrisma.notification.findUnique.mockResolvedValue({ id: 'notif-1' })
- // @ts-expect-error accessing private method for testing
- await service.notifyGameFollowers(
+ await serviceInternals.notifyGameFollowers(
makeEvent({ triggeredBy: 'admin-1', payload: { listingId: 'listing-1' } }),
[],
'listing',
@@ -438,9 +476,7 @@ describe('NotificationService', () => {
})
it('filters out banned followers', async () => {
- mockPrisma.listing.findUnique.mockResolvedValue({
- game: { id: 'game-1', title: 'Zelda' },
- })
+ mockPrisma.listing.findUnique.mockResolvedValue(makeListingRecord({ gameTitle: 'Zelda' }))
mockGameFollowIterator(['banned-user', 'clean-user'])
mockPrisma.userBan.findMany.mockResolvedValue([{ userId: 'banned-user' }])
mockPrisma.user.findUnique.mockResolvedValue({ name: 'Admin' })
@@ -448,8 +484,7 @@ describe('NotificationService', () => {
mockPrisma.notification.create.mockResolvedValue({ id: 'notif-1' })
mockPrisma.notification.findUnique.mockResolvedValue({ id: 'notif-1' })
- // @ts-expect-error accessing private method for testing
- await service.notifyGameFollowers(
+ await serviceInternals.notifyGameFollowers(
makeEvent({ payload: { listingId: 'listing-1' } }),
[],
'listing',
@@ -463,8 +498,7 @@ describe('NotificationService', () => {
it('skips when no game found for listing', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(null)
- // @ts-expect-error accessing private method for testing
- await service.notifyGameFollowers(
+ await serviceInternals.notifyGameFollowers(
makeEvent({ payload: { listingId: 'non-existent' } }),
[],
'listing',
@@ -486,14 +520,12 @@ describe('NotificationService', () => {
]
it.each(mappings)('%s maps to correct NotificationType', (eventType, expectedType) => {
- // @ts-expect-error accessing private method for testing
- const result = service.mapEventToNotificationType(eventType)
+ const result = serviceInternals.mapEventToNotificationType(eventType)
expect(result).toBe(expectedType)
})
it('returns null for unknown event types', () => {
- // @ts-expect-error accessing private method for testing
- expect(service.mapEventToNotificationType('unknown.event')).toBeNull()
+ expect(serviceInternals.mapEventToNotificationType('unknown.event')).toBeNull()
})
})
@@ -504,8 +536,7 @@ describe('NotificationService', () => {
})
mockPrisma.user.findUnique.mockResolvedValue({ name: 'Admin' })
- // @ts-expect-error accessing private method for testing
- const context = await service.enrichContextWithData(
+ const context = await serviceInternals.enrichContextWithData(
makeEvent({
eventType: NOTIFICATION_EVENTS.PC_LISTING_APPROVED,
payload: { pcListingId: 'pc-listing-1' },
@@ -520,16 +551,16 @@ describe('NotificationService', () => {
})
it('enriches handheld listing data when listingId is present', async () => {
- mockPrisma.listing.findUnique.mockResolvedValue({
- id: 'listing-1',
- game: { title: 'Zelda', id: 'game-1' },
- device: { modelName: 'RP4', id: 'dev-1', brand: { name: 'Retroid' } },
- emulator: { name: 'AetherSX2', id: 'emu-1' },
- })
+ mockPrisma.listing.findUnique.mockResolvedValue(
+ makeListingRecord({
+ gameTitle: 'Zelda',
+ deviceId: 'dev-1',
+ emulatorName: 'AetherSX2',
+ }),
+ )
mockPrisma.user.findUnique.mockResolvedValue({ name: 'Admin' })
- // @ts-expect-error accessing private method for testing
- const context = await service.enrichContextWithData(
+ const context = await serviceInternals.enrichContextWithData(
makeEvent({ payload: { listingId: 'listing-1' } }),
NotificationType.LISTING_APPROVED,
)
@@ -541,6 +572,98 @@ describe('NotificationService', () => {
})
})
+ describe('createNotificationFromEvent', () => {
+ it('creates a scheduled listing approval notification from enriched listing data', async () => {
+ mockPrisma.listing.findUnique.mockResolvedValue(
+ makeListingRecord({
+ gameTitle: 'The Legend of Zelda',
+ deviceModelName: 'Pocket S',
+ deviceBrandName: 'AYN',
+ emulatorName: 'Sudachi',
+ }),
+ )
+ mockPrisma.user.findUnique.mockResolvedValue({ name: 'Moderator' })
+
+ const result = await service.createNotificationFromEvent(
+ makeEvent({
+ payload: {
+ listingId: 'listing-1',
+ approvedBy: 'admin-1',
+ approvedAt: '2026-05-13T14:00:00.000Z',
+ },
+ }),
+ 'author-1',
+ )
+
+ expect(result).toBe('batch-id-1')
+ expect(mockPrisma.notification.findFirst).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ userId: 'author-1',
+ type: NotificationType.LISTING_APPROVED,
+ }),
+ }),
+ )
+ expect(mockScheduleNotification).toHaveBeenCalledTimes(1)
+
+ const notification = mockScheduleNotification.mock.calls[0][0]
+ expect(notification).toMatchObject({
+ userId: 'author-1',
+ type: NotificationType.LISTING_APPROVED,
+ category: NotificationCategory.MODERATION,
+ title: 'Your listing has been approved',
+ message: 'Your listing "The Legend of Zelda" has been approved by Moderator',
+ actionUrl: '/listings/listing-1',
+ deliveryChannel: DeliveryChannel.IN_APP,
+ metadata: {
+ listingId: 'listing-1',
+ approvedBy: 'Moderator',
+ approvedAt: '2026-05-13T14:00:00.000Z',
+ },
+ })
+ })
+
+ it('creates a scheduled PC listing approval notification with the PC listing URL', async () => {
+ mockPrisma.pcListing.findUnique.mockResolvedValue({
+ game: { id: 'game-2', title: 'Elden Ring' },
+ })
+ mockPrisma.user.findUnique.mockResolvedValue({ name: 'Moderator' })
+
+ const result = await service.createNotificationFromEvent(
+ makeEvent({
+ eventType: NOTIFICATION_EVENTS.PC_LISTING_APPROVED,
+ entityType: 'pcListing',
+ entityId: 'pc-listing-1',
+ payload: {
+ pcListingId: 'pc-listing-1',
+ approvedBy: 'admin-1',
+ approvedAt: '2026-05-13T14:00:00.000Z',
+ },
+ }),
+ 'pc-author-1',
+ )
+
+ expect(result).toBe('batch-id-1')
+ expect(mockScheduleNotification).toHaveBeenCalledTimes(1)
+
+ const notification = mockScheduleNotification.mock.calls[0][0]
+ expect(notification).toMatchObject({
+ userId: 'pc-author-1',
+ type: NotificationType.LISTING_APPROVED,
+ category: NotificationCategory.MODERATION,
+ title: 'Your listing has been approved',
+ message: 'Your listing "Elden Ring" has been approved by Moderator',
+ actionUrl: '/pc-listings/pc-listing-1',
+ deliveryChannel: DeliveryChannel.IN_APP,
+ metadata: {
+ pcListingId: 'pc-listing-1',
+ approvedBy: 'Moderator',
+ approvedAt: '2026-05-13T14:00:00.000Z',
+ },
+ })
+ })
+ })
+
describe('notification template integration', () => {
it('LISTING_APPROVED uses /pc-listings/ URL for PC listings', async () => {
const { notificationTemplateEngine } = await import('./templates')
diff --git a/src/server/services/vote-nullification.service.test.ts b/src/server/services/vote-nullification.service.test.ts
index 1934eb946..7aa5a1bb0 100644
--- a/src/server/services/vote-nullification.service.test.ts
+++ b/src/server/services/vote-nullification.service.test.ts
@@ -13,10 +13,12 @@ vi.mock('@/server/services/audit.service', () => ({
const mockApplyManualAdjustment = vi.fn()
const mockApplyBulkManualAdjustments = vi.fn()
vi.mock('@/lib/trust/service', () => ({
- TrustService: vi.fn().mockImplementation(() => ({
- applyManualAdjustment: (...args: unknown[]) => mockApplyManualAdjustment(...args),
- applyBulkManualAdjustments: (...args: unknown[]) => mockApplyBulkManualAdjustments(...args),
- })),
+ TrustService: vi.fn().mockImplementation(function MockTrustService() {
+ return {
+ applyManualAdjustment: (...args: unknown[]) => mockApplyManualAdjustment(...args),
+ applyBulkManualAdjustments: (...args: unknown[]) => mockApplyBulkManualAdjustments(...args),
+ }
+ }),
}))
vi.mock('@/utils/wilson-score', () => ({
diff --git a/src/server/utils/vote-trust-effects.test.ts b/src/server/utils/vote-trust-effects.test.ts
index 825c55fb0..3a31978cc 100644
--- a/src/server/utils/vote-trust-effects.test.ts
+++ b/src/server/utils/vote-trust-effects.test.ts
@@ -6,10 +6,12 @@ const mockLogAction = vi.fn()
const mockReverseLogAction = vi.fn()
vi.mock('@/lib/trust/service', () => ({
- TrustService: vi.fn().mockImplementation(() => ({
- logAction: mockLogAction,
- reverseLogAction: mockReverseLogAction,
- })),
+ TrustService: vi.fn().mockImplementation(function MockTrustService() {
+ return {
+ logAction: mockLogAction,
+ reverseLogAction: mockReverseLogAction,
+ }
+ }),
}))
const USER_ID = 'voter-1'
diff --git a/src/test/setup.ts b/src/test/setup.ts
index 63409e145..fef899c12 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -35,25 +35,27 @@ vi.mock('@orm', () => ({
desc: 'desc',
},
},
- PrismaClient: vi.fn().mockImplementation(() => ({
- $transaction: vi.fn(),
- $queryRawTyped: vi.fn().mockResolvedValue([]),
- customFieldDefinition: {
- findMany: vi.fn(),
- findUnique: vi.fn(),
- },
- listing: {
- create: vi.fn(),
- findFirst: vi.fn(),
- },
- listingCustomFieldValue: {
- create: vi.fn(),
- },
- user: {
- findUnique: vi.fn(),
- },
- $disconnect: vi.fn(),
- })),
+ PrismaClient: vi.fn().mockImplementation(function MockPrismaClient() {
+ return {
+ $transaction: vi.fn(),
+ $queryRawTyped: vi.fn().mockResolvedValue([]),
+ customFieldDefinition: {
+ findMany: vi.fn(),
+ findUnique: vi.fn(),
+ },
+ listing: {
+ create: vi.fn(),
+ findFirst: vi.fn(),
+ },
+ listingCustomFieldValue: {
+ create: vi.fn(),
+ },
+ user: {
+ findUnique: vi.fn(),
+ },
+ $disconnect: vi.fn(),
+ }
+ }),
CustomFieldType: {
TEXT: 'TEXT',
TEXTAREA: 'TEXTAREA',
@@ -335,18 +337,31 @@ afterEach(() => {
})
// Mock ResizeObserver for components that use it
-global.ResizeObserver = vi.fn().mockImplementation(() => ({
- observe: vi.fn(),
- unobserve: vi.fn(),
- disconnect: vi.fn(),
-}))
+class MockResizeObserver implements ResizeObserver {
+ constructor(_callback: ResizeObserverCallback) {}
+
+ observe = vi.fn()
+ unobserve = vi.fn()
+ disconnect = vi.fn()
+}
+
+global.ResizeObserver = MockResizeObserver
// Mock IntersectionObserver for components that use it
-global.IntersectionObserver = vi.fn().mockImplementation(() => ({
- observe: vi.fn(),
- unobserve: vi.fn(),
- disconnect: vi.fn(),
-}))
+class MockIntersectionObserver implements IntersectionObserver {
+ readonly root = null
+ readonly rootMargin = ''
+ readonly thresholds = []
+
+ constructor(_callback: IntersectionObserverCallback, _options?: IntersectionObserverInit) {}
+
+ observe = vi.fn()
+ unobserve = vi.fn()
+ disconnect = vi.fn()
+ takeRecords = vi.fn((): IntersectionObserverEntry[] => [])
+}
+
+global.IntersectionObserver = MockIntersectionObserver
// Mock matchMedia for responsive components - only if window is available
if (typeof window !== 'undefined') {
diff --git a/tests/helpers/auth.ts b/tests/helpers/auth.ts
deleted file mode 100644
index 22cd51954..000000000
--- a/tests/helpers/auth.ts
+++ /dev/null
@@ -1,308 +0,0 @@
-import type { Page } from '@playwright/test'
-
-export class AuthHelpers {
- constructor(private page: Page) {}
-
- /**
- * Wait for Clerk to initialize and authentication buttons to be available
- */
- async waitForAuthComponents(timeout = 8000) {
- try {
- // Check if page is still available (prevents Webkit crashes)
- await this.page
- .evaluate(() => document.readyState)
- .catch(() => {
- throw new Error('Page unavailable - browser may have crashed')
- })
-
- // Wait for either sign-in buttons or user button to appear
- await this.page.waitForFunction(
- () => {
- // Look for buttons containing "Sign In" or "Sign Up" text
- const buttons = Array.from(document.querySelectorAll('button'))
- const signInButtons = buttons.filter((btn) =>
- btn.textContent?.toLowerCase().includes('sign in'),
- )
- const signUpButtons = buttons.filter((btn) =>
- btn.textContent?.toLowerCase().includes('sign up'),
- )
- const userButton = document.querySelector('[data-testid="user-button"]')
-
- return signInButtons.length > 0 || signUpButtons.length > 0 || userButton
- },
- { timeout },
- )
- } catch (error: unknown) {
- // Check if it's a browser crash
- if (
- error instanceof Error &&
- error.message.includes('Target page, context or browser has been closed')
- ) {
- throw new Error('Browser crashed during auth component wait')
- }
-
- // If Clerk components don't load, check if basic auth elements are available
- try {
- const hasBasicAuth =
- (await this.page
- .locator('button')
- .filter({ hasText: /sign in|sign up/i })
- .count()) > 0
- if (!hasBasicAuth) {
- throw new Error(
- `Authentication components not found within ${timeout}ms. Clerk may not be properly initialized.`,
- )
- }
- } catch (basicError: unknown) {
- if (
- basicError instanceof Error &&
- basicError.message.includes('Target page, context or browser has been closed')
- ) {
- throw new Error('Browser crashed during basic auth check')
- }
- throw basicError
- }
- }
- }
-
- /**
- * Check if user is authenticated
- */
- async isAuthenticated(): Promise {
- try {
- // Look for user button which indicates authentication
- const userButton = this.page.locator('[data-testid="user-button"]')
- return await userButton.isVisible({ timeout: 2000 })
- } catch {
- return false
- }
- }
-
- /**
- * Get sign in button with better error handling
- */
- async getSignInButton(mobile = false) {
- try {
- await this.waitForAuthComponents()
- } catch {
- // Continue without strict auth component wait
- }
-
- // Try multiple approaches to find the sign-in button
- const selectors = [
- 'button:has-text("Sign In")',
- 'button[data-testid="sign-in"]',
- '[data-clerk-element="signInButton"]',
- 'a:has-text("Sign In")',
- ]
-
- for (const selector of selectors) {
- const button = this.page.locator(selector).first()
- if (
- (await button.count()) > 0 &&
- (await button.isVisible({ timeout: 1000 }).catch(() => false))
- ) {
- return button
- }
- }
-
- // Fallback: use role-based selector
- const button = this.page.getByRole('button', { name: /sign in/i }).first()
- if (await button.isVisible({ timeout: 2000 }).catch(() => false)) {
- return button
- }
-
- throw new Error(
- `Sign In button not found (mobile: ${mobile}). Available buttons: ${await this.getAvailableButtons()}`,
- )
- }
-
- /**
- * Get sign up button with better error handling
- */
- async getSignUpButton(mobile = false) {
- try {
- await this.waitForAuthComponents()
- } catch {
- // Continue without strict auth component wait
- }
-
- // Try multiple approaches to find the sign up button
- const selectors = [
- 'button:has-text("Sign Up")',
- 'button[data-testid="sign-up"]',
- '[data-clerk-element="signUpButton"]',
- 'a:has-text("Sign Up")',
- ]
-
- for (const selector of selectors) {
- const button = this.page.locator(selector).first()
- if (
- (await button.count()) > 0 &&
- (await button.isVisible({ timeout: 1000 }).catch(() => false))
- ) {
- return button
- }
- }
-
- // Fallback: use role-based selector
- const button = this.page.getByRole('button', { name: /sign up/i }).first()
- if (await button.isVisible({ timeout: 2000 }).catch(() => false)) {
- return button
- }
-
- throw new Error(
- `Sign Up button not found (mobile: ${mobile}). Available buttons: ${await this.getAvailableButtons()}`,
- )
- }
-
- /**
- * Click sign in button with better error handling for mobile menu interference
- */
- async clickSignInButton(mobile = false) {
- const button = await this.getSignInButton(mobile)
-
- try {
- // Try normal click first
- await button.click({ timeout: 5000 })
- } catch (error) {
- console.log('Normal click failed, trying force click:', error)
- try {
- // Force click if normal click fails (handles overlapping elements)
- await button.click({ force: true, timeout: 3000 })
- } catch (forceError) {
- console.log('Force click failed, trying JavaScript click:', forceError)
- // Last resort: JavaScript click
- await button.evaluate((el) => (el as HTMLElement).click())
- }
- }
- }
-
- /**
- * Helper to debug available buttons
- */
- private async getAvailableButtons(): Promise {
- const buttons = await this.page.locator('button').all()
- const buttonTexts = await Promise.all(
- buttons.map(async (button) => {
- try {
- const text = await button.textContent()
- const visible = await button.isVisible()
- return `"${text}" (visible: ${visible})`
- } catch {
- return 'error reading button'
- }
- }),
- )
- return buttonTexts.join(', ')
- }
-
- /**
- * Wait for Clerk modal to appear
- */
- async waitForClerkModal(type: 'sign-in' | 'sign-up' = 'sign-in', timeout = 10000) {
- const modalSelectors = [
- '.cl-modal',
- '.cl-modalContent',
- `[data-testid="${type}-modal"]`,
- '.cl-component',
- '.cl-signIn-root',
- '.cl-signUp-root',
- ]
-
- try {
- await this.page.waitForSelector(modalSelectors.join(', '), {
- timeout,
- state: 'visible',
- })
- } catch {
- throw new Error(
- `Clerk ${type} modal not found within ${timeout}ms. Modal selectors tried: ${modalSelectors.join(', ')}`,
- )
- }
- }
-
- /**
- * Check for authentication requirement messages
- */
- async hasAuthRequirement(): Promise {
- const authMessages = [
- 'please sign in to view your profile',
- 'you need to be logged in to access this page',
- 'you need to be logged in to add games',
- 'you need to be logged in to create listings',
- 'you need to be logged in',
- 'please sign in',
- 'sign in required',
- 'authentication required',
- ]
-
- // First check for explicit authentication messages
- for (const message of authMessages) {
- try {
- const hasMessage = await this.page
- .getByText(message, { exact: false })
- .isVisible({ timeout: 2000 })
- if (hasMessage) return true
- } catch {
- // Continue checking other messages
- }
- }
-
- // Check for Clerk SignInButton components
- const clerkSignInButtons = [
- 'button[data-clerk-element="signInButton"]',
- '.cl-signInButton',
- '[data-testid="sign-in-button"]',
- ]
-
- for (const selector of clerkSignInButtons) {
- try {
- const isVisible = await this.page.locator(selector).isVisible({ timeout: 1000 })
- if (isVisible) return true
- } catch {
- // Continue to next selector
- }
- }
-
- // Check for main content area sign in button (indicates auth requirement)
- try {
- const mainSignInButton = this.page.locator('main').getByRole('button', { name: /sign in/i })
- const isVisible = await mainSignInButton.isVisible({ timeout: 2000 })
- if (isVisible) return true
- } catch {
- // Continue to final check
- }
-
- // Check if the page shows a form (which indicates the page is accessible without auth)
- // If there's a form on the page, it means no authentication is required
- try {
- const hasForm = await this.page.locator('form').isVisible({ timeout: 1000 })
- if (hasForm) {
- // Page has a form, so it's accessible without auth
- return false
- }
- } catch {
- // Continue to final check
- }
-
- // Check if main element is empty (which might indicate authentication issues)
- try {
- const mainElement = this.page.locator('main')
- const mainText = await mainElement.textContent({ timeout: 1000 })
- if (!mainText || mainText.trim().length === 0) {
- // Empty main suggests authentication protection that isn't displaying properly
- return false
- }
- } catch {
- // Continue to final check
- }
-
- // Final check for any sign-in button on the page
- try {
- return await this.page.getByRole('button', { name: /sign in/i }).isVisible({ timeout: 1000 })
- } catch {
- return false
- }
- }
-}
diff --git a/tests/helpers/external-services.ts b/tests/helpers/external-services.ts
index 6bdfac229..ec42861e2 100644
--- a/tests/helpers/external-services.ts
+++ b/tests/helpers/external-services.ts
@@ -1,6 +1,30 @@
import type { Page } from '@playwright/test'
+const transparentPng = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
+ 'base64',
+)
+
export async function registerExternalServiceMocks(page: Page) {
+ await page.route(/\/api\/proxy-image(?:\?.*)?$/u, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'image/png',
+ body: transparentPng,
+ })
+ })
+
+ await page.route(
+ /^https:\/\/(?:cdn\.thegamesdb\.net|media\.rawg\.io|images\.igdb\.com|assets\.nintendo\.com)\/.*/u,
+ async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'image/png',
+ body: transparentPng,
+ })
+ },
+ )
+
await page.route('**/api/retrocatalog/**', async (route) => {
await route.fulfill({
status: 200,
diff --git a/tests/notification-system.spec.ts b/tests/notification-system.spec.ts
index 474aeee62..e45dc206e 100644
--- a/tests/notification-system.spec.ts
+++ b/tests/notification-system.spec.ts
@@ -2,10 +2,11 @@ import { test, expect } from './fixtures'
import type { Page } from '@playwright/test'
function bellButton(page: Page) {
- return page
- .locator('button')
- .filter({ has: page.locator('svg.lucide-bell, svg[class*="bell"]') })
- .first()
+ return page.getByRole('button', { name: /open notifications/i })
+}
+
+function notificationDropdown(page: Page) {
+ return page.getByRole('region', { name: /^Notifications$/i })
}
test.describe('Notification System', () => {
@@ -25,7 +26,7 @@ test.describe('Notification System', () => {
await bellButton(page).click()
- await expect(page.getByText('Notifications').first()).toBeVisible()
+ await expect(notificationDropdown(page)).toBeVisible()
})
test('should show close button in dropdown', async ({ page }) => {
@@ -34,7 +35,9 @@ test.describe('Notification System', () => {
await bellButton(page).click()
- const closeButton = page.getByRole('button', { name: /close notifications/i })
+ const closeButton = notificationDropdown(page).getByRole('button', {
+ name: /close notifications/i,
+ })
await expect(closeButton).toBeVisible()
await closeButton.click()
})
@@ -45,10 +48,9 @@ test.describe('Notification System', () => {
await bellButton(page).click()
- const emptyState = page.getByText(/no notifications yet/i)
- const notificationItems = page
- .locator('[class*="hover:bg-gray"]')
- .filter({ has: page.locator('[class*="font-medium"]') })
+ const dropdown = notificationDropdown(page)
+ const emptyState = dropdown.getByTestId('notification-empty-state')
+ const notificationItems = dropdown.getByTestId('notification-item')
await expect(emptyState.or(notificationItems.first())).toBeVisible()
})
@@ -90,10 +92,7 @@ test.describe('Notification System', () => {
await page.goto('/')
await page.waitForLoadState('domcontentloaded')
- const bell = page
- .locator('button')
- .filter({ has: page.locator('svg.lucide-bell, svg[class*="bell"]') })
- await expect(bell).toHaveCount(0)
+ await expect(bellButton(page)).toHaveCount(0)
})
})
})
diff --git a/vitest.config.mts b/vitest.config.mts
index a269fdf17..d4f0f92f6 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -11,17 +11,13 @@ export default defineConfig({
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
exclude: ['playwright.config.ts'],
pool: 'threads',
- poolOptions: {
- threads: {
- singleThread: true,
- },
- },
+ fileParallelism: false,
typecheck: {
enabled: false,
},
testTimeout: 15000,
hookTimeout: 10000,
- reporters: process.env.CI ? ['basic'] : ['default'],
+ reporters: process.env.CI ? [['default', { summary: false }], 'github-actions'] : ['default'],
silent: false,
},
})