From 86381db883bceb6acb5f3c347be7983de13924b3 Mon Sep 17 00:00:00 2001 From: ObfuscatedVoid Date: Fri, 27 Mar 2026 23:17:06 +0100 Subject: [PATCH 1/6] feat: add GameNative emulator support with custom fields and configuration parsing --- prisma/seed.ts | 3 + .../seeders/gamenativeCustomFieldsSeeder.ts | 462 +++++++++++++ src/app/listings/new/NewListingPage.tsx | 32 +- .../gamenative/gamenative.converter.test.ts | 460 ++++++++----- .../gamenative/gamenative.converter.ts | 349 ++++------ .../gamenative/gamenative.defaults.ts | 315 ++++++--- .../gamenative/gamenative.types.ts | 323 +++++---- .../emulator-config/gamenative/index.ts | 13 + .../emulator-config/gamenative/mapping.ts | 207 ++++++ .../emulator-config/gamenative/parser.test.ts | 627 ++++++++++++++++++ .../emulator-config/gamenative/parser.ts | 74 +++ 11 files changed, 2201 insertions(+), 664 deletions(-) create mode 100644 prisma/seeders/gamenativeCustomFieldsSeeder.ts create mode 100644 src/shared/emulator-config/gamenative/index.ts create mode 100644 src/shared/emulator-config/gamenative/mapping.ts create mode 100644 src/shared/emulator-config/gamenative/parser.test.ts create mode 100644 src/shared/emulator-config/gamenative/parser.ts diff --git a/prisma/seed.ts b/prisma/seed.ts index e10128cd..63596a09 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -6,6 +6,7 @@ import customFieldTemplatesSeeder from './seeders/customFieldTemplatesSeeder' import devicesSeeder from './seeders/devicesSeeder' import edenCustomFieldsSeeder from './seeders/edenCustomFieldsSeeder' import emulatorsSeeder from './seeders/emulatorsSeeder' +import gamenativeCustomFieldsSeeder from './seeders/gamenativeCustomFieldsSeeder' import gamesSeeder from './seeders/gamesSeeder' import gpuSeeder from './seeders/gpuSeeder' import listingsSeeder from './seeders/listingsSeeder' @@ -129,6 +130,7 @@ async function main() { await customFieldTemplatesSeeder(prisma) await azaharCustomFieldsSeeder(prisma) await edenCustomFieldsSeeder(prisma) + await gamenativeCustomFieldsSeeder(prisma) console.info('✅ Custom fields seeded successfully!') } catch (error) { console.error('❌ Error seeding custom fields:', error) @@ -188,6 +190,7 @@ async function main() { await emulatorsSeeder(prisma) await azaharCustomFieldsSeeder(prisma) await edenCustomFieldsSeeder(prisma) + await gamenativeCustomFieldsSeeder(prisma) await customFieldTemplatesSeeder(prisma) await socSeeder(prisma) await cpuSeeder(prisma) diff --git a/prisma/seeders/gamenativeCustomFieldsSeeder.ts b/prisma/seeders/gamenativeCustomFieldsSeeder.ts new file mode 100644 index 00000000..c233f992 --- /dev/null +++ b/prisma/seeders/gamenativeCustomFieldsSeeder.ts @@ -0,0 +1,462 @@ +import { CustomFieldType, Prisma, type PrismaClient } from '@orm' + +interface SelectOption { + value: string + label: string +} + +interface GameNativeCustomFieldSeed { + name: string + label: string + type: CustomFieldType + required: boolean + displayOrder: number + defaultValue?: string | number | boolean | null + placeholder?: string | null + options?: SelectOption[] +} + +const GAMENATIVE_EMULATOR_NAME = 'GameNative' + +const GAMENATIVE_CUSTOM_FIELDS: GameNativeCustomFieldSeed[] = [ + { + name: 'emulator_version', + label: 'Emulator Version', + type: CustomFieldType.TEXT, + required: true, + displayOrder: 0, + defaultValue: null, + }, + { + name: 'graphics_driver', + label: 'Graphics Driver', + type: CustomFieldType.SELECT, + required: true, + displayOrder: 1, + defaultValue: 'Vortek (Universal)', + options: [ + { value: 'Vortek (Universal)', label: 'Vortek (Universal)' }, + { value: 'Turnip (Adreno)', label: 'Turnip (Adreno)' }, + { value: 'VirGL (Universal)', label: 'VirGL (Universal)' }, + { value: 'Adreno (Adreno)', label: 'Adreno (Adreno)' }, + { value: 'SD 8 Elite (SD 8 Elite)', label: 'SD 8 Elite (SD 8 Elite)' }, + { value: 'Wrapper', label: 'Wrapper (Bionic)' }, + { value: 'Wrapper-v2', label: 'Wrapper-v2 (Bionic)' }, + { value: 'Wrapper-leegao', label: 'Wrapper-leegao (Bionic)' }, + { value: 'Wrapper-legacy', label: 'Wrapper-legacy (Bionic)' }, + ], + }, + { + name: 'dx_wrapper', + label: 'DX Wrapper', + type: CustomFieldType.SELECT, + required: true, + displayOrder: 2, + defaultValue: 'DXVK', + options: [ + { value: 'WineD3D', label: 'WineD3D' }, + { value: 'DXVK', label: 'DXVK' }, + { value: 'VKD3D', label: 'VKD3D' }, + { value: 'CNC DDraw', label: 'CNC DDraw' }, + { value: 'Other', label: 'Other' }, + ], + }, + { + name: 'dxvk_version', + label: 'DXVK Version', + type: CustomFieldType.SELECT, + required: true, + displayOrder: 3, + defaultValue: '2.6.1-gplasync', + options: [ + { value: '1.10.1', label: '1.10.1' }, + { value: '1.10.3', label: '1.10.3' }, + { value: 'async-1.10.3', label: 'async-1.10.3' }, + { value: '1.10.9-sarek', label: '1.10.9-sarek' }, + { value: '1.11.1-sarek', label: '1.11.1-sarek' }, + { value: '1.9.2', label: '1.9.2' }, + { value: '2.3.1', label: '2.3.1' }, + { value: '2.4-gplasync', label: '2.4-gplasync' }, + { value: '2.4.1', label: '2.4.1' }, + { value: '2.4.1-gplasync', label: '2.4.1-gplasync' }, + { value: '2.6.1-gplasync', label: '2.6.1-gplasync' }, + { value: '2.6-arm64ec', label: '2.6-arm64ec' }, + { value: '2.7.1', label: '2.7.1' }, + { value: 'other', label: 'Other (specify in notes)' }, + ], + }, + { + name: 'dx_wrapper_config', + label: 'DX Wrapper Config (Version, Frame Rate, Max Memory)', + type: CustomFieldType.TEXTAREA, + required: false, + displayOrder: 4, + defaultValue: null, + }, + { + name: 'audio_driver', + label: 'Audio Driver', + type: CustomFieldType.SELECT, + required: true, + displayOrder: 5, + defaultValue: 'alsa', + options: [ + { value: 'alsa', label: 'ALSA' }, + { value: 'pulse', label: 'PulseAudio' }, + { value: 'other', label: 'Other' }, + ], + }, + { + name: 'env_variables', + label: 'Environment Variables', + type: CustomFieldType.TEXTAREA, + required: false, + displayOrder: 6, + defaultValue: null, + }, + { + name: 'box64_version', + label: 'Box64 Version', + type: CustomFieldType.SELECT, + required: true, + displayOrder: 7, + defaultValue: '0.3.6', + options: [ + { value: '0.3.2', label: '0.3.2' }, + { value: '0.3.4', label: '0.3.4' }, + { value: '0.3.6', label: '0.3.6' }, + { value: '0.3.7', label: '0.3.7' }, + { value: '0.3.8', label: '0.3.8' }, + { value: '0.4.0', label: '0.4.0' }, + ], + }, + { + name: 'box64_preset', + label: 'Box64 Preset', + type: CustomFieldType.SELECT, + required: true, + displayOrder: 8, + defaultValue: 'compatibility', + options: [ + { value: 'stability', label: 'Stability' }, + { value: 'compatibility', label: 'Compatibility' }, + { value: 'intermediate', label: 'Intermediate' }, + { value: 'performance', label: 'Performance' }, + { value: 'unity', label: 'Unity' }, + { value: 'unity_mono_bleeding_edge', label: 'Unity Mono Bleeding Edge' }, + { value: 'denuvo', label: 'Denuvo' }, + { value: 'other', label: 'Other/Custom' }, + ], + }, + { + name: 'startup_selection', + label: 'Startup Selection', + type: CustomFieldType.SELECT, + required: false, + displayOrder: 9, + defaultValue: 'Aggressive (Stop services on startup)', + options: [ + { value: 'Normal (Load all services)', label: 'Normal (Load all services)' }, + { + value: 'Essential (Load only essential services)', + label: 'Essential (Load only essential services)', + }, + { + value: 'Aggressive (Stop services on startup)', + label: 'Aggressive (Stop services on startup)', + }, + { value: 'Other', label: 'Other' }, + ], + }, + { + name: 'resolution', + label: 'Resolution (Screen Size)', + type: CustomFieldType.TEXT, + required: true, + displayOrder: 10, + defaultValue: '1280x720 (16:9)', + }, + { + name: 'youtube', + label: 'YouTube', + type: CustomFieldType.URL, + required: false, + displayOrder: 11, + defaultValue: null, + }, + { + name: 'media_url', + label: 'Screenshots, Blog Post, etc', + type: CustomFieldType.URL, + required: false, + displayOrder: 12, + defaultValue: null, + }, + { + name: 'exec_arguments', + label: 'Exec Arguments', + type: CustomFieldType.TEXT, + required: false, + displayOrder: 13, + defaultValue: null, + placeholder: + '-noverifyfiles -nobootstrapupdate -skipinitialbootstrap -norepairfiles -nocrashmonitor -noshaders', + }, + { + name: 'game_version', + label: 'Game Version', + type: CustomFieldType.TEXT, + required: false, + displayOrder: 14, + defaultValue: null, + }, + { + name: 'average_fps', + label: 'Average FPS', + type: CustomFieldType.TEXT, + required: false, + displayOrder: 15, + defaultValue: null, + }, + { + name: 'container_variant', + label: 'Container Variant', + type: CustomFieldType.SELECT, + required: true, + displayOrder: 16, + defaultValue: 'bionic', + options: [ + { value: 'bionic', label: 'bionic' }, + { value: 'glibc', label: 'glibc' }, + ], + }, + { + name: 'wine_version', + label: 'Wine Version', + type: CustomFieldType.SELECT, + required: true, + displayOrder: 17, + defaultValue: 'proton-9.0-arm64ec', + options: [ + { value: 'wine-9.2-x86_64', label: 'wine-9.2-x86_64 (Glibc)' }, + { value: 'proton-9.0-arm64ec', label: 'proton-9.0-arm64ec (Bionic)' }, + { value: 'proton-9.0-x86_64', label: 'proton-9.0-x86_64 (Bionic)' }, + { value: 'proton-10.0-arm64ec', label: 'proton-10.0-arm64ec (Bionic)' }, + ], + }, + { + name: 'steam_type', + label: 'Steam Type', + type: CustomFieldType.SELECT, + required: true, + displayOrder: 18, + defaultValue: 'normal', + options: [ + { value: 'normal', label: 'Normal' }, + { value: 'light', label: 'Light' }, + { value: 'ultra_light', label: 'Ultra Light' }, + ], + }, + { + name: 'dynamic_driver_version', + label: 'Dynamic Driver Version', + type: CustomFieldType.TEXT, + required: true, + displayOrder: 19, + defaultValue: null, + }, + { + name: 'max_device_memory', + label: 'Max Device Memory', + type: CustomFieldType.SELECT, + required: true, + displayOrder: 20, + defaultValue: '0', + options: [ + { value: '0', label: '0 MB' }, + { value: '512', label: '512 MB' }, + { value: '1024', label: '1024 MB' }, + { value: '2048', label: '2048 MB' }, + { value: '4096', label: '4096 MB' }, + ], + }, + { + name: 'use_adrenotools_turnip', + label: 'Use Adrenotools Turnip', + type: CustomFieldType.BOOLEAN, + required: true, + displayOrder: 21, + defaultValue: true, + }, + { + name: 'fex_core_version', + label: 'FEXCore Version', + type: CustomFieldType.TEXT, + required: true, + displayOrder: 22, + defaultValue: '2603', + }, + { + name: '32_bit_emulator', + label: '32-bit Emulator', + type: CustomFieldType.SELECT, + required: true, + displayOrder: 23, + defaultValue: 'fex', + options: [ + { value: 'fex', label: 'FEXCore' }, + { value: 'box', label: 'Box32' }, + ], + }, + { + name: '64_bit_emulator', + label: '64-bit Emulator', + type: CustomFieldType.SELECT, + required: false, + displayOrder: 24, + defaultValue: 'fex', + options: [ + { value: 'fex', label: 'FEXCore' }, + { value: 'box', label: 'Box64' }, + ], + }, + { + name: 'fex_core_preset', + label: 'FEXCore Preset', + type: CustomFieldType.SELECT, + required: true, + displayOrder: 25, + defaultValue: 'intermediate', + options: [ + { value: 'stability', label: 'Stability' }, + { value: 'compatibility', label: 'Compatibility' }, + { value: 'intermediate', label: 'Intermediate' }, + { value: 'performance', label: 'Performance' }, + { value: 'extreme', label: 'Extreme' }, + { value: 'denuvo', label: 'Denuvo' }, + { value: 'other', label: 'Other' }, + ], + }, + { + name: 'use_steam_input', + label: 'Use Steam Input', + type: CustomFieldType.BOOLEAN, + required: false, + displayOrder: 26, + defaultValue: false, + }, + { + name: 'enable_x_input_api', + label: 'Enable XInput API', + type: CustomFieldType.BOOLEAN, + required: false, + displayOrder: 27, + defaultValue: true, + }, + { + name: 'enable_direct_input_api', + label: 'Enable DirectInput API', + type: CustomFieldType.BOOLEAN, + required: false, + displayOrder: 28, + defaultValue: true, + }, + { + name: 'direct_input_mapper_type', + label: 'DirectInput Mapper Type', + type: CustomFieldType.SELECT, + required: true, + displayOrder: 29, + defaultValue: 'standard', + options: [ + { value: 'standard', label: 'Standard' }, + { value: 'xinput_mapper', label: 'XInput Mapper' }, + ], + }, +] + +export default async function gamenativeCustomFieldsSeeder(prisma: PrismaClient) { + console.info('🌱 Seeding GameNative custom fields...') + + const gamenative = await prisma.emulator.findUnique({ + where: { name: GAMENATIVE_EMULATOR_NAME }, + select: { id: true }, + }) + + if (!gamenative) { + console.warn( + `⚠️ Emulator "${GAMENATIVE_EMULATOR_NAME}" not found. Skipping custom field seeding.`, + ) + return + } + + const fieldNames = GAMENATIVE_CUSTOM_FIELDS.map((field) => field.name) + + for (const field of GAMENATIVE_CUSTOM_FIELDS) { + await prisma.customFieldDefinition.upsert({ + where: { + emulatorId_name: { + emulatorId: gamenative.id, + name: field.name, + }, + }, + create: buildDefinitionCreate(gamenative.id, field), + update: buildDefinitionUpdate(field), + }) + } + + const removed = await prisma.customFieldDefinition.deleteMany({ + where: { + emulatorId: gamenative.id, + name: { notIn: fieldNames }, + }, + }) + + console.info( + `✅ GameNative custom fields synced. Updated ${GAMENATIVE_CUSTOM_FIELDS.length} definitions, removed ${removed.count}.`, + ) +} + +function buildDefinitionCreate(emulatorId: string, field: GameNativeCustomFieldSeed) { + const { options, defaultValue, placeholder, ...base } = field + + return { + emulatorId, + name: base.name, + label: base.label, + type: base.type, + options: normalizeJsonInput(options), + defaultValue: normalizeJsonInput(defaultValue), + placeholder: placeholder ?? null, + rangeMin: null, + rangeMax: null, + rangeDecimals: null, + rangeUnit: null, + isRequired: base.required, + displayOrder: base.displayOrder, + } +} + +function buildDefinitionUpdate(field: GameNativeCustomFieldSeed) { + const { options, defaultValue, placeholder, ...base } = field + + return { + label: base.label, + type: base.type, + options: normalizeJsonInput(options), + defaultValue: normalizeJsonInput(defaultValue), + placeholder: placeholder ?? null, + rangeMin: null, + rangeMax: null, + rangeDecimals: null, + rangeUnit: null, + isRequired: base.required, + displayOrder: base.displayOrder, + } +} + +function normalizeJsonInput( + value: unknown, +): Prisma.NullableJsonNullValueInput | Prisma.InputJsonValue | undefined { + return value === null || value === undefined ? Prisma.JsonNull : (value as Prisma.InputJsonValue) +} diff --git a/src/app/listings/new/NewListingPage.tsx b/src/app/listings/new/NewListingPage.tsx index 42c85151..6d49ea12 100644 --- a/src/app/listings/new/NewListingPage.tsx +++ b/src/app/listings/new/NewListingPage.tsx @@ -16,6 +16,7 @@ import { import { useForm, Controller } from 'react-hook-form' import '@/shared/emulator-config/eden' import '@/shared/emulator-config/azahar' +import '@/shared/emulator-config/gamenative' import { Button, LoadingSpinner } from '@/components/ui' import analytics from '@/lib/analytics' import { api } from '@/lib/api' @@ -27,6 +28,7 @@ import { type RouterInput } from '@/types/trpc' import { parseCustomFieldOptions, getCustomFieldDefaultValue } from '@/utils/custom-fields' import getErrorMessage from '@/utils/getErrorMessage' import { formatCountLabel } from '@/utils/text' +import { ms } from '@/utils/time' import { CustomFieldsFormSection, type DeviceOption, @@ -49,6 +51,8 @@ import { reconcileDriverValue } from '../components/shared/custom-fields/driverV export type ListingFormValues = RouterInput['listings']['create'] +const HIGHLIGHT_DURATION_MS = 1800 + function AddListingPage() { const router = useRouter() const searchParams = useSearchParams() @@ -99,7 +103,7 @@ function AddListingPage() { }, [availableEmulators, selectedEmulatorId]) // Prefetch driver versions so an imported Eden driver filename can be resolved immediately const driverVersionsQuery = api.listings.driverVersions.useQuery(undefined, { - staleTime: 30 * 60 * 1000, + staleTime: ms.minutes(30), refetchOnWindowFocus: false, refetchOnReconnect: false, }) @@ -165,15 +169,14 @@ function AddListingPage() { } importHighlightTimeoutRef.current = window.setTimeout(() => { setHighlightedFieldIds([]) - }, 1800) - - if (changedCount > 0) { - toast.success( - `Imported Eden configuration. Filled ${formatCountLabel('field', changedCount)}.`, - ) - } else { - toast.success('Imported Eden configuration.') - } + }, HIGHLIGHT_DURATION_MS) + + const changedFieldsMessage = + changedCount > 0 + ? `Filled ${formatCountLabel('field', changedCount)}.` + : 'No matching fields were filled.' + + toast.success(`Imported configuration. ${changedFieldsMessage}`) if (uniqueMissing.length > 0) { toast.info(`Review manually: ${uniqueMissing.join(', ')}`) @@ -202,9 +205,10 @@ function AddListingPage() { .trim() .toLowerCase() - const importerSlugMap: Record = { + const importerSlugMap: Record = { eden: 'eden', azahar: 'azahar', + gamenative: 'gamenative', } const selectedEmulatorSlug = importerSlugMap[normalizedEmulatorName] ?? null @@ -412,10 +416,8 @@ function AddListingPage() { }) useEffect(() => { - if (selectedEmulatorSlug !== 'eden') { - setImportSummary(null) - setHighlightedFieldIds([]) - } + setImportSummary(null) + setHighlightedFieldIds([]) }, [selectedEmulatorSlug]) const onSubmit = async (data: ListingFormValues) => { diff --git a/src/server/utils/emulator-config/gamenative/gamenative.converter.test.ts b/src/server/utils/emulator-config/gamenative/gamenative.converter.test.ts index 96e54c87..0cc0463c 100644 --- a/src/server/utils/emulator-config/gamenative/gamenative.converter.test.ts +++ b/src/server/utils/emulator-config/gamenative/gamenative.converter.test.ts @@ -18,10 +18,10 @@ describe('GameNative Converter', () => { expect(config).toMatchObject({ name: '', - screenSize: '854x480', + screenSize: '1280x720', graphicsDriver: 'vortek', dxwrapper: 'dxvk', - audioDriver: 'alsa', + audioDriver: 'pulseaudio', startupSelection: 1, box64Version: '0.3.6', box86Version: '0.3.2', @@ -30,6 +30,12 @@ describe('GameNative Converter', () => { wow64Mode: true, showFPS: false, csmt: true, + containerVariant: 'glibc', + emulator: 'FEXCore', + fexcoreVersion: '2603', + fexcorePreset: 'INTERMEDIATE', + useSteamInput: false, + dinputMapperType: 1, }) }) @@ -38,8 +44,8 @@ describe('GameNative Converter', () => { { input: '1920x1080 (16:9)', expected: '1920x1080' }, { input: '2560x1440', expected: '2560x1440' }, { input: '854x480 (16:9)', expected: '854x480' }, - { input: 'invalid', expected: '854x480' }, // Default fallback - { input: '', expected: '854x480' }, // Empty fallback + { input: 'invalid', expected: '1280x720' }, + { input: '', expected: '1280x720' }, ] testCases.forEach(({ input, expected }) => { @@ -63,7 +69,6 @@ describe('GameNative Converter', () => { }) it('should handle environment variables properly', () => { - // Test with empty value - should use default const configEmpty = convertToGameNativeConfig({ listingId: 'test', gameId: 'game', @@ -80,9 +85,8 @@ describe('GameNative Converter', () => { }) expect(configEmpty.envVars).toContain('ZINK_DESCRIPTORS=lazy') - expect(configEmpty.envVars).toContain('MESA_VK_WSI_PRESENT_MODE=mailbox') + expect(configEmpty.envVars).toContain('PULSE_LATENCY_MSEC=144') - // Test with custom value const configCustom = convertToGameNativeConfig({ listingId: 'test', gameId: 'game', @@ -104,11 +108,11 @@ describe('GameNative Converter', () => { it('should map graphics driver names correctly', () => { const testCases = [ { input: 'Vortek (Universal)', expected: 'vortek' }, - { input: 'Mesa Turnip', expected: 'turnip' }, - { input: 'VirGL', expected: 'virgl' }, - { input: 'Freedreno', expected: 'vortek' }, // Not a valid driver, defaults to vortek - { input: 'Custom Driver', expected: 'vortek' }, // Unknown defaults to vortek - { input: '', expected: 'vortek' }, // Default when empty + { input: 'Turnip (Adreno)', expected: 'turnip' }, + { input: 'VirGL (Universal)', expected: 'virgl' }, + { input: 'Adreno (Adreno)', expected: 'adreno' }, + { input: 'Custom Driver', expected: 'vortek' }, + { input: '', expected: 'vortek' }, ] testCases.forEach(({ input, expected }) => { @@ -120,7 +124,7 @@ describe('GameNative Converter', () => { customFieldDefinition: { name: 'graphics_driver', label: 'Graphics Driver', - type: 'TEXT', + type: 'SELECT', }, value: input, }, @@ -138,7 +142,7 @@ describe('GameNative Converter', () => { { input: 'VKD3D', expected: 'vkd3d' }, { input: 'CNC DDraw', expected: 'cnc-ddraw' }, { input: 'Other', expected: 'dxvk' }, - { input: 'Unknown', expected: 'dxvk' }, // Default fallback + { input: 'Unknown', expected: 'dxvk' }, ] testCases.forEach(({ input, expected }) => { @@ -163,10 +167,12 @@ describe('GameNative Converter', () => { it('should map audio driver options correctly', () => { const testCases = [ + { input: 'alsa', expected: 'alsa' }, + { input: 'pulse', expected: 'pulseaudio' }, + { input: 'other', expected: 'pulseaudio' }, { input: 'ALSA', expected: 'alsa' }, - { input: 'PulseAudio', expected: 'pulse' }, // Correct value is 'pulse' - { input: 'Other', expected: 'alsa' }, - { input: 'Unknown', expected: 'alsa' }, // Default fallback + { input: 'PulseAudio', expected: 'pulseaudio' }, + { input: 'Unknown', expected: 'pulseaudio' }, ] testCases.forEach(({ input, expected }) => { @@ -195,7 +201,7 @@ describe('GameNative Converter', () => { { input: 'Essential (Load only essential services)', expected: 1 }, { input: 'Aggressive (Stop services on startup)', expected: 2 }, { input: 'Other', expected: 1 }, - { input: 'Unknown', expected: 1 }, // Default fallback + { input: 'Unknown', expected: 1 }, ] testCases.forEach(({ input, expected }) => { @@ -218,8 +224,7 @@ describe('GameNative Converter', () => { }) }) - it('should handle Box64 and Box86 versions with defaults', () => { - // Test with provided values + it('should handle Box64 versions with defaults', () => { const configWithValues = convertToGameNativeConfig({ listingId: 'test', gameId: 'game', @@ -228,25 +233,15 @@ describe('GameNative Converter', () => { customFieldDefinition: { name: 'box64_version', label: 'Box64 Version', - type: 'TEXT', - }, - value: '0.3.6', - }, - { - customFieldDefinition: { - name: 'box86_version', - label: 'Box86 Version', - type: 'TEXT', + type: 'SELECT', }, - value: '0.3.4', + value: '0.3.8', }, ], }) - expect(configWithValues.box64Version).toBe('0.3.6') // Invalid version defaults to 0.3.6 - expect(configWithValues.box86Version).toBe('0.3.2') // Invalid version defaults to 0.3.2 + expect(configWithValues.box64Version).toBe('0.3.8') - // Test with empty values - should use defaults const configWithEmpty = convertToGameNativeConfig({ listingId: 'test', gameId: 'game', @@ -255,52 +250,58 @@ describe('GameNative Converter', () => { customFieldDefinition: { name: 'box64_version', label: 'Box64 Version', - type: 'TEXT', + type: 'SELECT', }, value: '', }, - { - customFieldDefinition: { - name: 'box86_version', - label: 'Box86 Version', - type: 'TEXT', - }, - value: null, - }, ], }) expect(configWithEmpty.box64Version).toBe('0.3.6') - expect(configWithEmpty.box86Version).toBe('0.3.2') }) - it('should map Box64 and Box86 presets correctly', () => { - const presetOptions = [ - { input: 'Stability', expected: 'STABILITY' }, - { input: 'Compatibility', expected: 'COMPATIBILITY' }, - { input: 'Intermediate', expected: 'INTERMEDIATE' }, - { input: 'Performance', expected: 'PERFORMANCE' }, - { input: 'Other/Custom', expected: 'COMPATIBILITY' }, - { input: 'Unknown', expected: 'COMPATIBILITY' }, // Default fallback - ] + it('should accept bionic-specific Box64 versions', () => { + const bionicVersions = ['0.3.2', '0.3.7', '0.4.0'] - presetOptions.forEach(({ input, expected }) => { + bionicVersions.forEach((version) => { const config = convertToGameNativeConfig({ listingId: 'test', gameId: 'game', customFieldValues: [ { customFieldDefinition: { - name: 'box64_preset', - label: 'Box64 Preset', + name: 'box64_version', + label: 'Box64 Version', type: 'SELECT', }, - value: input, + value: version, }, + ], + }) + + expect(config.box64Version).toBe(version) + }) + }) + + it('should map Box64 presets correctly (lowercase input)', () => { + const presetOptions = [ + { input: 'stability', expected: 'STABILITY' }, + { input: 'compatibility', expected: 'COMPATIBILITY' }, + { input: 'intermediate', expected: 'INTERMEDIATE' }, + { input: 'performance', expected: 'PERFORMANCE' }, + { input: 'denuvo', expected: 'DENUVO' }, + { input: 'other/custom', expected: 'COMPATIBILITY' }, + ] + + presetOptions.forEach(({ input, expected }) => { + const config = convertToGameNativeConfig({ + listingId: 'test', + gameId: 'game', + customFieldValues: [ { customFieldDefinition: { - name: 'box86_preset', - label: 'Box86 Preset', + name: 'box64_preset', + label: 'Box64 Preset', type: 'SELECT', }, value: input, @@ -309,7 +310,6 @@ describe('GameNative Converter', () => { }) expect(config.box64Preset).toBe(expected) - expect(config.box86Preset).toBe(expected) }) }) @@ -330,65 +330,225 @@ describe('GameNative Converter', () => { }) expect(config.execArgs).toBe('-skipmovies -nologo') + }) - // Test with empty value - const configEmpty = convertToGameNativeConfig({ + it('should merge dxvk_version into dxwrapperConfig', () => { + const config = convertToGameNativeConfig({ listingId: 'test', gameId: 'game', customFieldValues: [ { customFieldDefinition: { - name: 'exec_arguments', - label: 'Exec Arguments', + name: 'dxvk_version', + label: 'DXVK Version', + type: 'SELECT', + }, + value: 'async-1.10.3', + }, + ], + }) + + expect(config.dxwrapperConfig).toContain('version=async-1.10.3') + }) + + it('should merge max_device_memory into graphicsDriverConfig', () => { + const config = convertToGameNativeConfig({ + listingId: 'test', + gameId: 'game', + customFieldValues: [ + { + customFieldDefinition: { + name: 'max_device_memory', + label: 'Max Device Memory', type: 'TEXT', }, - value: '', + value: '4096', }, ], }) - expect(configEmpty.execArgs).toBe('') + expect(config.graphicsDriverConfig).toContain('maxDeviceMemory=4096') }) - it('should handle DX wrapper config properly', () => { + it('should merge use_adrenotools_turnip into graphicsDriverConfig', () => { const config = convertToGameNativeConfig({ listingId: 'test', gameId: 'game', customFieldValues: [ { customFieldDefinition: { - name: 'dx_wrapper_config', - label: 'DX Wrapper Config', - type: 'TEXTAREA', + name: 'use_adrenotools_turnip', + label: 'Use Adrenotools Turnip', + type: 'BOOLEAN', }, - value: '2.6.1-gplasync', + value: true, }, ], }) - expect(config.dxwrapperConfig).toBe('2.6.1-gplasync') + expect(config.graphicsDriverConfig).toContain('adrenotoolsTurnip=1') }) - it('should convert all fields from the complete example listing', () => { - // Using exact data from notes/GameNativeListingByIdExample.ts - const input: GameNativeConfigInput = { - listingId: 'ea4107c5-371b-4030-b42c-4469f251fe8b', - gameId: 'f097b273-ad3d-4b2b-b6a4-d76856aafabf', + it('should write adrenotoolsTurnip=0 when disabled', () => { + const config = convertToGameNativeConfig({ + listingId: 'test', + gameId: 'game', customFieldValues: [ { customFieldDefinition: { - name: 'audio_driver', - label: 'Audio Driver', - type: 'SELECT', + name: 'use_adrenotools_turnip', + label: 'Use Adrenotools Turnip', + type: 'BOOLEAN', }, - value: 'ALSA', + value: false, }, + ], + }) + + expect(config.graphicsDriverConfig).toContain('adrenotoolsTurnip=0') + }) + + it('should combine max_device_memory and use_adrenotools_turnip in graphicsDriverConfig', () => { + const config = convertToGameNativeConfig({ + listingId: 'test', + gameId: 'game', + customFieldValues: [ { customFieldDefinition: { - name: 'average_fps', - label: 'Average FPS', + name: 'max_device_memory', + label: 'Max Device Memory', type: 'TEXT', }, + value: '4096', + }, + { + customFieldDefinition: { + name: 'use_adrenotools_turnip', + label: 'Use Adrenotools Turnip', + type: 'BOOLEAN', + }, + value: true, + }, + ], + }) + + expect(config.graphicsDriverConfig).toContain('maxDeviceMemory=4096') + expect(config.graphicsDriverConfig).toContain('adrenotoolsTurnip=1') + }) + + it('should map container_variant correctly', () => { + const config = convertToGameNativeConfig({ + listingId: 'test', + gameId: 'game', + customFieldValues: [ + { + customFieldDefinition: { + name: 'container_variant', + label: 'Container Variant', + type: 'SELECT', + }, + value: 'bionic', + }, + ], + }) + + expect(config.containerVariant).toBe('bionic') + }) + + it('should map steam_type with ultra_light conversion', () => { + const config = convertToGameNativeConfig({ + listingId: 'test', + gameId: 'game', + customFieldValues: [ + { + customFieldDefinition: { + name: 'steam_type', + label: 'Steam Type', + type: 'SELECT', + }, + value: 'ultra_light', + }, + ], + }) + + expect(config.steamType).toBe('ultralight') + }) + + it('should map emulator from 64_bit_emulator field', () => { + const config = convertToGameNativeConfig({ + listingId: 'test', + gameId: 'game', + customFieldValues: [ + { + customFieldDefinition: { + name: '64_bit_emulator', + label: '64-bit Emulator', + type: 'SELECT', + }, + value: 'box', + }, + ], + }) + + expect(config.emulator).toBe('Box64') + }) + + it('should map fex_core_preset correctly (lowercase input)', () => { + const config = convertToGameNativeConfig({ + listingId: 'test', + gameId: 'game', + customFieldValues: [ + { + customFieldDefinition: { + name: 'fex_core_preset', + label: 'FEXCore Preset', + type: 'SELECT', + }, + value: 'performance', + }, + ], + }) + + expect(config.fexcorePreset).toBe('PERFORMANCE') + }) + + it('should map direct_input_mapper_type string values to numbers', () => { + const testCases = [ + { input: 'standard', expected: 1 }, + { input: 'xinput_mapper', expected: 2 }, + ] + + testCases.forEach(({ input, expected }) => { + const config = convertToGameNativeConfig({ + listingId: 'test', + gameId: 'game', + customFieldValues: [ + { + customFieldDefinition: { + name: 'direct_input_mapper_type', + label: 'DirectInput Mapper Type', + type: 'SELECT', + }, + value: input, + }, + ], + }) + + expect(config.dinputMapperType).toBe(expected) + }) + }) + + it('should convert all fields from a complete listing', () => { + const input: GameNativeConfigInput = { + listingId: 'ea4107c5-371b-4030-b42c-4469f251fe8b', + gameId: 'f097b273-ad3d-4b2b-b6a4-d76856aafabf', + customFieldValues: [ + { + customFieldDefinition: { name: 'audio_driver', label: 'Audio Driver', type: 'SELECT' }, + value: 'alsa', + }, + { + customFieldDefinition: { name: 'average_fps', label: 'Average FPS', type: 'TEXT' }, value: '120', }, { @@ -397,22 +557,18 @@ describe('GameNative Converter', () => { label: 'Box64 Preset', type: 'SELECT', }, - value: 'Performance', + value: 'performance', }, { customFieldDefinition: { name: 'box64_version', label: 'Box64 Version', - type: 'TEXT', + type: 'SELECT', }, value: '0.3.6', }, { - customFieldDefinition: { - name: 'dx_wrapper', - label: 'DX Wrapper', - type: 'SELECT', - }, + customFieldDefinition: { name: 'dx_wrapper', label: 'DX Wrapper', type: 'SELECT' }, value: 'DXVK', }, { @@ -421,7 +577,7 @@ describe('GameNative Converter', () => { label: 'DX Wrapper Config', type: 'TEXTAREA', }, - value: '2.6.1-gplasync', + value: 'async=1', }, { customFieldDefinition: { @@ -447,35 +603,19 @@ describe('GameNative Converter', () => { }, value: '-skipmovies', }, - { - customFieldDefinition: { - name: 'game_version', - label: 'Game Version', - type: 'TEXT', - }, - value: '', - }, { customFieldDefinition: { name: 'graphics_driver', label: 'Graphics Driver', - type: 'TEXT', + type: 'SELECT', }, value: 'Vortek (Universal)', }, - { - customFieldDefinition: { - name: 'media_url', - label: 'Screenshots, Blog Post, etc', - type: 'URL', - }, - value: '', - }, { customFieldDefinition: { name: 'resolution', label: 'Resolution (Screen Size)', - type: 'TEXT', + type: 'SELECT', }, value: '1920x1080 (16:9)', }, @@ -489,38 +629,51 @@ describe('GameNative Converter', () => { }, { customFieldDefinition: { - name: 'youtube', - label: 'YouTube', - type: 'URL', + name: 'container_variant', + label: 'Container Variant', + type: 'SELECT', + }, + value: 'bionic', + }, + { + customFieldDefinition: { + name: '64_bit_emulator', + label: '64-bit Emulator', + type: 'SELECT', }, - value: '', + value: 'fex', + }, + { + customFieldDefinition: { + name: 'fex_core_preset', + label: 'FEXCore Preset', + type: 'SELECT', + }, + value: 'intermediate', }, ], } const config = convertToGameNativeConfig(input) - // Verify all mapped fields expect(config.audioDriver).toBe('alsa') expect(config.box64Preset).toBe('PERFORMANCE') expect(config.box64Version).toBe('0.3.6') expect(config.dxwrapper).toBe('dxvk') - expect(config.dxwrapperConfig).toBe('2.6.1-gplasync') - expect(config.envVars).toContain('ZINK_DESCRIPTORS=lazy') // Default since empty + expect(config.dxwrapperConfig).toBe('async=1') + expect(config.envVars).toContain('ZINK_DESCRIPTORS=lazy') expect(config.execArgs).toBe('-skipmovies') expect(config.graphicsDriver).toBe('vortek') expect(config.screenSize).toBe('1920x1080') expect(config.startupSelection).toBe(2) + expect(config.containerVariant).toBe('bionic') + expect(config.emulator).toBe('FEXCore') + expect(config.fexcorePreset).toBe('INTERMEDIATE') - // Verify default values for missing fields - expect(config.box86Version).toBe('0.3.2') expect(config.box86Preset).toBe('COMPATIBILITY') expect(config.wow64Mode).toBe(true) expect(config.showFPS).toBe(false) expect(config.launchRealSteam).toBe(false) - expect(config.cpuList).toBe('0,1,2,3,4,5,6,7') - expect(config.csmt).toBe(true) - expect(config.sdlControllerAPI).toBe(true) expect(config.enableXInput).toBe(true) expect(config.enableDInput).toBe(true) }) @@ -539,35 +692,19 @@ describe('GameNative Converter', () => { value: 'v0.3.0', }, { - customFieldDefinition: { - name: 'game_version', - label: 'Game Version', - type: 'TEXT', - }, + customFieldDefinition: { name: 'game_version', label: 'Game Version', type: 'TEXT' }, value: '1.0.0', }, { - customFieldDefinition: { - name: 'average_fps', - label: 'Average FPS', - type: 'TEXT', - }, + customFieldDefinition: { name: 'average_fps', label: 'Average FPS', type: 'TEXT' }, value: '60', }, { - customFieldDefinition: { - name: 'media_url', - label: 'Media URL', - type: 'URL', - }, + customFieldDefinition: { name: 'media_url', label: 'Media URL', type: 'URL' }, value: 'https://example.com', }, { - customFieldDefinition: { - name: 'youtube', - label: 'YouTube', - type: 'URL', - }, + customFieldDefinition: { name: 'youtube', label: 'YouTube', type: 'URL' }, value: 'https://youtube.com/watch?v=123', }, ], @@ -575,10 +712,8 @@ describe('GameNative Converter', () => { const config = convertToGameNativeConfig(input) - // These fields should not affect the config - // Config should still have default values expect(config.name).toBe('') - expect(config.screenSize).toBe('854x480') + expect(config.screenSize).toBe('1280x720') expect(config.graphicsDriver).toBe('vortek') expect(config.dxwrapper).toBe('dxvk') }) @@ -600,7 +735,7 @@ describe('GameNative Converter', () => { customFieldDefinition: { name: 'graphics_driver', label: 'Graphics Driver', - type: 'TEXT', + type: 'SELECT', }, value: null, }, @@ -617,9 +752,9 @@ describe('GameNative Converter', () => { const config = convertToGameNativeConfig(input) - expect(config.screenSize).toBe('854x480') // Default - expect(config.graphicsDriver).toBe('vortek') // Default - expect(config.execArgs).toBe('') // Empty string default + expect(config.screenSize).toBe('1280x720') + expect(config.graphicsDriver).toBe('vortek') + expect(config.execArgs).toBe('') }) }) @@ -633,7 +768,7 @@ describe('GameNative Converter', () => { customFieldDefinition: { name: 'resolution', label: 'Resolution', - type: 'TEXT', + type: 'SELECT', }, value: '1920x1080', }, @@ -641,7 +776,7 @@ describe('GameNative Converter', () => { customFieldDefinition: { name: 'graphics_driver', label: 'Graphics Driver', - type: 'TEXT', + type: 'SELECT', }, value: 'vortek', }, @@ -655,10 +790,9 @@ describe('GameNative Converter', () => { screenSize: '1920x1080', graphicsDriver: 'vortek', dxwrapper: 'dxvk', - audioDriver: 'alsa', + audioDriver: 'pulseaudio', }) - // Check formatting (should be pretty-printed) expect(serialized).toContain('\n') expect(serialized).toContain(' ') }) @@ -674,18 +808,16 @@ describe('GameNative Converter', () => { const parsed = JSON.parse(serialized) expect(parsed).toBeDefined() - expect(parsed.screenSize).toBe('854x480') + expect(parsed.screenSize).toBe('1280x720') }) }) - describe('Edge cases and special scenarios', () => { + describe('Edge cases', () => { it('should handle mixed case graphics driver names', () => { const testCases = [ { input: 'VORTEK', expected: 'vortek' }, { input: 'Turnip', expected: 'turnip' }, { input: 'VIRGL', expected: 'virgl' }, - { input: 'FREEDRENO', expected: 'vortek' }, // Not valid, defaults to vortek - { input: 'MeSa', expected: 'vortek' }, // Not valid, defaults to vortek ] testCases.forEach(({ input, expected }) => { @@ -697,7 +829,7 @@ describe('GameNative Converter', () => { customFieldDefinition: { name: 'graphics_driver', label: 'Graphics Driver', - type: 'TEXT', + type: 'SELECT', }, value: input, }, @@ -713,7 +845,7 @@ describe('GameNative Converter', () => { { input: 'Resolution: 1920x1080', expected: '1920x1080' }, { input: '1920x1080 pixels', expected: '1920x1080' }, { input: 'Use 2560x1440 for best quality', expected: '2560x1440' }, - { input: 'No resolution here', expected: '854x480' }, // Default + { input: 'No resolution here', expected: '1280x720' }, ] testCases.forEach(({ input, expected }) => { @@ -752,7 +884,7 @@ describe('GameNative Converter', () => { ], }) - expect(config.envVars).toBe('VAR1=value1 VAR2=value2') // Trimmed but internal spacing preserved + expect(config.envVars).toBe('VAR1=value1 VAR2=value2') }) it('should handle multiple fields updating simultaneously', () => { @@ -764,7 +896,7 @@ describe('GameNative Converter', () => { customFieldDefinition: { name: 'resolution', label: 'Resolution', - type: 'TEXT', + type: 'SELECT', }, value: '2560x1440', }, @@ -772,7 +904,7 @@ describe('GameNative Converter', () => { customFieldDefinition: { name: 'graphics_driver', label: 'Graphics Driver', - type: 'TEXT', + type: 'SELECT', }, value: 'turnip', }, @@ -790,7 +922,7 @@ describe('GameNative Converter', () => { label: 'Audio Driver', type: 'SELECT', }, - value: 'PulseAudio', + value: 'pulse', }, { customFieldDefinition: { @@ -804,7 +936,7 @@ describe('GameNative Converter', () => { customFieldDefinition: { name: 'box64_version', label: 'Box64 Version', - type: 'TEXT', + type: 'SELECT', }, value: '0.3.6', }, @@ -814,7 +946,7 @@ describe('GameNative Converter', () => { label: 'Box64 Preset', type: 'SELECT', }, - value: 'Stability', + value: 'stability', }, ], }) @@ -822,7 +954,7 @@ describe('GameNative Converter', () => { expect(config.screenSize).toBe('2560x1440') expect(config.graphicsDriver).toBe('turnip') expect(config.dxwrapper).toBe('vkd3d') - expect(config.audioDriver).toBe('pulse') + expect(config.audioDriver).toBe('pulseaudio') expect(config.startupSelection).toBe(0) expect(config.box64Version).toBe('0.3.6') expect(config.box64Preset).toBe('STABILITY') diff --git a/src/server/utils/emulator-config/gamenative/gamenative.converter.ts b/src/server/utils/emulator-config/gamenative/gamenative.converter.ts index bed404fa..eff096d4 100644 --- a/src/server/utils/emulator-config/gamenative/gamenative.converter.ts +++ b/src/server/utils/emulator-config/gamenative/gamenative.converter.ts @@ -10,19 +10,24 @@ import { AUDIO_DRIVER_MAPPING, STARTUP_SELECTION_MAPPING, BOX64_PRESET_MAPPING, - BOX86_PRESET_MAPPING, + EMULATOR_MAPPING, + CONTAINER_VARIANT_MAPPING, + STEAM_TYPE_MAPPING, + FEXCORE_PRESET_MAPPING, } from './gamenative.defaults' -import { - type DxvkVersion, - type ContainerConfig, - type ScreenSize, - type GraphicsDriver, - type DxWrapper, - type AudioDriver, - type StartupSelection, - type Box86Version, - type Box64Version, - type Box86_64Preset, +import type { + ContainerConfig, + ScreenSize, + GraphicsDriver, + DxWrapper, + AudioDriver, + StartupSelection, + Box64Version, + Box86_64Preset, + ContainerVariant, + SteamType, + FEXCorePreset, + DinputMapperType, } from './gamenative.types' import type { Prisma } from '@orm' @@ -42,9 +47,16 @@ export interface GameNativeConfigInput { customFieldValues: CustomFieldValue[] } -// Export the properly typed config export type GameNativeConfig = Required +/** + * Appends a key=value pair to a comma-separated config string + */ +function appendToConfigString(existing: string, key: string, value: string): string { + if (!existing) return `${key}=${value}` + return `${existing},${key}=${value}` +} + /** * Maps custom field names to GameNative config keys and transforms values */ @@ -56,280 +68,145 @@ const FIELD_MAPPINGS: Record< defaultIfEmpty?: unknown } > = { - // Resolution mapping resolution: { key: 'screenSize', transform: (value): ScreenSize => { const resStr = String(value) - // Extract resolution from formats like "1920x1080 (16:9)" or just "1920x1080" const match = resStr.match(/(\d+x\d+)/) return match ? match[1] : GameNativeDefaults.getDefaultScreenSize() }, }, - // Environment variables - complex string needs special handling env_variables: { key: 'envVars', transform: (value) => { - // If empty or not provided, use default environment variables return !value || String(value).trim() === '' ? GameNativeDefaults.getDefaultEnvVars() : String(value).trim() }, }, - // Graphics driver - handles both new SELECT values and legacy TEXT values graphics_driver: { key: 'graphicsDriver', transform: (value): GraphicsDriver => GameNativeDefaults.detectGraphicsDriver(String(value)), defaultIfEmpty: GameNativeDefaults.getDefaultGraphicsDriver(), }, - // DX Wrapper dx_wrapper: { key: 'dxwrapper', transform: (value): DxWrapper => DX_WRAPPER_MAPPING[String(value)] ?? GameNativeDefaults.getDefaultDxWrapper(), }, - // DX Wrapper Config dx_wrapper_config: { key: 'dxwrapperConfig', transform: (value) => String(value || ''), defaultIfEmpty: '', }, - // DXVK version - dxvk_version: { - key: 'dxvkVersion', - transform: (value): DxvkVersion => { - const version = String(value || GameNativeDefaults.getDefaultDxvkVersion()).trim() - return GameNativeDefaults.isValidDxvkVersion(version) - ? (version as DxvkVersion) - : GameNativeDefaults.getDefaultDxvkVersion() - }, - }, - - // Audio driver audio_driver: { key: 'audioDriver', transform: (value): AudioDriver => AUDIO_DRIVER_MAPPING[String(value)] ?? GameNativeDefaults.getDefaultAudioDriver(), }, - // Execution arguments exec_arguments: { key: 'execArgs', transform: (value) => String(value || ''), defaultIfEmpty: '', }, - // Startup selection startup_selection: { key: 'startupSelection', transform: (value): StartupSelection => STARTUP_SELECTION_MAPPING[String(value)] ?? GameNativeDefaults.getDefaultStartupSelection(), }, - // Box64 version box64_version: { key: 'box64Version', transform: (value): Box64Version => { const version = String(value || GameNativeDefaults.getDefaultBox64Version()).trim() - // Validate against known versions using centralized validation - return GameNativeDefaults.isValidBox64Version(version) - ? (version as Box64Version) - : GameNativeDefaults.getDefaultBox64Version() + if (GameNativeDefaults.isValidBox64Version(version)) return version + return GameNativeDefaults.getDefaultBox64Version() }, defaultIfEmpty: GameNativeDefaults.getDefaultBox64Version(), }, - // Box86 version (not in example but in target config) - box86_version: { - key: 'box86Version', - transform: (value): Box86Version => { - const version = String(value || GameNativeDefaults.getDefaultBox86Version()).trim() - // Validate against known versions using centralized validation - return GameNativeDefaults.isValidBox86Version(version) - ? (version as Box86Version) - : GameNativeDefaults.getDefaultBox86Version() - }, - defaultIfEmpty: GameNativeDefaults.getDefaultBox86Version(), - }, - - // Box64 preset box64_preset: { key: 'box64Preset', transform: (value): Box86_64Preset => BOX64_PRESET_MAPPING[String(value)] ?? GameNativeDefaults.getDefaultBoxPreset(), }, - // Box86 preset (not in example but needed) - box86_preset: { - key: 'box86Preset', - transform: (value): Box86_64Preset => - BOX86_PRESET_MAPPING[String(value)] ?? GameNativeDefaults.getDefaultBoxPreset(), + container_variant: { + key: 'containerVariant', + transform: (value): ContainerVariant => + CONTAINER_VARIANT_MAPPING[String(value)] ?? GameNativeDefaults.getDefaultContainerVariant(), }, - // Windows Components - Critical missing field - // Convert individual boolean fields to wincomponents string - use_native_direct3d: { key: 'wincomponents', transform: () => undefined }, // Handled specially - use_native_directsound: { key: 'wincomponents', transform: () => undefined }, // Handled specially - use_native_directmusic: { key: 'wincomponents', transform: () => undefined }, // Handled specially - use_native_directshow: { key: 'wincomponents', transform: () => undefined }, // Handled specially - use_native_directplay: { key: 'wincomponents', transform: () => undefined }, // Handled specially - use_native_vcrun2010: { key: 'wincomponents', transform: () => undefined }, // Handled specially - use_native_wmdecoder: { key: 'wincomponents', transform: () => undefined }, // Handled specially - - // Video Memory Size - video_memory_size: { - key: 'videoMemorySize', - transform: (value) => { - const sizeStr = String(value || GameNativeDefaults.getDefaultVideoMemorySize()).trim() - // Validate against known sizes using centralized validation - return GameNativeDefaults.isValidVideoMemorySize(sizeStr) - ? sizeStr - : GameNativeDefaults.getDefaultVideoMemorySize() - }, - defaultIfEmpty: GameNativeDefaults.getDefaultVideoMemorySize(), + wine_version: { + key: 'wineVersion', + transform: (value) => String(value || ''), + defaultIfEmpty: '', }, - // CPU Core Affinity - cpu_list: { - key: 'cpuList', - transform: (value) => String(value || GameNativeDefaults.getDefaultCpuList()), - defaultIfEmpty: GameNativeDefaults.getDefaultCpuList(), + steam_type: { + key: 'steamType', + transform: (value): SteamType => + STEAM_TYPE_MAPPING[String(value)] ?? GameNativeDefaults.getDefaultSteamType(), }, - cpu_list_wow64: { - key: 'cpuListWoW64', - transform: (value) => String(value || GameNativeDefaults.getDefaultCpuList()), - defaultIfEmpty: GameNativeDefaults.getDefaultCpuList(), + dynamic_driver_version: { + key: 'graphicsDriverVersion', + transform: (value) => String(value || ''), + defaultIfEmpty: '', }, - // WoW64 Mode - wow64_mode: { - key: 'wow64Mode', - transform: (value) => Boolean(value ?? true), - defaultIfEmpty: true, + fex_core_version: { + key: 'fexcoreVersion', + transform: (value) => String(value || ''), + defaultIfEmpty: '', }, - // Show FPS Overlay - show_fps: { - key: 'showFPS', - transform: (value) => Boolean(value ?? false), - defaultIfEmpty: false, + fex_core_preset: { + key: 'fexcorePreset', + transform: (value): FEXCorePreset => + FEXCORE_PRESET_MAPPING[String(value)] ?? GameNativeDefaults.getDefaultFexcorePreset(), }, - // Input/Controller Settings - sdl_controller_api: { - key: 'sdlControllerAPI', - transform: (value) => Boolean(value ?? true), - defaultIfEmpty: true, + use_steam_input: { + key: 'useSteamInput', + transform: (value) => Boolean(value ?? false), + defaultIfEmpty: false, }, - enable_xinput: { + enable_x_input_api: { key: 'enableXInput', transform: (value) => Boolean(value ?? true), defaultIfEmpty: true, }, - enable_dinput: { + enable_direct_input_api: { key: 'enableDInput', transform: (value) => Boolean(value ?? true), defaultIfEmpty: true, }, - dinput_mapper_type: { + direct_input_mapper_type: { key: 'dinputMapperType', - transform: (value) => { + transform: (value): DinputMapperType => { + const str = String(value) + if (str === 'xinput_mapper') return 2 + if (str === 'standard') return 1 const num = Number(value) - // 0: Standard, 1: XInput - return num === 0 || num === 1 ? num : 1 + if (num === 2) return 2 + return 1 }, defaultIfEmpty: 1, }, - - disable_mouse_input: { - key: 'disableMouseInput', - transform: (value) => Boolean(value ?? false), - defaultIfEmpty: false, - }, - - // Graphics/Rendering Settings - csmt: { - key: 'csmt', - transform: (value) => Boolean(value ?? true), - defaultIfEmpty: true, - }, - - video_pci_device_id: { - key: 'videoPciDeviceID', - transform: (value) => { - const num = Number(value) - return isNaN(num) ? GameNativeDefaults.getDefaultVideoPciDeviceId() : num - }, - defaultIfEmpty: GameNativeDefaults.getDefaultVideoPciDeviceId(), - }, - - offscreen_rendering_mode: { - key: 'offScreenRenderingMode', - transform: (value) => { - const mode = String( - value || GameNativeDefaults.getDefaultOffscreenRenderingMode(), - ).toLowerCase() - return GameNativeDefaults.isValidOffscreenRenderingMode(mode) - ? mode - : GameNativeDefaults.getDefaultOffscreenRenderingMode() - }, - defaultIfEmpty: GameNativeDefaults.getDefaultOffscreenRenderingMode(), - }, - - strict_shader_math: { - key: 'strictShaderMath', - transform: (value) => Boolean(value ?? true), - defaultIfEmpty: true, - }, - - mouse_warp_override: { - key: 'mouseWarpOverride', - transform: (value) => { - const mode = String(value || GameNativeDefaults.getDefaultMouseWarpOverride()).toLowerCase() - return GameNativeDefaults.isValidMouseWarpOverride(mode) - ? mode - : GameNativeDefaults.getDefaultMouseWarpOverride() - }, - defaultIfEmpty: GameNativeDefaults.getDefaultMouseWarpOverride(), - }, - - shader_backend: { - key: 'shaderBackend', - transform: (value) => String(value || GameNativeDefaults.getDefaultShaderBackend()), - defaultIfEmpty: GameNativeDefaults.getDefaultShaderBackend(), - }, - - use_glsl: { - key: 'useGLSL', - transform: (value) => { - const val = String(value || GameNativeDefaults.getDefaultUseGlsl()) - return GameNativeDefaults.isValidUseGlsl(val) ? val : GameNativeDefaults.getDefaultUseGlsl() - }, - defaultIfEmpty: GameNativeDefaults.getDefaultUseGlsl(), - }, - - // Graphics Driver Version - dynamic field - graphics_driver_version: { - key: 'graphicsDriverVersion', - transform: (value) => String(value || ''), - defaultIfEmpty: '', - }, } -/** - * Get default GameNative configuration - */ function getDefaultConfig(): GameNativeConfig { - // Use the typed default config from the types file return { ...DEFAULT_CONFIG } } @@ -339,73 +216,73 @@ function getDefaultConfig(): GameNativeConfig { export function convertToGameNativeConfig(input: GameNativeConfigInput): GameNativeConfig { const config = getDefaultConfig() - // Process each custom field value + const fieldValuesByName = new Map() + for (const fieldValue of input.customFieldValues) { + fieldValuesByName.set(fieldValue.customFieldDefinition.name, fieldValue.value) + } + for (const fieldValue of input.customFieldValues) { const fieldName = fieldValue.customFieldDefinition.name const mapping = FIELD_MAPPINGS[fieldName] - if (mapping) { - const value = fieldValue.value + if (!mapping) continue - // Skip if empty and no defaultIfEmpty is specified - if ((value === '' || value === null || value === undefined) && !mapping.defaultIfEmpty) { - continue - } + const value = fieldValue.value - // Use defaultIfEmpty if value is empty - const actualValue = - value === '' || value === null || value === undefined ? mapping.defaultIfEmpty : value + if ((value === '' || value === null || value === undefined) && !mapping.defaultIfEmpty) { + continue + } - // Transform and assign value - const transformedValue = mapping.transform ? mapping.transform(actualValue) : actualValue + const actualValue = + value === '' || value === null || value === undefined ? mapping.defaultIfEmpty : value - // Skip Windows Components fields - they're handled specially below - if (mapping.key === 'wincomponents') { - continue - } + const transformedValue = mapping.transform ? mapping.transform(actualValue) : actualValue - // Assign value to the correct key - // We need to use Object.assign or explicit property access - Object.assign(config, { [mapping.key]: transformedValue }) - } + Object.assign(config, { [mapping.key]: transformedValue }) } - // Special handling for Windows Components - combine multiple boolean fields - const winComponentFields = { - use_native_direct3d: 'direct3d', - use_native_directsound: 'directsound', - use_native_directmusic: 'directmusic', - use_native_directshow: 'directshow', - use_native_directplay: 'directplay', - use_native_vcrun2010: 'vcrun2010', - use_native_wmdecoder: 'wmdecoder', + // Merge dxvk_version into dxwrapperConfig as version=X + const dxvkVersion = fieldValuesByName.get('dxvk_version') + if (dxvkVersion && String(dxvkVersion).trim()) { + config.dxwrapperConfig = appendToConfigString( + config.dxwrapperConfig, + 'version', + String(dxvkVersion).trim(), + ) } - const winComponentsMap = new Map() - - // Set defaults using centralized configuration - for (const [component, value] of Object.entries( - GameNativeDefaults.getDefaultWindowsComponents(), - )) { - winComponentsMap.set(component, value) + // Merge max_device_memory into graphicsDriverConfig as maxDeviceMemory=N + const maxDeviceMemory = fieldValuesByName.get('max_device_memory') + if (maxDeviceMemory && String(maxDeviceMemory).trim()) { + config.graphicsDriverConfig = appendToConfigString( + config.graphicsDriverConfig, + 'maxDeviceMemory', + String(maxDeviceMemory).trim(), + ) } - // Override with user values if present - for (const fieldValue of input.customFieldValues) { - const fieldName = fieldValue.customFieldDefinition.name - const componentName = winComponentFields[fieldName as keyof typeof winComponentFields] - - if (componentName) { - winComponentsMap.set(componentName, Boolean(fieldValue.value)) - } + // Handle 32_bit_emulator / 64_bit_emulator → emulator field + const emulator32 = fieldValuesByName.get('32_bit_emulator') + const emulator64 = fieldValuesByName.get('64_bit_emulator') + const emulatorValue = emulator64 ?? emulator32 + if (emulatorValue && String(emulatorValue).trim()) { + config.emulator = + EMULATOR_MAPPING[String(emulatorValue)] ?? GameNativeDefaults.getDefaultEmulator() } - // Build wincomponents string - const winComponentsParts: string[] = [] - for (const [component, useNative] of winComponentsMap) { - winComponentsParts.push(`${component}=${useNative ? '1' : '0'}`) + // Handle use_adrenotools_turnip → graphicsDriverConfig.adrenotoolsTurnip + const useAdrenotoolsTurnip = fieldValuesByName.get('use_adrenotools_turnip') + if (useAdrenotoolsTurnip !== undefined && useAdrenotoolsTurnip !== null) { + const isEnabled = + useAdrenotoolsTurnip === true || + useAdrenotoolsTurnip === 'true' || + useAdrenotoolsTurnip === '1' + config.graphicsDriverConfig = appendToConfigString( + config.graphicsDriverConfig, + 'adrenotoolsTurnip', + isEnabled ? '1' : '0', + ) } - config.wincomponents = winComponentsParts.join(',') return config } diff --git a/src/server/utils/emulator-config/gamenative/gamenative.defaults.ts b/src/server/utils/emulator-config/gamenative/gamenative.defaults.ts index 2334d5ba..facbebee 100644 --- a/src/server/utils/emulator-config/gamenative/gamenative.defaults.ts +++ b/src/server/utils/emulator-config/gamenative/gamenative.defaults.ts @@ -1,8 +1,8 @@ /** * GameNative Configuration Defaults * - * Centralized default values for GameNative emulator configurations - * to avoid hardcoding values throughout transform functions + * Source of truth: ContainerData.kt defaults, Container.java constants, + * DefaultVersion.java, arrays.xml */ import type { @@ -15,82 +15,102 @@ import type { Box86_64Preset, ScreenSize, DxvkVersion, + VKD3DVersion, ContainerConfig, + Emulator, + ContainerVariant, + SteamType, + FEXCorePreset, + FEXCoreVersion, + ExternalDisplayMode, + SharpnessEffect, } from './gamenative.types' -// Default environment variables for GameNative export const DEFAULT_ENV_VARS = - 'ZINK_DESCRIPTORS=lazy ZINK_DEBUG=compact MESA_SHADER_CACHE_DISABLE=false MESA_SHADER_CACHE_MAX_SIZE=512MB mesa_glthread=true WINEESYNC=1 MESA_VK_WSI_PRESENT_MODE=mailbox TU_DEBUG=noconform DXVK_FRAME_RATE=60' + 'WRAPPER_MAX_IMAGE_COUNT=0 ZINK_DESCRIPTORS=lazy ZINK_DEBUG=compact MESA_SHADER_CACHE_DISABLE=false MESA_SHADER_CACHE_MAX_SIZE=512MB mesa_glthread=true WINEESYNC=1 MESA_VK_WSI_PRESENT_MODE=mailbox TU_DEBUG=noconform DXVK_FRAME_RATE=60 PULSE_LATENCY_MSEC=144' -// Default resolution for GameNative -export const DEFAULT_SCREEN_SIZE: ScreenSize = '854x480' +export const DEFAULT_SCREEN_SIZE: ScreenSize = '1280x720' -// Default graphics driver export const DEFAULT_GRAPHICS_DRIVER: GraphicsDriver = 'vortek' -// Graphics driver mapping for both new SELECT values and legacy TEXT values export const GRAPHICS_DRIVER_MAPPING: Record = { - // New SELECT values (exact match) 'VirGL (Universal)': 'virgl', 'Turnip (Adreno)': 'turnip', 'Vortek (Universal)': 'vortek', - - // Legacy TEXT values (for backward compatibility) + 'Adreno (Adreno)': 'adreno', + 'SD 8 Elite (SD 8 Elite)': 'sd-8-elite', + Wrapper: 'wrapper', + 'Wrapper-v2': 'wrapper-v2', + 'Wrapper-leegao': 'wrapper-leegao', + 'Wrapper-legacy': 'wrapper-legacy', virgl: 'virgl', turnip: 'turnip', vortek: 'vortek', + adreno: 'adreno', + 'sd-8-elite': 'sd-8-elite', + wrapper: 'wrapper', + 'wrapper-v2': 'wrapper-v2', + 'wrapper-leegao': 'wrapper-leegao', + 'wrapper-legacy': 'wrapper-legacy', VirGL: 'virgl', Turnip: 'turnip', Vortek: 'vortek', VIRGL: 'virgl', TURNIP: 'turnip', VORTEK: 'vortek', - - // Partial matches for legacy TEXT entries - adreno: 'turnip', // Common legacy entry } -// Default DX wrapper export const DEFAULT_DX_WRAPPER: DxWrapper = 'dxvk' -// Default DXVK version -export const DEFAULT_DXVK_VERSION = '2.6.1-gplasync' +export const DEFAULT_DXVK_VERSION: DxvkVersion = '2.6.1-gplasync' -// Default audio driver -export const DEFAULT_AUDIO_DRIVER: AudioDriver = 'alsa' +export const DEFAULT_VKD3D_VERSION: VKD3DVersion = '2.14.1' + +export const DEFAULT_AUDIO_DRIVER: AudioDriver = 'pulseaudio' -// Default startup selection (Essential services) export const DEFAULT_STARTUP_SELECTION: StartupSelection = 1 -// Default Box versions export const DEFAULT_BOX64_VERSION: Box64Version = '0.3.6' export const DEFAULT_BOX86_VERSION: Box86Version = '0.3.2' -// Default Box presets (Compatibility for best results) export const DEFAULT_BOX_PRESET: Box86_64Preset = 'COMPATIBILITY' -// Default video memory size in MB +export const DEFAULT_EMULATOR: Emulator = 'FEXCore' + +export const DEFAULT_CONTAINER_VARIANT: ContainerVariant = 'glibc' + +export const DEFAULT_STEAM_TYPE: SteamType = 'normal' + +export const DEFAULT_FEXCORE_VERSION: FEXCoreVersion = '2603' +export const DEFAULT_FEXCORE_PRESET: FEXCorePreset = 'INTERMEDIATE' +export const DEFAULT_FEXCORE_TSO_MODE = 'Fast' +export const DEFAULT_FEXCORE_X87_MODE = 'Fast' +export const DEFAULT_FEXCORE_MULTIBLOCK = 'Disabled' + +export const DEFAULT_WINE_VERSION = 'wine-9.2-x86_64' + +export const DEFAULT_EXTERNAL_DISPLAY_MODE: ExternalDisplayMode = 'off' + +export const DEFAULT_SHARPNESS_EFFECT: SharpnessEffect = 'None' +export const DEFAULT_SHARPNESS_LEVEL = 100 +export const DEFAULT_SHARPNESS_DENOISE = 100 + export const DEFAULT_VIDEO_MEMORY_SIZE = '2048' -// Default CPU affinity (all 8 cores) export const DEFAULT_CPU_LIST = '0,1,2,3,4,5,6,7' -// Default PCI device ID (NVIDIA GeForce GTX 480) export const DEFAULT_VIDEO_PCI_DEVICE_ID = 1728 -// Default offscreen rendering mode export const DEFAULT_OFFSCREEN_RENDERING_MODE = 'fbo' -// Default mouse warp override export const DEFAULT_MOUSE_WARP_OVERRIDE = 'disable' -// Default shader backend export const DEFAULT_SHADER_BACKEND = 'glsl' -// Default GLSL usage export const DEFAULT_USE_GLSL = 'enabled' -// Valid video memory sizes (in MB) +export const DEFAULT_RENDERER = 'gl' + export const VALID_VIDEO_MEMORY_SIZES = [ '32', '64', @@ -106,16 +126,12 @@ export const VALID_VIDEO_MEMORY_SIZES = [ '12288', ] -// Valid offscreen rendering modes export const VALID_OFFSCREEN_RENDERING_MODES = ['fbo', 'backbuffer'] -// Valid mouse warp override modes export const VALID_MOUSE_WARP_OVERRIDE_MODES = ['disable', 'enable', 'force'] -// Valid GLSL values export const VALID_USE_GLSL_VALUES = ['enabled', 'disabled'] -// Windows Components defaults export const DEFAULT_WINDOWS_COMPONENTS = { direct3d: true, directsound: true, @@ -124,9 +140,9 @@ export const DEFAULT_WINDOWS_COMPONENTS = { directplay: false, vcrun2010: true, wmdecoder: true, + opengl: false, } -// Mapping objects for user-friendly values to internal values export const DX_WRAPPER_MAPPING: Record = { WineD3D: 'wined3d', DXVK: 'dxvk', @@ -136,9 +152,13 @@ export const DX_WRAPPER_MAPPING: Record = { } export const AUDIO_DRIVER_MAPPING: Record = { + alsa: 'alsa', + pulse: 'pulseaudio', + other: 'pulseaudio', + // Legacy/display label variants for backward compatibility ALSA: 'alsa', - PulseAudio: 'pulse', // Correct value is 'pulse' not 'pulseaudio' - Other: 'alsa', + PulseAudio: 'pulseaudio', + Other: 'pulseaudio', } export const STARTUP_SELECTION_MAPPING: Record = { @@ -149,98 +169,221 @@ export const STARTUP_SELECTION_MAPPING: Record = { } export const BOX64_PRESET_MAPPING: Record = { + stability: 'STABILITY', + compatibility: 'COMPATIBILITY', + intermediate: 'INTERMEDIATE', + performance: 'PERFORMANCE', + denuvo: 'DENUVO', + unity: 'UNITY', + unity_mono_bleeding_edge: 'UNITY_MONO_BLEEDING_EDGE', + other: 'COMPATIBILITY', + 'other/custom': 'COMPATIBILITY', Stability: 'STABILITY', Compatibility: 'COMPATIBILITY', Intermediate: 'INTERMEDIATE', Performance: 'PERFORMANCE', + Denuvo: 'DENUVO', + Unity: 'UNITY', + 'Unity Mono Bleeding Edge': 'UNITY_MONO_BLEEDING_EDGE', 'Other/Custom': 'COMPATIBILITY', } export const BOX86_PRESET_MAPPING: Record = { + stability: 'STABILITY', + compatibility: 'COMPATIBILITY', + intermediate: 'INTERMEDIATE', + performance: 'PERFORMANCE', + denuvo: 'DENUVO', + unity: 'UNITY', + unity_mono_bleeding_edge: 'UNITY_MONO_BLEEDING_EDGE', + 'other/custom': 'COMPATIBILITY', Stability: 'STABILITY', Compatibility: 'COMPATIBILITY', Intermediate: 'INTERMEDIATE', Performance: 'PERFORMANCE', + Denuvo: 'DENUVO', + Unity: 'UNITY', + 'Unity Mono Bleeding Edge': 'UNITY_MONO_BLEEDING_EDGE', 'Other/Custom': 'COMPATIBILITY', } +export const EMULATOR_MAPPING: Record = { + FEXCore: 'FEXCore', + Box64: 'Box64', + fex: 'FEXCore', + box: 'Box64', + Other: 'FEXCore', +} + +export const CONTAINER_VARIANT_MAPPING: Record = { + glibc: 'glibc', + bionic: 'bionic', + Other: 'glibc', +} + +export const STEAM_TYPE_MAPPING: Record = { + normal: 'normal', + light: 'light', + ultralight: 'ultralight', + ultra_light: 'ultralight', + Normal: 'normal', + Light: 'light', + Ultralight: 'ultralight', + Other: 'normal', +} + +export const FEXCORE_PRESET_MAPPING: Record = { + stability: 'STABILITY', + compatibility: 'COMPATIBILITY', + intermediate: 'INTERMEDIATE', + performance: 'PERFORMANCE', + extreme: 'EXTREME', + denuvo: 'DENUVO', + other: 'INTERMEDIATE', + 'other/custom': 'INTERMEDIATE', + Stability: 'STABILITY', + Compatibility: 'COMPATIBILITY', + Intermediate: 'INTERMEDIATE', + Performance: 'PERFORMANCE', + Extreme: 'EXTREME', + Denuvo: 'DENUVO', + 'Other/Custom': 'INTERMEDIATE', +} + +export const EXTERNAL_DISPLAY_MODE_MAPPING: Record = { + off: 'off', + touchpad: 'touchpad', + keyboard: 'keyboard', + hybrid: 'hybrid', + Off: 'off', + Touchpad: 'touchpad', + Keyboard: 'keyboard', + Hybrid: 'hybrid', + Other: 'off', +} + +export const SHARPNESS_EFFECT_MAPPING: Record = { + None: 'None', + CAS: 'CAS', + DLS: 'DLS', + Other: 'None', +} + const VALID_DXVK_VERSIONS: DxvkVersion[] = [ - '2.6.1-gplasync', + 'async-1.10.3', + '2.7.1', '1.10.3', + '1.10.1', '1.10.9-sarek', + '1.11.1-sarek', '1.9.2', '2.3.1', '2.4-gplasync', - 'async-1.10.3', + '2.4.1', + '2.4.1-gplasync', + '2.6.1-gplasync', + '2.6-arm64ec', ] -/** - * Default configuration values (from GameNative) - */ +const VALID_VKD3D_VERSIONS: VKD3DVersion[] = ['2.6', '2.12', '2.13', '2.14.1', '3.0b'] + +const VALID_BOX64_ALL_VERSIONS: Box64Version[] = [ + '0.3.2', + '0.3.4', + '0.3.6', + '0.3.7', + '0.3.8', + '0.4.0', +] + +const VALID_FEXCORE_VERSIONS: FEXCoreVersion[] = ['2507', '2508', '2511', '2512', '2601', '2603'] + export const DEFAULT_CONFIG: Required = { name: '', - screenSize: '854x480', + screenSize: '1280x720', envVars: - 'ZINK_DESCRIPTORS=lazy ZINK_DEBUG=compact MESA_SHADER_CACHE_DISABLE=false MESA_SHADER_CACHE_MAX_SIZE=512MB mesa_glthread=true WINEESYNC=1 MESA_VK_WSI_PRESENT_MODE=mailbox TU_DEBUG=noconform', + 'WRAPPER_MAX_IMAGE_COUNT=0 ZINK_DESCRIPTORS=lazy ZINK_DEBUG=compact MESA_SHADER_CACHE_DISABLE=false MESA_SHADER_CACHE_MAX_SIZE=512MB mesa_glthread=true WINEESYNC=1 MESA_VK_WSI_PRESENT_MODE=mailbox TU_DEBUG=noconform DXVK_FRAME_RATE=60 PULSE_LATENCY_MSEC=144', graphicsDriver: 'vortek', graphicsDriverVersion: '', + graphicsDriverConfig: '', dxwrapper: 'dxvk', - dxvkVersion: '2.6.1-gplasync', dxwrapperConfig: '', - audioDriver: 'alsa', + audioDriver: 'pulseaudio', wincomponents: - 'direct3d=1,directsound=1,directmusic=0,directshow=0,directplay=0,vcrun2010=1,wmdecoder=1', + 'direct3d=1,directsound=1,directmusic=0,directshow=0,directplay=0,vcrun2010=1,wmdecoder=1,opengl=0', + drives: '', execArgs: '', executablePath: '', + installPath: '', showFPS: false, launchRealSteam: false, - cpuList: '0,1,2,3,4,5,6,7', // Dynamic: matches Runtime.getRuntime().availableProcessors() - cpuListWoW64: '0,1,2,3,4,5,6,7', // Same as cpuList + allowSteamUpdates: false, + steamType: 'normal', + cpuList: '0,1,2,3,4,5,6,7', + cpuListWoW64: '0,1,2,3,4,5,6,7', wow64Mode: true, - startupSelection: 1, // STARTUP_SELECTION_ESSENTIAL + startupSelection: 1, box86Version: '0.3.2', box64Version: '0.3.6', box86Preset: 'COMPATIBILITY', box64Preset: 'COMPATIBILITY', - desktopTheme: 'LIGHT,IMAGE,#0277bd,854x480', // TODO: check if `854x480` is correct and needed - sdlControllerAPI: true, - enableXInput: true, - enableDInput: true, - dinputMapperType: 1, - disableMouseInput: false, + desktopTheme: 'LIGHT,IMAGE,#0277bd', + containerVariant: 'glibc', + wineVersion: 'wine-9.2-x86_64', + emulator: 'FEXCore', + fexcoreVersion: '2603', + fexcoreTSOMode: 'Fast', + fexcoreX87Mode: 'Fast', + fexcoreMultiBlock: 'Disabled', + fexcorePreset: 'INTERMEDIATE', + renderer: 'gl', csmt: true, videoPciDeviceID: 1728, offScreenRenderingMode: 'fbo', strictShaderMath: true, + useDRI3: true, videoMemorySize: '2048', mouseWarpOverride: 'disable', shaderBackend: 'glsl', useGLSL: 'enabled', + sdlControllerAPI: true, + useSteamInput: false, + enableXInput: true, + enableDInput: true, + dinputMapperType: 1, + disableMouseInput: false, + touchscreenMode: false, + shooterMode: true, + gestureConfig: '', + externalDisplayMode: 'off', + externalDisplaySwap: false, + language: 'english', + forceDlc: false, + steamOfflineMode: false, + useLegacyDRM: false, + unpackFiles: false, + portraitMode: false, + sharpnessEffect: 'None', + sharpnessLevel: 100, + sharpnessDenoise: 100, } -// Helper functions for validation export const GameNativeDefaults = { - // Environment variables getDefaultEnvVars: (): string => DEFAULT_ENV_VARS, - - // Screen size getDefaultScreenSize: (): ScreenSize => DEFAULT_SCREEN_SIZE, - - // Graphics getDefaultGraphicsDriver: (): GraphicsDriver => DEFAULT_GRAPHICS_DRIVER, getDefaultDxWrapper: (): DxWrapper => DEFAULT_DX_WRAPPER, getDefaultDxvkVersion: (): DxvkVersion => DEFAULT_DXVK_VERSION, - - // Audio getDefaultAudioDriver: (): AudioDriver => DEFAULT_AUDIO_DRIVER, - - // System getDefaultStartupSelection: (): StartupSelection => DEFAULT_STARTUP_SELECTION, getDefaultBox64Version: (): Box64Version => DEFAULT_BOX64_VERSION, getDefaultBox86Version: (): Box86Version => DEFAULT_BOX86_VERSION, getDefaultBoxPreset: (): Box86_64Preset => DEFAULT_BOX_PRESET, - - // Video/Graphics settings + getDefaultEmulator: (): Emulator => DEFAULT_EMULATOR, + getDefaultContainerVariant: (): ContainerVariant => DEFAULT_CONTAINER_VARIANT, + getDefaultSteamType: (): SteamType => DEFAULT_STEAM_TYPE, + getDefaultFexcoreVersion: (): FEXCoreVersion => DEFAULT_FEXCORE_VERSION, + getDefaultFexcorePreset: (): FEXCorePreset => DEFAULT_FEXCORE_PRESET, getDefaultVideoMemorySize: (): string => DEFAULT_VIDEO_MEMORY_SIZE, getDefaultCpuList: (): string => DEFAULT_CPU_LIST, getDefaultVideoPciDeviceId: (): number => DEFAULT_VIDEO_PCI_DEVICE_ID, @@ -248,47 +391,53 @@ export const GameNativeDefaults = { getDefaultMouseWarpOverride: (): string => DEFAULT_MOUSE_WARP_OVERRIDE, getDefaultShaderBackend: (): string => DEFAULT_SHADER_BACKEND, getDefaultUseGlsl: (): string => DEFAULT_USE_GLSL, - - // Windows components getDefaultWindowsComponents: () => ({ ...DEFAULT_WINDOWS_COMPONENTS }), - // Validation functions isValidVideoMemorySize: (size: string): boolean => VALID_VIDEO_MEMORY_SIZES.includes(size), isValidOffscreenRenderingMode: (mode: string): boolean => VALID_OFFSCREEN_RENDERING_MODES.includes(mode.toLowerCase()), isValidMouseWarpOverride: (mode: string): boolean => VALID_MOUSE_WARP_OVERRIDE_MODES.includes(mode.toLowerCase()), isValidUseGlsl: (value: string): boolean => VALID_USE_GLSL_VALUES.includes(value.toLowerCase()), - isValidBox64Version: (version: string): boolean => version === '0.3.6' || version === '0.3.4', - isValidBox86Version: (version: string): boolean => version === '0.3.2' || version === '0.3.7', - isValidDxvkVersion: (version: string): boolean => - VALID_DXVK_VERSIONS.includes(version as DxvkVersion), + isValidBox64Version: (version: string): version is Box64Version => + VALID_BOX64_ALL_VERSIONS.some((v) => v === version), + isValidBox86Version: (version: string): version is Box86Version => + version === '0.3.2' || version === '0.3.7', + isValidDxvkVersion: (version: string): version is DxvkVersion => + VALID_DXVK_VERSIONS.some((v) => v === version), + isValidVkd3dVersion: (version: string): version is VKD3DVersion => + VALID_VKD3D_VERSIONS.some((v) => v === version), + isValidFexcoreVersion: (version: string): version is FEXCoreVersion => + VALID_FEXCORE_VERSIONS.some((v) => v === version), + isValidEmulator: (value: string): value is Emulator => value === 'FEXCore' || value === 'Box64', + isValidContainerVariant: (value: string): value is ContainerVariant => + value === 'glibc' || value === 'bionic', - // Graphics driver detection with smart fallback for legacy TEXT values detectGraphicsDriver: (value: string): GraphicsDriver => { if (!value) return DEFAULT_GRAPHICS_DRIVER const cleanValue = value.trim() - // First try exact match for new SELECT values (these are reliable) if (GRAPHICS_DRIVER_MAPPING[cleanValue]) return GRAPHICS_DRIVER_MAPPING[cleanValue] - // For legacy TEXT values, normalize by trimming whitespace and converting to lowercase const normalizedValue = cleanValue.toLowerCase() - // Try exact match after normalization for (const [key, driver] of Object.entries(GRAPHICS_DRIVER_MAPPING)) { if (key.toLowerCase() === normalizedValue) return driver } - // Try substring matching for legacy TEXT entries (handles messy user input) - if (normalizedValue.includes('turnip') || normalizedValue.includes('adreno')) { - return 'turnip' + if (normalizedValue.includes('sd-8-elite') || normalizedValue.includes('sd 8 elite')) { + return 'sd-8-elite' } + if (normalizedValue.includes('wrapper-leegao')) return 'wrapper-leegao' + if (normalizedValue.includes('wrapper-legacy')) return 'wrapper-legacy' + if (normalizedValue.includes('wrapper-v2')) return 'wrapper-v2' + if (normalizedValue.includes('wrapper')) return 'wrapper' + if (normalizedValue.includes('turnip')) return 'turnip' + if (normalizedValue.includes('adreno')) return 'adreno' if (normalizedValue.includes('virgl')) return 'virgl' if (normalizedValue.includes('vortek')) return 'vortek' - // If we can't determine, fall back to default return DEFAULT_GRAPHICS_DRIVER }, } diff --git a/src/server/utils/emulator-config/gamenative/gamenative.types.ts b/src/server/utils/emulator-config/gamenative/gamenative.types.ts index 1f810c6d..175c670b 100644 --- a/src/server/utils/emulator-config/gamenative/gamenative.types.ts +++ b/src/server/utils/emulator-config/gamenative/gamenative.types.ts @@ -1,15 +1,14 @@ /** * GameNative Container Configuration Type Definitions * - * Contains all type definitions for GameNative container configuration files. + * Source of truth: ContainerData.kt, Container.java, DefaultVersion.java, arrays.xml */ -// Screen resolution options export type ScreenSize = | '640x480' // 4:3 | '800x600' // 4:3 | '854x480' // 16:9 (default) - | '960x544' // 16:9 + | '960x540' // 16:9 | '1024x768' // 4:3 | '1280x720' // 16:9 | '1280x800' // 16:10 @@ -20,242 +19,234 @@ export type ScreenSize = | '1920x1080' // 16:9 | string // Custom format: "WIDTHxHEIGHT" -// Graphics driver options export type GraphicsDriver = - | 'vortek' // Universal (default) - uses Zink backend - | 'turnip' // Adreno GPUs - uses Zink + Turnip Vulkan - | 'virgl' // Universal - uses VirGL + | 'vortek' + | 'turnip' + | 'virgl' + | 'adreno' + | 'sd-8-elite' + | 'wrapper' + | 'wrapper-v2' + | 'wrapper-leegao' + | 'wrapper-legacy' -// Graphics driver versions by driver type export type GraphicsDriverVersions = { - turnip: '25.1.0' | '25.2.0' | '25.0.0' | '24.1.0' + turnip: '25.1.0' | '25.2.0' | '25.3.0' | '25.0.0' | '24.1.0' virgl: '23.1.9' - vortek: '2.0' - zink: '22.2.5' // Used internally by vortek/turnip + vortek: '2.1' + zink: '22.2.5' + adreno: '819.2' | '805' + sd8elite: '800.51' | '2-842.6' } -// DirectX wrapper options -export type DxWrapper = - | 'dxvk' // DXVK (default) - | 'vkd3d' // VKD3D - | 'wined3d' // WineD3D - | 'cnc-ddraw' // CNC DDraw +export type DxWrapper = 'dxvk' | 'vkd3d' | 'wined3d' | 'cnc-ddraw' -// DXVK version options export type DxvkVersion = - | '2.6.1-gplasync' + | 'async-1.10.3' + | '2.7.1' | '1.10.3' + | '1.10.1' | '1.10.9-sarek' + | '1.11.1-sarek' | '1.9.2' | '2.3.1' | '2.4-gplasync' - | 'async-1.10.3' + | '2.4.1' + | '2.4.1-gplasync' + | '2.6.1-gplasync' + | '2.6-arm64ec' + +export type VKD3DVersion = '2.6' | '2.12' | '2.13' | '2.14.1' | '3.0b' -// Audio driver options -export type AudioDriver = - | 'alsa' // ALSA (default) - | 'pulse' // PulseAudio +export type AudioDriver = 'alsa' | 'pulseaudio' -// Video memory size options export type VideoMemorySize = - | '32' // 32 MB - | '64' // 64 MB - | '128' // 128 MB - | '256' // 256 MB - | '512' // 512 MB - | '1024' // 1024 MB - | '2048' // 2048 MB (default) - | '4096' // 4096 MB - | '6144' // 6144 MB - | '8192' // 8192 MB - | '10240' // 10240 MB - | '12288' // 12288 MB + | '32' + | '64' + | '128' + | '256' + | '512' + | '1024' + | '2048' + | '4096' + | '6144' + | '8192' + | '10240' + | '12288' -// Box86/64 performance presets export type Box86_64Preset = | 'STABILITY' - | 'COMPATIBILITY' // Default + | 'COMPATIBILITY' | 'INTERMEDIATE' | 'PERFORMANCE' - | string // Custom presets start with "CUSTOM-" + | 'DENUVO' + | 'UNITY' + | 'UNITY_MONO_BLEEDING_EDGE' + | 'CUSTOM' + | string -// Box86/64 version options export type Box86Version = '0.3.2' | '0.3.7' -export type Box64Version = '0.3.6' | '0.3.4' +export type Box64Version = '0.3.2' | '0.3.4' | '0.3.6' | '0.3.7' | '0.3.8' | '0.4.0' + +export type FEXCorePreset = + | 'STABILITY' + | 'COMPATIBILITY' + | 'INTERMEDIATE' + | 'PERFORMANCE' + | 'EXTREME' + | 'DENUVO' + | 'CUSTOM' + | string -// Startup selection modes -export type StartupSelection = - | 0 // Normal (Load all services) - | 1 // Essential (Load only essential services) - Default - | 2 // Aggressive (Stop services on startup) +export type FEXCoreVersion = '2507' | '2508' | '2511' | '2512' | '2601' | '2603' + +export type FEXCoreTSOMode = 'Fast' | 'Slow' +export type FEXCoreX87Mode = 'Fast' | 'Slow' +export type FEXCoreMultiBlock = 'Enabled' | 'Disabled' + +export type Emulator = 'FEXCore' | 'Box64' + +export type ContainerVariant = 'glibc' | 'bionic' + +export type SteamType = 'normal' | 'light' | 'ultralight' + +export type ExternalDisplayMode = 'off' | 'touchpad' | 'keyboard' | 'hybrid' + +export type SharpnessEffect = 'None' | 'CAS' | 'DLS' + +export type StartupSelection = 0 | 1 | 2 -// Wine desktop theme options export type DesktopTheme = 'LIGHT' | 'DARK' -// Wine desktop background type -export type DesktopBackgroundType = - | 'IMAGE' // Default - | 'COLOR' +export type DesktopBackgroundType = 'IMAGE' | 'COLOR' -// Off-screen rendering modes -export type OffScreenRenderingMode = - | 'fbo' // FBO (default) - | 'backbuffer' // Backbuffer +export type OffScreenRenderingMode = 'fbo' | 'backbuffer' -// Mouse warp override options -export type MouseWarpOverride = - | 'disable' // Disable (default) - | 'enable' // Enable - | 'force' // Force +export type MouseWarpOverride = 'disable' | 'enable' | 'force' -// DirectInput mapper type -export type DinputMapperType = - | 0 // Standard (Old Gamepads) - | 1 // XInput (default) +export type DinputMapperType = 1 | 2 -// Shader backend options export type ShaderBackend = 'glsl' -// Wine component configuration export type WinComponents = string - -// Environment variables export type EnvVars = string - -// CPU list format export type CpuList = string -/** - * Complete container configuration interface - */ +export type VulkanVersion = '1.1' | '1.2' | '1.3' +export type BcnEmulation = 'none' | 'partial' | 'full' | 'auto' +export type BcnEmulationType = 'software' | 'compute' +export type PresentMode = 'mailbox' | 'fifo' | 'immediate' | 'relaxed' +export type ResourceType = 'auto' | 'dmabuf' | 'ahb' | 'opaque' + +export interface DxWrapperConfig { + version?: DxvkVersion + framerate?: number + maxDeviceMemory?: number + async?: '0' | '1' + asyncCache?: '0' | '1' + vkd3dVersion?: VKD3DVersion + vkd3dLevel?: string + ddrawrapper?: 'none' | string + csmt?: number + gpuName?: string + videoMemorySize?: string + strict_shader_math?: '0' | '1' + OffscreenRenderingMode?: OffScreenRenderingMode + renderer?: string +} + +export interface GraphicsDriverConfig { + vulkanVersion?: VulkanVersion + version?: string + blacklistedExtensions?: string + maxDeviceMemory?: number + presentMode?: PresentMode + syncFrame?: '0' | '1' + disablePresentWait?: '0' | '1' + resourceType?: ResourceType + bcnEmulation?: BcnEmulation + bcnEmulationType?: BcnEmulationType + bcnEmulationCache?: '0' | '1' + gpuName?: string + adrenotoolsTurnip?: '0' | '1' +} + export interface ContainerConfig { - /** Container name */ name?: string - - /** Screen resolution */ screenSize?: ScreenSize - - /** Environment variables */ envVars?: EnvVars - - /** Graphics driver */ graphicsDriver?: GraphicsDriver - - /** Graphics driver version */ graphicsDriverVersion?: string - - /** DirectX wrapper */ + graphicsDriverConfig?: string dxwrapper?: DxWrapper - - /** DirectX wrapper configuration */ dxwrapperConfig?: string - - /** DXVK version */ - dxvkVersion?: DxvkVersion - - /** Audio driver */ audioDriver?: AudioDriver - - /** Windows components configuration */ wincomponents?: WinComponents - - /** Execution arguments */ + drives?: string execArgs?: string - - /** Executable path */ executablePath?: string - - /** Show FPS overlay */ + installPath?: string showFPS?: boolean - - /** Launch real Steam client */ launchRealSteam?: boolean - - /** CPU core list */ + allowSteamUpdates?: boolean + steamType?: SteamType cpuList?: CpuList - - /** CPU core list for WoW64 */ cpuListWoW64?: CpuList - - /** Enable WoW64 mode */ wow64Mode?: boolean - - /** Startup selection mode */ startupSelection?: StartupSelection - - /** Box86 version */ box86Version?: Box86Version - - /** Box64 version */ box64Version?: Box64Version - - /** Box86 performance preset */ box86Preset?: Box86_64Preset - - /** Box64 performance preset */ box64Preset?: Box86_64Preset - - /** - * Desktop theme configuration - * Format: "THEME,BACKGROUND_TYPE,COLOR" - * - * Examples: - * - "LIGHT,IMAGE,#0277bd" (default) - * - "DARK,COLOR,#000000" - * - "LIGHT,COLOR,#ffffff" - */ desktopTheme?: string - - /** Enable SDL controller API */ - sdlControllerAPI?: boolean - - /** Enable XInput support */ - enableXInput?: boolean - - /** Enable DirectInput support */ - enableDInput?: boolean - - /** DirectInput mapper type */ - dinputMapperType?: DinputMapperType - - /** Disable mouse input */ - disableMouseInput?: boolean - - /** Enable Command Stream Multithreading */ + containerVariant?: ContainerVariant + wineVersion?: string + emulator?: Emulator + fexcoreVersion?: FEXCoreVersion | string + fexcoreTSOMode?: FEXCoreTSOMode + fexcoreX87Mode?: FEXCoreX87Mode + fexcoreMultiBlock?: FEXCoreMultiBlock + fexcorePreset?: FEXCorePreset + renderer?: string csmt?: boolean - - /** - * Video PCI device ID for GPU emulation - * Default: 1728 (NVIDIA GeForce GTX 480) - */ videoPciDeviceID?: number - - /** Off-screen rendering mode */ offScreenRenderingMode?: OffScreenRenderingMode - - /** Enable strict shader math */ strictShaderMath?: boolean - - /** Video memory size in MB */ + useDRI3?: boolean videoMemorySize?: VideoMemorySize - - /** Mouse warp override setting */ mouseWarpOverride?: MouseWarpOverride - - /** Shader backend */ shaderBackend?: ShaderBackend - - /** Use GLSL shaders */ useGLSL?: 'enabled' | 'disabled' + sdlControllerAPI?: boolean + useSteamInput?: boolean + enableXInput?: boolean + enableDInput?: boolean + dinputMapperType?: DinputMapperType + disableMouseInput?: boolean + touchscreenMode?: boolean + shooterMode?: boolean + gestureConfig?: string + externalDisplayMode?: ExternalDisplayMode + externalDisplaySwap?: boolean + language?: string + forceDlc?: boolean + steamOfflineMode?: boolean + useLegacyDRM?: boolean + unpackFiles?: boolean + portraitMode?: boolean + sharpnessEffect?: SharpnessEffect + sharpnessLevel?: number + sharpnessDenoise?: number } -/** - * Helper type for graphics driver version based on selected driver - */ export type GraphicsDriverVersionFor = T extends 'turnip' ? GraphicsDriverVersions['turnip'] : T extends 'virgl' ? GraphicsDriverVersions['virgl'] : T extends 'vortek' ? GraphicsDriverVersions['vortek'] - : string + : T extends 'adreno' + ? GraphicsDriverVersions['adreno'] + : T extends 'sd-8-elite' + ? GraphicsDriverVersions['sd8elite'] + : string diff --git a/src/shared/emulator-config/gamenative/index.ts b/src/shared/emulator-config/gamenative/index.ts new file mode 100644 index 00000000..b8ceae35 --- /dev/null +++ b/src/shared/emulator-config/gamenative/index.ts @@ -0,0 +1,13 @@ +import { parseGameNativeConfigFromJson } from './parser' +import { registerEmulatorConfigMapper } from '../index' +import type { EmulatorConfigMapper } from '../types' + +const gamenativeMapper: EmulatorConfigMapper = { + slug: 'gamenative', + fileTypes: ['json'], + parse: parseGameNativeConfigFromJson, +} + +registerEmulatorConfigMapper(gamenativeMapper) + +export default gamenativeMapper diff --git a/src/shared/emulator-config/gamenative/mapping.ts b/src/shared/emulator-config/gamenative/mapping.ts new file mode 100644 index 00000000..2e9532b1 --- /dev/null +++ b/src/shared/emulator-config/gamenative/mapping.ts @@ -0,0 +1,207 @@ +import { + GRAPHICS_DRIVER_MAPPING, + DX_WRAPPER_MAPPING, + STARTUP_SELECTION_MAPPING, + BOX64_PRESET_MAPPING, + FEXCORE_PRESET_MAPPING, +} from '@/server/utils/emulator-config/gamenative/gamenative.defaults' + +function createReverseLookup>( + mapping: T, +): Record { + const reverse: Record = {} + for (const [customValue, configValue] of Object.entries(mapping)) { + const key = String(configValue) + if (!(key in reverse)) { + reverse[key] = customValue + } + } + return reverse +} + +const GRAPHICS_DRIVER_REVERSE = createReverseLookup(GRAPHICS_DRIVER_MAPPING) +const DX_WRAPPER_REVERSE = createReverseLookup(DX_WRAPPER_MAPPING) +const STARTUP_SELECTION_REVERSE = createReverseLookup(STARTUP_SELECTION_MAPPING) + +export interface GameNativeFieldMapping { + jsonPath: string | string[] + fromConfig?: (rawValue: unknown, fullConfig?: Record) => unknown +} + +/** + * Parse a value from a comma-separated key=value config string + */ +export function parseConfigString(configStr: string, key: string): string | undefined { + if (!configStr) return undefined + for (const part of configStr.split(',')) { + const eqIndex = part.indexOf('=') + if (eqIndex === -1) continue + const k = part.slice(0, eqIndex).trim() + const v = part.slice(eqIndex + 1).trim() + if (k === key) return v + } + return undefined +} + +export const GAMENATIVE_IMPORT_MAPPINGS: Record = { + resolution: { + jsonPath: 'screenSize', + }, + + env_variables: { + jsonPath: 'envVars', + }, + + graphics_driver: { + jsonPath: 'graphicsDriver', + fromConfig: (value) => GRAPHICS_DRIVER_REVERSE[String(value)] ?? String(value), + }, + + dx_wrapper: { + jsonPath: 'dxwrapper', + fromConfig: (value) => DX_WRAPPER_REVERSE[String(value)] ?? String(value), + }, + + dx_wrapper_config: { + jsonPath: 'dxwrapperConfig', + }, + + dxvk_version: { + jsonPath: ['dxwrapperConfig', 'dxvkVersion'], + fromConfig: (value, fullConfig) => { + const configStr = String(value || '') + const fromConfigStr = parseConfigString(configStr, 'version') + if (fromConfigStr) return fromConfigStr + // Fallback: check top-level dxvkVersion for old configs + if (fullConfig && typeof fullConfig['dxvkVersion'] === 'string') { + return fullConfig['dxvkVersion'] + } + return configStr || undefined + }, + }, + + audio_driver: { + jsonPath: 'audioDriver', + fromConfig: (value) => { + const str = String(value) + if (str === 'pulseaudio' || str === 'pulse') return 'pulse' + if (str === 'alsa') return 'alsa' + return str + }, + }, + + exec_arguments: { + jsonPath: 'execArgs', + }, + + startup_selection: { + jsonPath: 'startupSelection', + fromConfig: (value) => STARTUP_SELECTION_REVERSE[String(value)], + }, + + box64_version: { + jsonPath: 'box64Version', + }, + + box64_preset: { + jsonPath: 'box64Preset', + fromConfig: (value) => { + const upper = String(value) + // Reverse: uppercase → lowercase for custom field + const reversed = createReverseLookup(BOX64_PRESET_MAPPING) + return reversed[upper] ?? upper.toLowerCase() + }, + }, + + container_variant: { + jsonPath: 'containerVariant', + }, + + wine_version: { + jsonPath: 'wineVersion', + }, + + steam_type: { + jsonPath: 'steamType', + fromConfig: (value) => { + const str = String(value) + if (str === 'ultralight') return 'ultra_light' + return str + }, + }, + + dynamic_driver_version: { + jsonPath: 'graphicsDriverVersion', + }, + + max_device_memory: { + jsonPath: ['graphicsDriverConfig', 'dxwrapperConfig'], + fromConfig: (value) => { + const configStr = String(value || '') + return parseConfigString(configStr, 'maxDeviceMemory') + }, + }, + + use_adrenotools_turnip: { + jsonPath: 'graphicsDriverConfig', + fromConfig: (value) => { + const configStr = String(value || '') + return parseConfigString(configStr, 'adrenotoolsTurnip') === '1' + }, + }, + + fex_core_version: { + jsonPath: 'fexcoreVersion', + }, + + '32_bit_emulator': { + jsonPath: 'emulator', + fromConfig: (value) => { + const str = String(value) + if (str === 'FEXCore') return 'fex' + if (str === 'Box64') return 'box' + return str + }, + }, + + '64_bit_emulator': { + jsonPath: 'emulator', + fromConfig: (value) => { + const str = String(value) + if (str === 'FEXCore') return 'fex' + if (str === 'Box64') return 'box' + return str + }, + }, + + fex_core_preset: { + jsonPath: 'fexcorePreset', + fromConfig: (value) => { + const upper = String(value) + const reversed = createReverseLookup(FEXCORE_PRESET_MAPPING) + return reversed[upper] ?? upper.toLowerCase() + }, + }, + + use_steam_input: { + jsonPath: 'useSteamInput', + }, + + enable_x_input_api: { + jsonPath: 'enableXInput', + }, + + enable_direct_input_api: { + jsonPath: 'enableDInput', + }, + + direct_input_mapper_type: { + jsonPath: 'dinputMapperType', + fromConfig: (value) => { + const num = Number(value) + if (num === 1) return 'standard' + if (num === 2) return 'xinput_mapper' + return String(value) + }, + }, +} diff --git a/src/shared/emulator-config/gamenative/parser.test.ts b/src/shared/emulator-config/gamenative/parser.test.ts new file mode 100644 index 00000000..cff4a433 --- /dev/null +++ b/src/shared/emulator-config/gamenative/parser.test.ts @@ -0,0 +1,627 @@ +import { describe, expect, it } from 'vitest' +import { CustomFieldType } from '@orm' +import { parseGameNativeConfigFromJson } from './parser' +import type { CustomFieldImportDefinition } from '../types' + +const baseFields: CustomFieldImportDefinition[] = [ + { + id: 'resolution', + name: 'resolution', + label: 'Resolution', + type: CustomFieldType.SELECT, + isRequired: false, + options: [ + { value: '854x480', label: '854x480' }, + { value: '1280x720', label: '1280x720' }, + { value: '1920x1080', label: '1920x1080' }, + ], + }, + { + id: 'graphics_driver', + name: 'graphics_driver', + label: 'Graphics Driver', + type: CustomFieldType.SELECT, + isRequired: true, + options: [ + { value: 'VirGL (Universal)', label: 'VirGL (Universal)' }, + { value: 'Turnip (Adreno)', label: 'Turnip (Adreno)' }, + { value: 'Vortek (Universal)', label: 'Vortek (Universal)' }, + ], + }, + { + id: 'dx_wrapper', + name: 'dx_wrapper', + label: 'DX Wrapper', + type: CustomFieldType.SELECT, + isRequired: true, + options: [ + { value: 'WineD3D', label: 'WineD3D' }, + { value: 'DXVK', label: 'DXVK' }, + { value: 'VKD3D', label: 'VKD3D' }, + ], + }, + { + id: 'dxvk_version', + name: 'dxvk_version', + label: 'DXVK Version', + type: CustomFieldType.SELECT, + isRequired: false, + options: [ + { value: '2.6.1-gplasync', label: '2.6.1-gplasync' }, + { value: 'async-1.10.3', label: 'async-1.10.3' }, + ], + }, + { + id: 'audio_driver', + name: 'audio_driver', + label: 'Audio Driver', + type: CustomFieldType.SELECT, + isRequired: true, + options: [ + { value: 'alsa', label: 'ALSA' }, + { value: 'pulse', label: 'PulseAudio' }, + ], + }, + { + id: 'startup_selection', + name: 'startup_selection', + label: 'Startup Selection', + type: CustomFieldType.SELECT, + isRequired: false, + options: [ + { value: 'Normal (Load all services)', label: 'Normal (Load all services)' }, + { value: 'Essential (Load only essential services)', label: 'Essential' }, + { value: 'Aggressive (Stop services on startup)', label: 'Aggressive' }, + ], + }, + { + id: 'box64_version', + name: 'box64_version', + label: 'Box64 Version', + type: CustomFieldType.SELECT, + isRequired: false, + options: [ + { value: '0.3.6', label: '0.3.6' }, + { value: '0.3.8', label: '0.3.8' }, + ], + }, + { + id: 'box64_preset', + name: 'box64_preset', + label: 'Box64 Preset', + type: CustomFieldType.SELECT, + isRequired: false, + options: [ + { value: 'stability', label: 'Stability' }, + { value: 'compatibility', label: 'Compatibility' }, + { value: 'intermediate', label: 'Intermediate' }, + { value: 'performance', label: 'Performance' }, + ], + }, + { + id: 'env_variables', + name: 'env_variables', + label: 'Environment Variables', + type: CustomFieldType.TEXTAREA, + isRequired: false, + }, + { + id: 'exec_arguments', + name: 'exec_arguments', + label: 'Execution Arguments', + type: CustomFieldType.TEXT, + isRequired: false, + }, + { + id: 'container_variant', + name: 'container_variant', + label: 'Container Variant', + type: CustomFieldType.SELECT, + isRequired: false, + options: [ + { value: 'glibc', label: 'Glibc' }, + { value: 'bionic', label: 'Bionic' }, + ], + }, + { + id: 'wine_version', + name: 'wine_version', + label: 'Wine Version', + type: CustomFieldType.TEXT, + isRequired: false, + }, + { + id: 'steam_type', + name: 'steam_type', + label: 'Steam Type', + type: CustomFieldType.SELECT, + isRequired: false, + options: [ + { value: 'normal', label: 'Normal' }, + { value: 'light', label: 'Light' }, + { value: 'ultra_light', label: 'Ultra Light' }, + ], + }, + { + id: 'dynamic_driver_version', + name: 'dynamic_driver_version', + label: 'Graphics Driver Version', + type: CustomFieldType.TEXT, + isRequired: false, + }, + { + id: 'fex_core_version', + name: 'fex_core_version', + label: 'FEXCore Version', + type: CustomFieldType.SELECT, + isRequired: false, + options: [{ value: '2603', label: '2603' }], + }, + { + id: '32_bit_emulator', + name: '32_bit_emulator', + label: '32-bit Emulator', + type: CustomFieldType.SELECT, + isRequired: false, + options: [ + { value: 'fex', label: 'FEXCore' }, + { value: 'box', label: 'Box64' }, + ], + }, + { + id: '64_bit_emulator', + name: '64_bit_emulator', + label: '64-bit Emulator', + type: CustomFieldType.SELECT, + isRequired: false, + options: [ + { value: 'fex', label: 'FEXCore' }, + { value: 'box', label: 'Box64' }, + ], + }, + { + id: 'fex_core_preset', + name: 'fex_core_preset', + label: 'FEXCore Preset', + type: CustomFieldType.SELECT, + isRequired: false, + options: [ + { value: 'stability', label: 'Stability' }, + { value: 'intermediate', label: 'Intermediate' }, + { value: 'performance', label: 'Performance' }, + ], + }, + { + id: 'use_steam_input', + name: 'use_steam_input', + label: 'Use Steam Input', + type: CustomFieldType.BOOLEAN, + isRequired: false, + }, + { + id: 'enable_x_input_api', + name: 'enable_x_input_api', + label: 'Enable XInput API', + type: CustomFieldType.BOOLEAN, + isRequired: false, + }, + { + id: 'enable_direct_input_api', + name: 'enable_direct_input_api', + label: 'Enable DirectInput API', + type: CustomFieldType.BOOLEAN, + isRequired: false, + }, + { + id: 'direct_input_mapper_type', + name: 'direct_input_mapper_type', + label: 'DirectInput Mapper Type', + type: CustomFieldType.SELECT, + isRequired: false, + options: [ + { value: 'standard', label: 'Standard' }, + { value: 'xinput_mapper', label: 'XInput Mapper' }, + ], + }, + { + id: 'use_adrenotools_turnip', + name: 'use_adrenotools_turnip', + label: 'Use Adrenotools Turnip', + type: CustomFieldType.BOOLEAN, + isRequired: false, + }, + { + id: 'max_device_memory', + name: 'max_device_memory', + label: 'Max Device Memory', + type: CustomFieldType.TEXT, + isRequired: false, + }, +] + +const SAMPLE_CONFIG = { + screenSize: '1920x1080', + graphicsDriver: 'turnip', + graphicsDriverVersion: '25.2.0', + graphicsDriverConfig: 'adrenotoolsTurnip=1,maxDeviceMemory=4096', + dxwrapper: 'dxvk', + dxwrapperConfig: 'version=async-1.10.3,maxDeviceMemory=0', + audioDriver: 'pulseaudio', + startupSelection: 1, + box64Version: '0.3.6', + box64Preset: 'COMPATIBILITY', + envVars: 'ZINK_DESCRIPTORS=lazy MESA_SHADER_CACHE_DISABLE=false', + execArgs: '-windowed', + containerVariant: 'glibc', + wineVersion: 'wine-9.2-x86_64', + steamType: 'ultralight', + emulator: 'FEXCore', + fexcoreVersion: '2603', + fexcorePreset: 'INTERMEDIATE', + useSteamInput: false, + enableXInput: true, + enableDInput: true, + dinputMapperType: 1, +} + +describe('parseGameNativeConfigFromJson', () => { + it('parses a full config and maps all known fields', () => { + const raw = JSON.stringify(SAMPLE_CONFIG) + const result = parseGameNativeConfigFromJson(raw, baseFields) + + expect(result.warnings).toHaveLength(0) + + const valueMap = new Map(result.values.map((v) => [v.id, v.value])) + + expect(valueMap.get('resolution')).toBe('1920x1080') + expect(valueMap.get('graphics_driver')).toBe('Turnip (Adreno)') + expect(valueMap.get('dx_wrapper')).toBe('DXVK') + expect(valueMap.get('dxvk_version')).toBe('async-1.10.3') + expect(valueMap.get('audio_driver')).toBe('pulse') + expect(valueMap.get('startup_selection')).toBe('Essential (Load only essential services)') + expect(valueMap.get('box64_version')).toBe('0.3.6') + expect(valueMap.get('box64_preset')).toBe('compatibility') + expect(valueMap.get('env_variables')).toBe( + 'ZINK_DESCRIPTORS=lazy MESA_SHADER_CACHE_DISABLE=false', + ) + expect(valueMap.get('exec_arguments')).toBe('-windowed') + expect(valueMap.get('container_variant')).toBe('glibc') + expect(valueMap.get('wine_version')).toBe('wine-9.2-x86_64') + expect(valueMap.get('steam_type')).toBe('ultra_light') + expect(valueMap.get('dynamic_driver_version')).toBe('25.2.0') + expect(valueMap.get('fex_core_version')).toBe('2603') + expect(valueMap.get('32_bit_emulator')).toBe('fex') + expect(valueMap.get('64_bit_emulator')).toBe('fex') + expect(valueMap.get('fex_core_preset')).toBe('intermediate') + expect(valueMap.get('use_steam_input')).toBe(false) + expect(valueMap.get('enable_x_input_api')).toBe(true) + expect(valueMap.get('enable_direct_input_api')).toBe(true) + expect(valueMap.get('direct_input_mapper_type')).toBe('standard') + expect(valueMap.get('use_adrenotools_turnip')).toBe(true) + expect(valueMap.get('max_device_memory')).toBe('4096') + }) + + it('extracts dxvk_version from dxwrapperConfig string', () => { + const raw = JSON.stringify({ dxwrapperConfig: 'version=2.6.1-gplasync,async=1' }) + const fields: CustomFieldImportDefinition[] = [ + { + id: 'dxvk_version', + name: 'dxvk_version', + label: 'DXVK Version', + type: CustomFieldType.SELECT, + isRequired: false, + }, + ] + const result = parseGameNativeConfigFromJson(raw, fields) + const valueMap = new Map(result.values.map((v) => [v.id, v.value])) + expect(valueMap.get('dxvk_version')).toBe('2.6.1-gplasync') + }) + + it('falls back to top-level dxvkVersion for old configs', () => { + const raw = JSON.stringify({ dxvkVersion: '1.10.3', dxwrapperConfig: 'async=1' }) + const fields: CustomFieldImportDefinition[] = [ + { + id: 'dxvk_version', + name: 'dxvk_version', + label: 'DXVK Version', + type: CustomFieldType.SELECT, + isRequired: false, + }, + ] + const result = parseGameNativeConfigFromJson(raw, fields) + const valueMap = new Map(result.values.map((v) => [v.id, v.value])) + expect(valueMap.get('dxvk_version')).toBe('1.10.3') + }) + + it('handles audio driver backward compatibility with "pulse"', () => { + const raw = JSON.stringify({ audioDriver: 'pulse' }) + const fields: CustomFieldImportDefinition[] = [ + { + id: 'audio_driver', + name: 'audio_driver', + label: 'Audio Driver', + type: CustomFieldType.SELECT, + isRequired: false, + }, + ] + const result = parseGameNativeConfigFromJson(raw, fields) + const valueMap = new Map(result.values.map((v) => [v.id, v.value])) + expect(valueMap.get('audio_driver')).toBe('pulse') + }) + + it('handles minimal config with missing optional fields', () => { + const minimalConfig = { + graphicsDriver: 'vortek', + dxwrapper: 'wined3d', + audioDriver: 'alsa', + } + const raw = JSON.stringify(minimalConfig) + const result = parseGameNativeConfigFromJson(raw, baseFields) + + const valueMap = new Map(result.values.map((v) => [v.id, v.value])) + + expect(valueMap.get('graphics_driver')).toBe('Vortek (Universal)') + expect(valueMap.get('dx_wrapper')).toBe('WineD3D') + expect(valueMap.get('audio_driver')).toBe('alsa') + expect(valueMap.has('resolution')).toBe(false) + }) + + it('returns warning for invalid JSON', () => { + const result = parseGameNativeConfigFromJson('not valid json{', baseFields) + + expect(result.values).toHaveLength(0) + expect(result.warnings).toContain('Failed to parse JSON configuration file.') + }) + + it('marks required fields as missing when not in JSON and no default', () => { + const raw = JSON.stringify({}) + const fields: CustomFieldImportDefinition[] = [ + { + id: 'graphics_driver', + name: 'graphics_driver', + label: 'Graphics Driver', + type: CustomFieldType.SELECT, + isRequired: true, + }, + ] + const result = parseGameNativeConfigFromJson(raw, fields) + + expect(result.missing).toContain('Graphics Driver') + }) + + it('uses defaultValue for unmapped fields', () => { + const raw = JSON.stringify(SAMPLE_CONFIG) + const fields: CustomFieldImportDefinition[] = [ + { + id: 'unknown_field', + name: 'unknown_field', + label: 'Unknown Field', + type: CustomFieldType.TEXT, + isRequired: false, + defaultValue: 'fallback', + }, + ] + const result = parseGameNativeConfigFromJson(raw, fields) + const valueMap = new Map(result.values.map((v) => [v.id, v.value])) + + expect(valueMap.get('unknown_field')).toBe('fallback') + }) + + it('uses defaultValue when mapped JSON key is absent', () => { + const raw = JSON.stringify({}) + const fields: CustomFieldImportDefinition[] = [ + { + id: 'resolution', + name: 'resolution', + label: 'Resolution', + type: CustomFieldType.SELECT, + isRequired: false, + defaultValue: '854x480', + }, + ] + const result = parseGameNativeConfigFromJson(raw, fields) + const valueMap = new Map(result.values.map((v) => [v.id, v.value])) + + expect(valueMap.get('resolution')).toBe('854x480') + }) + + it('reverse-maps startup_selection numeric values', () => { + const configs = [ + { startupSelection: 0, expected: 'Normal (Load all services)' }, + { startupSelection: 1, expected: 'Essential (Load only essential services)' }, + { startupSelection: 2, expected: 'Aggressive (Stop services on startup)' }, + ] + + for (const { startupSelection, expected } of configs) { + const raw = JSON.stringify({ startupSelection }) + const fields: CustomFieldImportDefinition[] = [ + { + id: 'startup_selection', + name: 'startup_selection', + label: 'Startup Selection', + type: CustomFieldType.SELECT, + isRequired: false, + }, + ] + const result = parseGameNativeConfigFromJson(raw, fields) + const valueMap = new Map(result.values.map((v) => [v.id, v.value])) + expect(valueMap.get('startup_selection')).toBe(expected) + } + }) + + it('reverse-maps box64 presets from uppercase to lowercase', () => { + const presets = [ + { preset: 'STABILITY', expected: 'stability' }, + { preset: 'COMPATIBILITY', expected: 'compatibility' }, + { preset: 'INTERMEDIATE', expected: 'intermediate' }, + { preset: 'PERFORMANCE', expected: 'performance' }, + ] + + for (const { preset, expected } of presets) { + const raw = JSON.stringify({ box64Preset: preset }) + const fields: CustomFieldImportDefinition[] = [ + { + id: 'box64_preset', + name: 'box64_preset', + label: 'Box64 Preset', + type: CustomFieldType.SELECT, + isRequired: false, + }, + ] + const result = parseGameNativeConfigFromJson(raw, fields) + const valueMap = new Map(result.values.map((v) => [v.id, v.value])) + expect(valueMap.get('box64_preset')).toBe(expected) + } + }) + + it('handles all graphics driver reverse mappings', () => { + const drivers = [ + { config: 'virgl', expected: 'VirGL (Universal)' }, + { config: 'turnip', expected: 'Turnip (Adreno)' }, + { config: 'vortek', expected: 'Vortek (Universal)' }, + ] + + for (const { config, expected } of drivers) { + const raw = JSON.stringify({ graphicsDriver: config }) + const fields: CustomFieldImportDefinition[] = [ + { + id: 'graphics_driver', + name: 'graphics_driver', + label: 'Graphics Driver', + type: CustomFieldType.SELECT, + isRequired: true, + }, + ] + const result = parseGameNativeConfigFromJson(raw, fields) + const valueMap = new Map(result.values.map((v) => [v.id, v.value])) + expect(valueMap.get('graphics_driver')).toBe(expected) + } + }) + + it('passes through unknown graphics driver values', () => { + const raw = JSON.stringify({ graphicsDriver: 'future_driver' }) + const fields: CustomFieldImportDefinition[] = [ + { + id: 'graphics_driver', + name: 'graphics_driver', + label: 'Graphics Driver', + type: CustomFieldType.SELECT, + isRequired: true, + }, + ] + const result = parseGameNativeConfigFromJson(raw, fields) + const valueMap = new Map(result.values.map((v) => [v.id, v.value])) + expect(valueMap.get('graphics_driver')).toBe('future_driver') + }) + + it('reverse-maps dinputMapperType numbers to strings', () => { + const raw = JSON.stringify({ dinputMapperType: 2 }) + const fields: CustomFieldImportDefinition[] = [ + { + id: 'direct_input_mapper_type', + name: 'direct_input_mapper_type', + label: 'DirectInput Mapper Type', + type: CustomFieldType.SELECT, + isRequired: false, + }, + ] + const result = parseGameNativeConfigFromJson(raw, fields) + const valueMap = new Map(result.values.map((v) => [v.id, v.value])) + expect(valueMap.get('direct_input_mapper_type')).toBe('xinput_mapper') + }) + + it('reverse-maps emulator field to fex/box values', () => { + const raw = JSON.stringify({ emulator: 'Box64' }) + const fields: CustomFieldImportDefinition[] = [ + { + id: '64_bit_emulator', + name: '64_bit_emulator', + label: '64-bit Emulator', + type: CustomFieldType.SELECT, + isRequired: false, + }, + ] + const result = parseGameNativeConfigFromJson(raw, fields) + const valueMap = new Map(result.values.map((v) => [v.id, v.value])) + expect(valueMap.get('64_bit_emulator')).toBe('box') + }) + + it('detects use_adrenotools_turnip from graphicsDriverConfig', () => { + const configEnabled = JSON.stringify({ + graphicsDriverConfig: 'adrenotoolsTurnip=1,vulkanVersion=1.3', + }) + const configDisabled = JSON.stringify({ + graphicsDriverConfig: 'adrenotoolsTurnip=0,vulkanVersion=1.3', + }) + const configMissing = JSON.stringify({ graphicsDriverConfig: 'vulkanVersion=1.3' }) + const fields: CustomFieldImportDefinition[] = [ + { + id: 'use_adrenotools_turnip', + name: 'use_adrenotools_turnip', + label: 'Use Adrenotools Turnip', + type: CustomFieldType.BOOLEAN, + isRequired: false, + }, + ] + + const resultEnabled = parseGameNativeConfigFromJson(configEnabled, fields) + const mapEnabled = new Map(resultEnabled.values.map((v) => [v.id, v.value])) + expect(mapEnabled.get('use_adrenotools_turnip')).toBe(true) + + const resultDisabled = parseGameNativeConfigFromJson(configDisabled, fields) + const mapDisabled = new Map(resultDisabled.values.map((v) => [v.id, v.value])) + expect(mapDisabled.get('use_adrenotools_turnip')).toBe(false) + + const resultMissing = parseGameNativeConfigFromJson(configMissing, fields) + const mapMissing = new Map(resultMissing.values.map((v) => [v.id, v.value])) + expect(mapMissing.get('use_adrenotools_turnip')).toBe(false) + }) + + it('parses max_device_memory from graphicsDriverConfig', () => { + const raw = JSON.stringify({ graphicsDriverConfig: 'vulkanVersion=1.3,maxDeviceMemory=8192' }) + const fields: CustomFieldImportDefinition[] = [ + { + id: 'max_device_memory', + name: 'max_device_memory', + label: 'Max Device Memory', + type: CustomFieldType.TEXT, + isRequired: false, + }, + ] + const result = parseGameNativeConfigFromJson(raw, fields) + const valueMap = new Map(result.values.map((v) => [v.id, v.value])) + expect(valueMap.get('max_device_memory')).toBe('8192') + }) + + it('reverse-maps FEXCore presets from uppercase to lowercase', () => { + const raw = JSON.stringify({ fexcorePreset: 'PERFORMANCE' }) + const fields: CustomFieldImportDefinition[] = [ + { + id: 'fex_core_preset', + name: 'fex_core_preset', + label: 'FEXCore Preset', + type: CustomFieldType.SELECT, + isRequired: false, + }, + ] + const result = parseGameNativeConfigFromJson(raw, fields) + const valueMap = new Map(result.values.map((v) => [v.id, v.value])) + expect(valueMap.get('fex_core_preset')).toBe('performance') + }) + + it('reverse-maps steam_type ultralight to ultra_light', () => { + const raw = JSON.stringify({ steamType: 'ultralight' }) + const fields: CustomFieldImportDefinition[] = [ + { + id: 'steam_type', + name: 'steam_type', + label: 'Steam Type', + type: CustomFieldType.SELECT, + isRequired: false, + }, + ] + const result = parseGameNativeConfigFromJson(raw, fields) + const valueMap = new Map(result.values.map((v) => [v.id, v.value])) + expect(valueMap.get('steam_type')).toBe('ultra_light') + }) +}) diff --git a/src/shared/emulator-config/gamenative/parser.ts b/src/shared/emulator-config/gamenative/parser.ts new file mode 100644 index 00000000..93ad3a38 --- /dev/null +++ b/src/shared/emulator-config/gamenative/parser.ts @@ -0,0 +1,74 @@ +import { GAMENATIVE_IMPORT_MAPPINGS } from './mapping' +import type { CustomFieldImportDefinition, EmulatorConfigImportResult } from '../types' + +function getNestedValue(obj: Record, path: string): unknown { + const keys = path.split('.') + let current: unknown = obj + for (const key of keys) { + if (current === null || current === undefined || typeof current !== 'object') { + return undefined + } + current = (current as Record)[key] + } + return current +} + +export function parseGameNativeConfigFromJson( + raw: string, + customFields: CustomFieldImportDefinition[], +): EmulatorConfigImportResult { + const values: { id: string; value: unknown }[] = [] + const missing: string[] = [] + const warnings: string[] = [] + + let config: Record + try { + config = JSON.parse(raw) as Record + } catch { + warnings.push('Failed to parse JSON configuration file.') + return { values, missing, warnings } + } + + if (typeof config !== 'object' || config === null) { + warnings.push('Configuration file does not contain a valid JSON object.') + return { values, missing, warnings } + } + + for (const field of customFields) { + const mapping = GAMENATIVE_IMPORT_MAPPINGS[field.name] + if (!mapping) { + if (field.defaultValue !== undefined && field.defaultValue !== null) { + values.push({ id: field.id, value: field.defaultValue }) + } else if (field.isRequired) { + missing.push(field.label) + } + continue + } + + const paths = Array.isArray(mapping.jsonPath) ? mapping.jsonPath : [mapping.jsonPath] + let rawValue: unknown + let found = false + + for (const path of paths) { + rawValue = getNestedValue(config, path) + if (rawValue !== undefined) { + found = true + break + } + } + + if (!found) { + if (field.defaultValue !== undefined && field.defaultValue !== null) { + values.push({ id: field.id, value: field.defaultValue }) + } else if (field.isRequired) { + missing.push(field.label) + } + continue + } + + const value = mapping.fromConfig ? mapping.fromConfig(rawValue, config) : rawValue + values.push({ id: field.id, value }) + } + + return { values, missing, warnings } +} From 711015f9a4743d3dd239ae80cd55dcbe265139ac Mon Sep 17 00:00:00 2001 From: ObfuscatedVoid Date: Mon, 13 Apr 2026 19:41:55 +0200 Subject: [PATCH 2/6] refactor(tests): remove obsolete helpers and page objects --- tests/accessibility.spec.ts | 350 ++++++------------ tests/admin-dashboard.spec.ts | 231 +++--------- tests/admin-permissions.spec.ts | 93 ++--- tests/admin-reports.spec.ts | 167 +++------ tests/admin-users.spec.ts | 167 +++------ tests/android-downloads.spec.ts | 2 - tests/auth.spec.ts | 344 ++++------------- tests/badge-system.spec.ts | 133 ++----- tests/browsing.spec.ts | 387 +++++--------------- tests/commenting.spec.ts | 48 +-- tests/custom-fields.spec.ts | 89 +---- tests/data-setup.spec.ts | 37 ++ tests/error-handling.spec.ts | 266 +++----------- tests/filtering.spec.ts | 379 ++++--------------- tests/fixtures/test-fixtures.ts | 39 -- tests/forms.spec.ts | 384 +++++-------------- tests/full-listing-flow.spec.ts | 123 ++----- tests/game-management.spec.ts | 157 +++----- tests/helpers/cookie-helper.ts | 63 ---- tests/helpers/data-factory.ts | 372 +++++++++++++++++++ tests/helpers/navigation.ts | 334 ----------------- tests/helpers/test-config.ts | 75 +--- tests/igdb-search.spec.ts | 62 +--- tests/listing-approval.spec.ts | 169 +++------ tests/listings-success-rate-sorting.spec.ts | 59 +-- tests/navigation.spec.ts | 170 +++------ tests/notification-system.spec.ts | 102 ++---- tests/pages/AuthPage.ts | 192 +--------- tests/pages/BasePage.ts | 268 ++------------ tests/pages/CookieBanner.ts | 87 ----- tests/pages/GameFormPage.ts | 172 +-------- tests/pages/GamesPage.ts | 95 +---- tests/pages/HomePage.ts | 82 ----- tests/pages/ListingFormPage.ts | 255 ------------- tests/pages/ListingsPage.ts | 189 ++-------- tests/pagination.spec.ts | 291 +++------------ tests/pc-listings.spec.ts | 74 +--- tests/performance.spec.ts | 182 ++------- tests/router-coverage.spec.ts | 27 +- tests/search.spec.ts | 202 +--------- tests/trust-system.spec.ts | 75 +--- tests/user-flows.spec.ts | 336 ++--------------- tests/user-moderation.spec.ts | 95 ++--- tests/validate-tests.spec.ts | 48 --- tests/voting.spec.ts | 43 +-- 45 files changed, 1634 insertions(+), 5881 deletions(-) create mode 100644 tests/data-setup.spec.ts delete mode 100644 tests/fixtures/test-fixtures.ts delete mode 100644 tests/helpers/cookie-helper.ts create mode 100644 tests/helpers/data-factory.ts delete mode 100644 tests/helpers/navigation.ts delete mode 100644 tests/pages/CookieBanner.ts delete mode 100644 tests/pages/ListingFormPage.ts delete mode 100644 tests/validate-tests.spec.ts diff --git a/tests/accessibility.spec.ts b/tests/accessibility.spec.ts index d40b63e3..06411553 100644 --- a/tests/accessibility.spec.ts +++ b/tests/accessibility.spec.ts @@ -8,24 +8,16 @@ test.describe('Accessibility Tests', () => { const homePage = new HomePage(page) await homePage.goto() - // Check for h1 - const h1Elements = page.locator('h1') - const h1Count = await h1Elements.count() + const h1Count = await page.locator('h1').count() expect(h1Count).toBeGreaterThanOrEqual(1) - expect(h1Count).toBeLessThanOrEqual(1) // Should only have one h1 + expect(h1Count).toBeLessThanOrEqual(1) - // Check heading hierarchy - const headings = page.locator('h1, h2, h3, h4, h5, h6') - const headingLevels = await headings.evaluateAll((elements) => - elements.map((el) => parseInt(el.tagName.substring(1))), - ) + const headingLevels = await page + .locator('h1, h2, h3, h4, h5, h6') + .evaluateAll((elements) => elements.map((el) => parseInt(el.tagName.substring(1)))) - // Verify no skipped heading levels for (let i = 1; i < headingLevels.length; i++) { - const diff = headingLevels[i] - headingLevels[i - 1] - // Heading levels should not skip more than 1 level - // TODO: Fix heading hierarchy in the app - expect(diff).toBeLessThanOrEqual(2) + expect(headingLevels[i] - headingLevels[i - 1]).toBeLessThanOrEqual(2) } }) @@ -33,57 +25,46 @@ test.describe('Accessibility Tests', () => { const gamesPage = new GamesPage(page) await gamesPage.goto() - // Check buttons have accessible names - const buttons = page.locator('button') - const buttonCount = await buttons.count() - - for (let i = 0; i < Math.min(buttonCount, 5); i++) { - // Check first 5 buttons - const button = buttons.nth(i) - const accessibleName = - (await button.getAttribute('aria-label')) || - (await button.textContent()) || - (await button.getAttribute('title')) + const buttonData = await page.locator('button').evaluateAll((elements) => + elements.slice(0, 5).map((el) => ({ + ariaLabel: el.getAttribute('aria-label'), + text: el.textContent, + title: el.getAttribute('title'), + })), + ) - expect(accessibleName).toBeTruthy() - expect(accessibleName!.trim().length).toBeGreaterThan(0) + for (const btn of buttonData) { + const name = btn.ariaLabel || btn.text || btn.title + expect(name).toBeTruthy() + expect(name!.trim().length).toBeGreaterThan(0) } - // Check links have accessible text - const links = page.locator('a') - const linkCount = await links.count() - - for (let i = 0; i < Math.min(linkCount, 5); i++) { - const link = links.nth(i) - const linkText = (await link.textContent()) || (await link.getAttribute('aria-label')) + const linkData = await page.locator('a').evaluateAll((elements) => + elements.slice(0, 5).map((el) => ({ + text: el.textContent, + ariaLabel: el.getAttribute('aria-label'), + })), + ) - // Should not have generic link text - expect(linkText?.toLowerCase()).not.toMatch(/^(click here|here|link)$/) + for (const link of linkData) { + const text = link.text || link.ariaLabel + expect(text?.toLowerCase()).not.toMatch(/^(click here|here|link)$/) } }) test('should have alt text for all images', async ({ page }) => { const listingsPage = new ListingsPage(page) await listingsPage.goto() - - // Wait for content to load await listingsPage.verifyPageLoaded() - // Check all images - const images = page.locator('img') - const imageCount = await images.count() - - for (let i = 0; i < imageCount; i++) { - const img = images.nth(i) - const altText = await img.getAttribute('alt') + const altTexts = await page + .locator('img') + .evaluateAll((elements) => elements.map((el) => el.getAttribute('alt'))) - // Every image should have alt text + for (const altText of altTexts) { expect(altText).toBeDefined() - - // Decorative images should have empty alt="" - // Content images should have descriptive alt if (altText !== '') { - expect(altText!.length).toBeGreaterThan(3) // Not just "img" or "pic" + expect(altText!.length).toBeGreaterThan(3) } } }) @@ -92,28 +73,18 @@ test.describe('Accessibility Tests', () => { const homePage = new HomePage(page) await homePage.goto() - // Tab through interactive elements - const tabSequence = [] - + const tabData = [] for (let i = 0; i < 10; i++) { await page.keyboard.press('Tab') - - const focusedElement = page.locator(':focus') - const tagName = await focusedElement.evaluate((el) => el.tagName.toLowerCase()) - const text = await focusedElement.textContent().catch(() => '') - - tabSequence.push({ tagName, text }) - - // Focused element should be visible - await expect(focusedElement).toBeVisible() + const [tagName, text] = await page.evaluate(() => { + const focused = document.activeElement + return [focused?.tagName.toLowerCase() ?? '', focused?.textContent ?? ''] + }) + tabData.push({ tagName, text }) } - // Should have focused on various interactive elements const interactiveTags = ['a', 'button', 'input', 'select', 'textarea'] - const hasInteractiveElements = tabSequence.some((item) => - interactiveTags.includes(item.tagName), - ) - + const hasInteractiveElements = tabData.some((item) => interactiveTags.includes(item.tagName)) expect(hasInteractiveElements).toBe(true) }) @@ -121,41 +92,30 @@ test.describe('Accessibility Tests', () => { const gamesPage = new GamesPage(page) await gamesPage.goto() - // Check text elements for potential contrast issues - const textElements = page + const styles = await page .locator('p, span, div, h1, h2, h3, h4, h5, h6') .filter({ hasText: /\S+/ }) - const sampleSize = Math.min(await textElements.count(), 10) - - for (let i = 0; i < sampleSize; i++) { - const element = textElements.nth(i) - - const styles = await element.evaluate((el) => { - const computed = window.getComputedStyle(el) - return { - color: computed.color, - backgroundColor: computed.backgroundColor, - fontSize: computed.fontSize, - fontWeight: computed.fontWeight, - } - }) - - // Skip transparent elements as they inherit parent background - if ( - styles.backgroundColor === 'rgba(0, 0, 0, 0)' || - styles.backgroundColor === 'transparent' - ) { + .evaluateAll((elements) => + elements.slice(0, 10).map((el) => { + const computed = window.getComputedStyle(el) + return { + color: computed.color, + backgroundColor: computed.backgroundColor, + fontSize: computed.fontSize, + } + }), + ) + + for (const s of styles) { + if (s.backgroundColor === 'rgba(0, 0, 0, 0)' || s.backgroundColor === 'transparent') { continue } - // Basic check: text should not be same color as background - if (styles.color !== 'rgba(0, 0, 0, 0)' && styles.color !== 'transparent') { - expect(styles.color).not.toBe(styles.backgroundColor) + if (s.color !== 'rgba(0, 0, 0, 0)' && s.color !== 'transparent') { + expect(s.color).not.toBe(s.backgroundColor) } - // Text should be readable size - const fontSize = parseInt(styles.fontSize) - expect(fontSize).toBeGreaterThanOrEqual(12) + expect(parseInt(s.fontSize)).toBeGreaterThanOrEqual(12) } }) @@ -163,11 +123,9 @@ test.describe('Accessibility Tests', () => { const homePage = new HomePage(page) await homePage.goto() - // Focus on first link const firstLink = page.locator('a').first() await firstLink.focus() - // Check if focus is visible const focusStyles = await firstLink.evaluate((el) => { const computed = window.getComputedStyle(el) return { @@ -179,7 +137,6 @@ test.describe('Accessibility Tests', () => { } }) - // Should have some visual focus indicator const hasFocusIndicator = (focusStyles.outline && focusStyles.outline !== 'none') || (focusStyles.outlineWidth && focusStyles.outlineWidth !== '0px') || @@ -193,31 +150,24 @@ test.describe('Accessibility Tests', () => { const listingsPage = new ListingsPage(page) await listingsPage.goto() - // Check all form inputs - const inputs = page.locator('input, select, textarea') - const inputCount = await inputs.count() - - for (let i = 0; i < inputCount; i++) { - const input = inputs.nth(i) - const inputId = await input.getAttribute('id') - const ariaLabel = await input.getAttribute('aria-label') - const ariaLabelledBy = await input.getAttribute('aria-labelledby') - const placeholder = await input.getAttribute('placeholder') - - // Check for associated label - let hasLabel = false - - if (inputId) { - const label = page.locator(`label[for="${inputId}"]`) - hasLabel = (await label.count()) > 0 - } - - // Input should have some form of label - const hasAccessibleName = hasLabel || ariaLabel || ariaLabelledBy - - // Placeholder alone is not sufficient (except for search inputs which are commonly understood) - // TODO: Add proper labels to form inputs - expect(hasAccessibleName || !!placeholder).toBe(true) + const inputAccessibility = await page + .locator('input, select, textarea') + .evaluateAll((elements) => + elements.map((el) => { + const id = el.getAttribute('id') + const hasLabel = id ? !!document.querySelector(`label[for="${CSS.escape(id)}"]`) : false + return { + hasLabel, + ariaLabel: el.getAttribute('aria-label'), + ariaLabelledBy: el.getAttribute('aria-labelledby'), + placeholder: el.getAttribute('placeholder'), + } + }), + ) + + for (const input of inputAccessibility) { + const hasAccessibleName = input.hasLabel || input.ariaLabel || input.ariaLabelledBy + expect(hasAccessibleName || !!input.placeholder).toBe(true) } }) @@ -225,13 +175,10 @@ test.describe('Accessibility Tests', () => { const homePage = new HomePage(page) await homePage.goto() - // Navigate to trigger potential announcements await homePage.navigateToGames() - // Page title should update - wait for navigation to complete await page.waitForLoadState('domcontentloaded') const title = await page.title() - // Title should be "Games | EmuReady" or similar expect(title.toLowerCase()).toMatch(/games|emuready/) }) @@ -239,25 +186,23 @@ test.describe('Accessibility Tests', () => { const homePage = new HomePage(page) await homePage.goto() - // Look for skip links (often hidden until focused) - const skipLinks = page.locator('a').filter({ hasText: /skip to (content|main|navigation)/i }) - - if ((await skipLinks.count()) > 0) { - const firstSkipLink = skipLinks.first() - - // Focus to make it visible - await firstSkipLink.focus() - - // Should become visible when focused - await expect(firstSkipLink).toBeVisible() - - // Should have proper href - const href = await firstSkipLink.getAttribute('href') - expect(href).toMatch(/^#\w+/) + const skipLinkData = await page + .locator('a') + .filter({ hasText: /skip to (content|main|navigation)/i }) + .evaluateAll((elements) => + elements.map((el) => ({ + href: el.getAttribute('href'), + text: el.textContent, + })), + ) + + if (skipLinkData.length > 0) { + expect(skipLinkData[0].href).toMatch(/^#\w+/) } else { - // Check if main content has id for direct navigation - const mainContent = page.locator('main, [role="main"]') - const hasMainLandmark = (await mainContent.count()) > 0 + const hasMainLandmark = await page + .locator('main, [role="main"]') + .count() + .then((c) => c > 0) expect(hasMainLandmark).toBe(true) } }) @@ -266,79 +211,37 @@ test.describe('Accessibility Tests', () => { const gamesPage = new GamesPage(page) await gamesPage.goto() - // Check html lang attribute const htmlLang = await page.locator('html').getAttribute('lang') expect(htmlLang).toBeTruthy() - expect(htmlLang).toMatch(/^[a-z]{2}(-[A-Z]{2})?$/) // e.g., "en" or "en-US" + expect(htmlLang).toMatch(/^[a-z]{2}(-[A-Z]{2})?$/) }) - test('should handle focus trap in modals', async ({ page }) => { - const homePage = new HomePage(page) - await homePage.goto() - - // Look for modal triggers - const modalTriggers = page.locator('button').filter({ hasText: /sign in|sign up/i }) - const triggerCount = await modalTriggers.count() - test.skip(triggerCount === 0, 'No modal triggers found on page') - - const trigger = modalTriggers.first() - await trigger.click() - - // Check for standard ARIA dialog (our own modals) - const standardModal = page.locator('[role="dialog"], [aria-modal="true"], .modal') - const hasStandardModal = await standardModal - .first() - .waitFor({ state: 'visible', timeout: 5000 }) - .then(() => true) - .catch(() => false) - - if (hasStandardModal) { - // Tab should stay within modal - await page.keyboard.press('Tab') - await page.keyboard.press('Tab') - - const focusedElement = page.locator(':focus') - const isInModal = await focusedElement.evaluate((el, modalSelector) => { - const modal = document.querySelector(modalSelector) - return modal ? modal.contains(el) : false - }, '[role="dialog"], [aria-modal="true"], .modal') - - expect(isInModal).toBe(true) - - // Escape should close modal - await page.keyboard.press('Escape') - - await standardModal.first().waitFor({ state: 'hidden', timeout: 5000 }) - const modalStillVisible = await standardModal.first().isVisible() - expect(modalStillVisible).toBe(false) - } else { - // Clerk's auth modal (third-party, no role="dialog") — skip focus trap test - // Clerk manages its own focus trapping and accessibility - test.skip(true, 'Only third-party Clerk modal available — focus trap is managed by Clerk') - } + test('should handle focus trap in modals', async () => { + test.skip(true, 'Focus trap verification depends on Clerk third-party modal behavior') }) test('should have proper table accessibility', async ({ page }) => { const listingsPage = new ListingsPage(page) await listingsPage.goto() - // Check for tables const tables = page.locator('table') const tableCount = await tables.count() - test.skip(tableCount === 0, 'No tables found on listings page') - - const table = tables.first() + expect(tableCount).toBeGreaterThan(0) - // Should have proper headers - const headers = table.locator('th') - const headerCount = await headers.count() - expect(headerCount).toBeGreaterThan(0) - - // Headers should have scope (but it's not always required) - const firstHeader = headers.first() - const scope = await firstHeader.getAttribute('scope') - if (scope) { - expect(scope).toMatch(/^(col|row)$/) + const headerData = await tables + .first() + .locator('th') + .evaluateAll((elements) => + elements.map((el) => ({ + scope: el.getAttribute('scope'), + text: el.textContent, + })), + ) + + expect(headerData.length).toBeGreaterThan(0) + + if (headerData[0].scope) { + expect(headerData[0].scope).toMatch(/^(col|row)$/) } }) }) @@ -348,39 +251,28 @@ test.describe('Screen Reader Tests', () => { const homePage = new HomePage(page) await homePage.goto() - // Check for landmark regions - const landmarks = { - header: page.locator('header, [role="banner"]'), - nav: page.locator('nav, [role="navigation"]'), - main: page.locator('main, [role="main"]'), - footer: page.locator('footer, [role="contentinfo"]'), - } + const nav = page.locator('nav, [role="navigation"]') + const main = page.locator('main, [role="main"]') - // At minimum, we should have nav and main - const hasNav = (await landmarks.nav.count()) > 0 - const hasMain = (await landmarks.main.count()) > 0 - expect(hasNav || hasMain).toBe(true) + await expect(nav.first()).toBeVisible() + await expect(main.first()).toBeVisible() }) test('should provide context for icon buttons', async ({ page }) => { const gamesPage = new GamesPage(page) await gamesPage.goto() - // Find buttons that might only have icons - const buttons = page.locator('button') - const buttonCount = await buttons.count() - - for (let i = 0; i < Math.min(buttonCount, 10); i++) { - const button = buttons.nth(i) - const text = await button.textContent() - - // If button has no visible text (likely icon-only) - if (!text || text.trim().length === 0) { - const ariaLabel = await button.getAttribute('aria-label') - const title = await button.getAttribute('title') + const buttonData = await page.locator('button').evaluateAll((elements) => + elements.slice(0, 10).map((el) => ({ + text: el.textContent?.trim() ?? '', + ariaLabel: el.getAttribute('aria-label'), + title: el.getAttribute('title'), + })), + ) - // Should have aria-label or title - expect(ariaLabel || title).toBeTruthy() + for (const btn of buttonData) { + if (btn.text.length === 0) { + expect(btn.ariaLabel || btn.title).toBeTruthy() } } }) diff --git a/tests/admin-dashboard.spec.ts b/tests/admin-dashboard.spec.ts index eb7be2dd..6d8feca2 100644 --- a/tests/admin-dashboard.spec.ts +++ b/tests/admin-dashboard.spec.ts @@ -1,134 +1,55 @@ import { test, expect } from '@playwright/test' -test.describe('Admin Dashboard Tests - Requires Admin Role', () => { - test.use({ storageState: 'tests/.auth/admin.json' }) +test.describe('Admin Dashboard', () => { + test.use({ storageState: 'tests/.auth/super_admin.json' }) + test.beforeEach(async ({ page }) => { await page.goto('/admin', { waitUntil: 'domcontentloaded' }) await expect(page).toHaveURL(/\/admin/) - - // Wait for dashboard content to render - await page - .locator('[data-testid="admin-nav"]') - .or(page.locator('nav').filter({ hasText: /systems|games/i })) - .first() - .waitFor({ state: 'visible', timeout: 15000 }) + await page.locator('[data-testid="admin-nav"]').waitFor({ state: 'visible', timeout: 15000 }) }) - test('should display admin navigation menu with all required items', async ({ page }) => { - // Admin navigation should be visible - specifically the QuickNavigation component + test('should display admin navigation with required menu items', async ({ page }) => { const adminNav = page.locator('[data-testid="admin-nav"]') await expect(adminNav).toBeVisible() - // All admin menu items must be present (based on admin role permissions) - const requiredMenuItems = ['Games', 'Systems', 'Devices', 'Emulators'] - - for (const item of requiredMenuItems) { - // Look for links specifically within the admin nav, use first() to avoid duplicates + for (const item of ['Games', 'Systems', 'Devices', 'Emulators']) { const menuLink = adminNav .locator('a') .filter({ hasText: new RegExp(item, 'i') }) .first() - await expect(menuLink).toBeVisible({ timeout: 5000 }) + await expect(menuLink).toBeVisible() } }) - test('should display dashboard statistics with valid data', async ({ page }) => { - // PlatformStats component renders stat sections with headings and numeric values + test('should display dashboard statistics', async ({ page }) => { const mainContent = page.locator('main').first() await expect(mainContent).toBeVisible() - // Wait for dashboard data to load (stats or activity cards) - await page - .locator('text=/\\d+/') - .first() - .waitFor({ state: 'visible', timeout: 15000 }) - .catch(() => {}) - - // Dashboard must have numeric stat values rendered const statValues = mainContent.locator('text=/\\d+/') + await expect(statValues.first()).toBeVisible() + const statCount = await statValues.count() - test.skip(statCount === 0, 'Dashboard statistics have not loaded') expect(statCount).toBeGreaterThan(0) }) - test('should show recent activity feed with entries', async ({ page }) => { - // Activity cards are rendered with time range buttons (24h, 48h, 7d) + test('should show time range filter buttons', async ({ page }) => { const timeRangeButtons = page.locator('button').filter({ hasText: /24h|48h|7d/i }) - const buttonCount = await timeRangeButtons.count() - - if (buttonCount > 0) { - // Activity section is present with time range controls - await expect(timeRangeButtons.first()).toBeVisible() - - // Clicking a time range button should keep the dashboard functional - await timeRangeButtons.first().click() - await page.waitForLoadState('domcontentloaded') + await expect(timeRangeButtons.first()).toBeVisible() - // Dashboard main content must remain visible after interaction - await expect(page.locator('main').first()).toBeVisible() - } else { - // If no time range buttons, PlatformStats or QuickNavigation must be present - const quickNav = page.locator('[data-testid="admin-nav"]') - await expect(quickNav).toBeVisible() + for (const range of ['24h', '48h', '7d']) { + const rangeButton = page + .locator('button') + .filter({ hasText: new RegExp(range, 'i') }) + .first() + await expect(rangeButton).toBeVisible() } }) - test('should have functional quick actions', async ({ page }) => { - // Quick Navigation component serves as quick actions - const quickNav = page.locator('[data-testid="admin-nav"]') - await expect(quickNav).toBeVisible() - - // Check that navigation links are present in the QuickNavigation - const navLinks = quickNav.locator('a') - const linkCount = await navLinks.count() - - // Must have at least 2 navigation links for quick access - expect(linkCount).toBeGreaterThanOrEqual(2) - }) - - test('should display activity cards on dashboard', async ({ page }) => { - // Wait for dashboard content to load - await page - .locator('button') - .filter({ hasText: /24h|48h|7d/i }) - .first() - .or(page.locator('[data-testid="admin-nav"]')) - .waitFor({ state: 'visible', timeout: 15000 }) - .catch(() => {}) - - // Activity cards contain time range buttons and data sections - const timeRangeButtons = page.locator('button').filter({ hasText: /24h|48h|7d/i }) - const buttonCount = await timeRangeButtons.count() - - // Dashboard must have time range buttons (from activity cards) or QuickNavigation - const quickNav = page.locator('[data-testid="admin-nav"]') - const hasQuickNav = await quickNav.isVisible() - - expect(buttonCount > 0 || hasQuickNav).toBe(true) - }) - - test('should have time range filters on activity cards', async ({ page }) => { - // Wait for dashboard content to load - await page - .locator('button') - .filter({ hasText: /24h|48h|7d/i }) - .first() - .waitFor({ state: 'visible', timeout: 15000 }) - .catch(() => {}) - - // Activity cards have time range filter buttons: 24h, 48h, 7d + test('should switch time ranges on activity cards', async ({ page }) => { const timeRangeButtons = page.locator('button').filter({ hasText: /24h|48h|7d/i }) - const buttonCount = await timeRangeButtons.count() - - test.skip(buttonCount === 0, 'No time range buttons found on dashboard') - - // Verify all expected time ranges are present - for (const range of ['24h', '48h', '7d']) { - const rangeButton = page.locator('button').filter({ hasText: new RegExp(range, 'i') }) - await expect(rangeButton.first()).toBeVisible() - } + await expect(timeRangeButtons.first()).toBeVisible() - // Click each time range button and verify the page remains stable for (const range of ['24h', '48h', '7d']) { const rangeButton = page .locator('button') @@ -140,121 +61,61 @@ test.describe('Admin Dashboard Tests - Requires Admin Role', () => { } }) - test('should have responsive admin layout', async ({ page }) => { - // Test desktop view - await page.setViewportSize({ width: 1280, height: 800 }) - - // Wait for dashboard content to load - await page - .locator('[data-testid="admin-nav"]') - .waitFor({ state: 'visible', timeout: 15000 }) - .catch(() => {}) - - // QuickNavigation should be visible on desktop + test('should have functional quick action nav links', async ({ page }) => { const quickNav = page.locator('[data-testid="admin-nav"]') - await expect(quickNav).toBeVisible({ timeout: 5000 }) - - // Dashboard main content must be visible - await expect(page.locator('main').first()).toBeVisible() - - // Test mobile view - await page.setViewportSize({ width: 375, height: 667 }) - - // QuickNavigation should still be accessible on mobile await expect(quickNav).toBeVisible() - // Check if the QuickNavigation toggle button is present - const toggleButton = quickNav.locator('button').first() - if (await toggleButton.isVisible()) { - // Toggle the navigation - await toggleButton.click() - await page.waitForLoadState('domcontentloaded') - } - - // Navigation items should be accessible when expanded const navLinks = quickNav.locator('a') const linkCount = await navLinks.count() - - // If no links visible, try expanding the navigation first - if (linkCount === 0) { - const expandButton = quickNav.locator('button').first() - if (await expandButton.isVisible()) { - await expandButton.click() - await page.waitForLoadState('domcontentloaded') - } - // Check again after expanding - const expandedLinks = quickNav.locator('a') - expect(await expandedLinks.count()).toBeGreaterThan(0) - } else { - expect(linkCount).toBeGreaterThan(0) - } + expect(linkCount).toBeGreaterThanOrEqual(2) }) - test('should have working admin search functionality', async ({ page }) => { - // The admin dashboard doesn't have global search - // Instead, verify that navigation links work to access different admin sections + test('should navigate to games admin page via nav link', async ({ page }) => { const quickNav = page.locator('[data-testid="admin-nav"]') - await expect(quickNav).toBeVisible() - - // Find and click a navigation link (e.g., Games) const gamesLink = quickNav.locator('a').filter({ hasText: /games/i }).first() await expect(gamesLink).toBeVisible() + await gamesLink.click() + await expect(page).toHaveURL(/\/admin\/games/) + }) - // Should navigate to games admin page - await expect(page).toHaveURL(/\/admin\/games/, { timeout: 5000 }) + test('should display responsive layout at mobile viewport', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }) - // Navigate back to dashboard - await page.goto('/admin', { waitUntil: 'domcontentloaded' }) + const quickNav = page.locator('[data-testid="admin-nav"]') + await expect(quickNav).toBeVisible() + await expect(page.locator('main').first()).toBeVisible() }) - test('should have logout functionality', async ({ page }) => { - // User menu button should be present for authenticated users + test('should show user menu button', async ({ page }) => { const userMenuButton = page.getByRole('button', { name: /open user menu/i }) - await expect(userMenuButton).toBeVisible({ timeout: 5000 }) + await expect(userMenuButton).toBeVisible() }) }) -test.describe('Admin Dashboard Data Visualizations - Requires Admin Role', () => { - test.use({ storageState: 'tests/.auth/admin.json' }) +test.describe('Admin Dashboard Data Visualizations', () => { + test.use({ storageState: 'tests/.auth/super_admin.json' }) + test.beforeEach(async ({ page }) => { await page.goto('/admin', { waitUntil: 'domcontentloaded' }) await expect(page).toHaveURL(/\/admin/) - - // Wait for dashboard content to render - await page - .locator('[data-testid="admin-nav"]') - .or(page.locator('nav').filter({ hasText: /systems|games/i })) - .first() - .waitFor({ state: 'visible', timeout: 15000 }) + await page.locator('[data-testid="admin-nav"]').waitFor({ state: 'visible', timeout: 15000 }) }) - test('should display interactive data charts', async ({ page }) => { - // Wait for numeric data to appear - await page - .locator('text=/\\d+/') - .first() - .waitFor({ state: 'visible', timeout: 15000 }) - .catch(() => {}) + test('should display numeric data on dashboard', async ({ page }) => { + const numbers = page.locator('text=/\\d+/') + await expect(numbers.first()).toBeVisible() - // Dashboard should have numeric data indicators (stats, counts, etc.) - const numbersOnPage = await page.locator('text=/\\d+/').count() - expect(numbersOnPage).toBeGreaterThan(0) + const count = await numbers.count() + expect(count).toBeGreaterThan(0) }) - test('should support data refresh functionality', async ({ page }) => { - // ActivityCard refresh button uses aria-label="Refresh" - const refreshButton = page.locator('button[aria-label="Refresh"]') - const hasRefresh = await refreshButton - .first() - .isVisible({ timeout: 5000 }) - .catch(() => false) - test.skip(!hasRefresh, 'No refresh button available on dashboard') + test('should have a refresh button on activity cards', async ({ page }) => { + const refreshButton = page.locator('button[aria-label="Refresh"]').first() + await expect(refreshButton).toBeVisible() - await refreshButton.first().click() + await refreshButton.click() await page.waitForLoadState('domcontentloaded') - - // Dashboard should remain functional after refresh await expect(page.locator('main').first()).toBeVisible() }) }) diff --git a/tests/admin-permissions.spec.ts b/tests/admin-permissions.spec.ts index 7a74b8ec..e7eab6ac 100644 --- a/tests/admin-permissions.spec.ts +++ b/tests/admin-permissions.spec.ts @@ -1,126 +1,85 @@ import { test, expect } from '@playwright/test' test.describe('Admin Permissions Tests - Requires Admin Role', () => { - test.use({ storageState: 'tests/.auth/admin.json' }) + test.use({ storageState: 'tests/.auth/super_admin.json' }) test.beforeEach(async ({ page }) => { await page.goto('/admin/permissions', { waitUntil: 'domcontentloaded' }) await expect(page).toHaveURL(/\/admin\/permissions/) + + await expect(page.locator('table').first()).toBeVisible() }) test('should display permissions management page with table', async ({ page }) => { - const mainContent = page.locator('main').first() - await expect(mainContent).toBeVisible() + const table = page.locator('table').first() + await expect(table).toBeVisible() - // Wait for page content to finish loading - await page - .getByText(/loading/i) - .waitFor({ state: 'hidden', timeout: 15000 }) - .catch(() => {}) + const headers = table.locator('thead th') + const headerTexts = await headers.allTextContents() + const headerString = headerTexts.join(' ').toLowerCase() - const table = page.locator('table').first() - const hasTable = await table.isVisible({ timeout: 10000 }).catch(() => false) - - if (hasTable) { - const headers = table.locator('thead th') - const headerTexts = await headers.allTextContents() - const headerString = headerTexts.join(' ').toLowerCase() - - expect(headerString).toMatch(/label|permission|key/i) - - const rows = table.locator('tbody tr') - const rowCount = await rows.count() - if (rowCount > 0) { - const firstRow = rows.first() - await expect(firstRow).toBeVisible() - } - } else { - // Wait for meaningful content to load before checking text - await page - .waitForFunction( - (el) => el !== null && (el.textContent ?? '').trim().length > 10, - await mainContent.elementHandle(), - { timeout: 10000 }, - ) - .catch(() => {}) - const pageText = await mainContent.textContent() - expect(pageText).toMatch(/permission/i) - } + expect(headerString).toMatch(/label|permission|key/i) + + const rows = table.locator('tbody tr') + const rowCount = await rows.count() + expect(rowCount).toBeGreaterThan(0) + + await expect(rows.first()).toBeVisible() }) test('should edit role permissions', async ({ page }) => { - // EditButton uses title="Edit Permission" const editButtons = page.locator('button[title="Edit Permission"]') - const hasEdit = (await editButtons.count()) > 0 - test.skip(!hasEdit, 'No edit permission buttons available') + await expect(editButtons.first()).toBeVisible() + expect(await editButtons.count()).toBeGreaterThan(0) await editButtons.first().click() const permissionsModal = page.locator('[role="dialog"]') - const hasModal = await permissionsModal.isVisible({ timeout: 3000 }).catch(() => false) - test.skip(!hasModal, 'Permissions editor dialog did not appear') + await expect(permissionsModal).toBeVisible() - // Modal should have form fields (key, label, description, category) const formFields = permissionsModal.locator('input, textarea, select') const fieldCount = await formFields.count() expect(fieldCount).toBeGreaterThan(0) - // Close the modal const cancelButton = permissionsModal.locator('button').filter({ hasText: /cancel/i }) - if (await cancelButton.isVisible({ timeout: 1000 }).catch(() => false)) { - await cancelButton.click() - } else { - await page.keyboard.press('Escape') - } + await expect(cancelButton).toBeVisible() + await cancelButton.click() }) test('should manage individual permissions', async ({ page }) => { - // The permissions table IS the permissions list const table = page.locator('table').first() - const hasTable = await table.isVisible({ timeout: 5000 }).catch(() => false) - test.skip(!hasTable, 'No permissions table visible') + await expect(table).toBeVisible() const rows = table.locator('tbody tr') const rowCount = await rows.count() - test.skip(rowCount === 0, 'No permission rows found') + expect(rowCount).toBeGreaterThan(0) const firstRow = rows.first() await expect(firstRow).toBeVisible() - // Each row should have cells with permission data const cells = firstRow.locator('td') const cellCount = await cells.count() expect(cellCount).toBeGreaterThan(0) - // First cell should contain the permission key or label const firstCell = cells.first() const cellText = await firstCell.textContent() expect(cellText?.trim().length).toBeGreaterThan(0) }) test('should create custom permissions', async ({ page }) => { - // "Add Permission" button at top of page const addButton = page.locator('button').filter({ hasText: /add permission/i }) - const hasAdd = await addButton.isVisible({ timeout: 3000 }).catch(() => false) - test.skip(!hasAdd, 'No Add Permission button available') + await expect(addButton).toBeVisible() await addButton.click() - // Dialog opens for creating a new permission const dialog = page.locator('[role="dialog"]') - const hasDialog = await dialog.isVisible({ timeout: 3000 }).catch(() => false) - test.skip(!hasDialog, 'Permission creation dialog did not appear') + await expect(dialog).toBeVisible() - // Verify the dialog has input fields (key, label, description, category) const formFields = dialog.locator('input, textarea, select') const fieldCount = await formFields.count() expect(fieldCount).toBeGreaterThan(0) - // Close the dialog without creating const cancelButton = dialog.locator('button').filter({ hasText: /cancel/i }) - if (await cancelButton.isVisible({ timeout: 1000 }).catch(() => false)) { - await cancelButton.click() - } else { - await page.keyboard.press('Escape') - } + await expect(cancelButton).toBeVisible() + await cancelButton.click() }) }) diff --git a/tests/admin-reports.spec.ts b/tests/admin-reports.spec.ts index 184c27ba..bcf75ba6 100644 --- a/tests/admin-reports.spec.ts +++ b/tests/admin-reports.spec.ts @@ -1,182 +1,111 @@ import { test, expect } from '@playwright/test' test.describe('Admin Reports Management Tests - Requires Admin Role', () => { - test.use({ storageState: 'tests/.auth/admin.json' }) + test.use({ storageState: 'tests/.auth/super_admin.json' }) test.beforeEach(async ({ page }) => { await page.goto('/admin/reports', { waitUntil: 'domcontentloaded' }) await expect(page).toHaveURL(/\/admin\/reports/) + + await expect(page.locator('table').first()).toBeVisible() }) test('should display reports dashboard with required elements', async ({ page }) => { - const mainContent = page.locator('main').first() - await expect(mainContent).toBeVisible() + const reportsTable = page.locator('table').first() + await expect(reportsTable).toBeVisible() - // Wait for page content to finish loading - await page - .getByText(/loading/i) - .waitFor({ state: 'hidden', timeout: 15000 }) - .catch(() => {}) + const headers = reportsTable.locator('thead th') + const headerTexts = await headers.allTextContents() + const headerString = headerTexts.join(' ').toLowerCase() - const reportsTable = page.locator('table').first() - const hasTable = await reportsTable.isVisible({ timeout: 10000 }).catch(() => false) - - if (hasTable) { - const headers = reportsTable.locator('thead th') - const headerTexts = await headers.allTextContents() - const headerString = headerTexts.join(' ').toLowerCase() - - expect(headerString).toMatch(/reason/i) - expect(headerString).toMatch(/status/i) - - const reportRows = reportsTable.locator('tbody tr') - const rowCount = await reportRows.count() - expect(typeof rowCount).toBe('number') - } else { - // Wait for meaningful content to load before checking text - await page - .waitForFunction( - (el) => el !== null && (el.textContent ?? '').trim().length > 10, - await mainContent.elementHandle(), - { timeout: 10000 }, - ) - .catch(() => {}) - const pageText = await mainContent.textContent() - expect(pageText).toMatch(/report/i) - } + expect(headerString).toMatch(/reason/i) + expect(headerString).toMatch(/status/i) + + const reportRows = reportsTable.locator('tbody tr') + const rowCount = await reportRows.count() + expect(rowCount).toBeGreaterThanOrEqual(0) }) test('should filter reports by status', async ({ page }) => { - // Reports page uses a setCustomAdjustment(e.target.value)} disabled={isAdjusting} @@ -698,6 +702,7 @@ function UserDetailsModal(props: Props) {
setAdjustmentReason(e.target.value)} disabled={isAdjusting} diff --git a/src/app/admin/users/components/UserRoleModal.tsx b/src/app/admin/users/components/UserRoleModal.tsx index df1949d4..1a292884 100644 --- a/src/app/admin/users/components/UserRoleModal.tsx +++ b/src/app/admin/users/components/UserRoleModal.tsx @@ -64,7 +64,12 @@ function UserRoleModal(props: Props) { return (
-
+

Edit User Role diff --git a/src/app/listings/ListingsPage.tsx b/src/app/listings/ListingsPage.tsx index 2bdafb26..fd6147aa 100644 --- a/src/app/listings/ListingsPage.tsx +++ b/src/app/listings/ListingsPage.tsx @@ -12,7 +12,7 @@ import { } from '@/app/listings/shared/components' import CommunitySupportBanner from '@/components/banners/CommunitySupportBanner' import { EmulatorIcon, SystemIcon } from '@/components/icons' -import { Badge } from '@/components/ui/Badge' +import { BannedUserBadge } from '@/components/ui/BannedUserBadge' import { Button } from '@/components/ui/Button' import { ColumnVisibilityControl } from '@/components/ui/ColumnVisibilityControl' import { DisplayToggleButton } from '@/components/ui/DisplayToggleButton' @@ -516,20 +516,12 @@ function ListingsPage() { )} - {isModerator && - listing.author && - 'userBans' in listing.author && - Array.isArray(listing.author.userBans) && - listing.author.userBans.length > 0 && ( - - - - BANNED - - - This user has been banned - - )} + {listing.developerVerifications && listing.developerVerifications.length > 0 && ( diff --git a/src/app/listings/[id]/components/ListingDetailsClient.tsx b/src/app/listings/[id]/components/ListingDetailsClient.tsx index 5932e947..63a0c0fb 100644 --- a/src/app/listings/[id]/components/ListingDetailsClient.tsx +++ b/src/app/listings/[id]/components/ListingDetailsClient.tsx @@ -11,16 +11,16 @@ import { AuthorPanel } from '@/app/listings/components/shared/details/AuthorPane import BookmarkButton from '@/app/listings/components/shared/details/BookmarkButton' import { DetailsHeader } from '@/app/listings/components/shared/details/DetailsHeader' import { DetailsHeaderBadges } from '@/app/listings/components/shared/details/DetailsHeaderBadges' +import { ModeratorInfoPanel } from '@/app/listings/components/shared/details/ModeratorInfoPanel' +import { logHandheldVoteError } from '@/app/listings/components/shared/details/utils/logVoteError' +import { refreshHandheldListingDetail } from '@/app/listings/components/shared/details/utils/refreshListingDetail' import { VotingSection } from '@/app/listings/components/shared/details/VotingSection' import { NotesSection } from '@/app/listings/components/shared/NotesSection' import { GameImage } from '@/app/listings/shared/components' import CommunitySupportBanner from '@/components/banners/CommunitySupportBanner' -import { Card, Button, Badge } from '@/components/ui' +import { BannedUserBadge, Button, Card } from '@/components/ui' import { api } from '@/lib/api' -import { logger } from '@/lib/logger' -import toast from '@/lib/toast' import { type RouterOutput } from '@/types/trpc' -import getErrorMessage from '@/utils/getErrorMessage' import { roleIncludesRole } from '@/utils/permission-system' import { Role } from '@orm' import CommentThread from './CommentThread' @@ -46,10 +46,7 @@ function ListingDetailsClient(props: Props) { const currentUserQuery = api.users.me.useQuery() const canViewBannedUsers = roleIncludesRole(currentUserQuery.data?.role, Role.MODERATOR) - const refreshData = async () => { - await utils.listings.byId.invalidate({ id: props.listing.id }) - await utils.listings.byId.refetch({ id: props.listing.id }) - } + const refreshData = () => refreshHandheldListingDetail({ utils, listingId: props.listing.id }) const scrollToVoteSection = () => { voteSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }) @@ -57,10 +54,7 @@ function ListingDetailsClient(props: Props) { const voteMutation = api.listings.vote.useMutation({ onSuccess: refreshData, - onError: (error) => { - logger.error('[ListingDetailsClient] handleVoting:', error) - toast.error(`Failed to vote: ${getErrorMessage(error)}`) - }, + onError: (error) => logHandheldVoteError({ error, listingId: props.listing.id }), }) const handleVote = async (value: boolean | null) => { @@ -142,15 +136,11 @@ function ListingDetailsClient(props: Props) { authorId={props.listing?.author?.id} postedAt={props.listing.createdAt} bannedBadge={ - canViewBannedUsers && - props.listing?.author && - 'userBans' in props.listing.author && - Array.isArray(props.listing.author.userBans) && - props.listing.author.userBans.length > 0 ? ( - - BANNED USER - - ) : undefined + } /> @@ -205,6 +195,10 @@ function ListingDetailsClient(props: Props) { /> + {canViewBannedUsers && ( + + )} +
+ {props.name ?? 'Unknown User'} + + + ) +} diff --git a/src/app/listings/components/shared/details/ApprovalSection.tsx b/src/app/listings/components/shared/details/ApprovalSection.tsx new file mode 100644 index 00000000..35d6b67a --- /dev/null +++ b/src/app/listings/components/shared/details/ApprovalSection.tsx @@ -0,0 +1,43 @@ +import { ApprovalStatusBadge, LocalizedDate } from '@/components/ui' +import { type RouterOutput } from '@/types/trpc' +import { ApprovalStatus } from '@orm' +import { AdminUserLink } from './AdminUserLink' + +type ModeratorInfo = NonNullable + +interface Props { + approval: ModeratorInfo['approval'] +} + +export function ApprovalSection(props: Props) { + return ( +
+

Approval

+
+ + {props.approval.processedBy && ( + + by{' '} + + + )} + {props.approval.processedAt && ( + + + + )} +
+ {props.approval.processedNotes && ( +

+ {props.approval.processedNotes} +

+ )} + {props.approval.status === ApprovalStatus.PENDING && ( +

Not yet reviewed

+ )} +
+ ) +} diff --git a/src/app/listings/components/shared/details/ModeratorInfoPanel.tsx b/src/app/listings/components/shared/details/ModeratorInfoPanel.tsx new file mode 100644 index 00000000..e6c0dc03 --- /dev/null +++ b/src/app/listings/components/shared/details/ModeratorInfoPanel.tsx @@ -0,0 +1,66 @@ +'use client' + +import { ChevronDown, ChevronRight } from 'lucide-react' +import { useState } from 'react' +import { LoadingSpinner } from '@/components/ui' +import { api } from '@/lib/api' +import { type ListingType } from '@/schemas/common' +import { ApprovalSection } from './ApprovalSection' +import { VotesSection } from './VotesSection' + +interface Props { + listingId: string + listingType: ListingType +} + +export function ModeratorInfoPanel(props: Props) { + const [isExpanded, setIsExpanded] = useState(false) + + const moderatorInfoQuery = api.listings.moderatorInfo.useQuery( + { id: props.listingId, type: props.listingType }, + { enabled: isExpanded, refetchOnWindowFocus: false }, + ) + + const ChevronIcon = isExpanded ? ChevronDown : ChevronRight + + return ( +
+ + + {isExpanded && ( +
+ {moderatorInfoQuery.isLoading && ( +
+ +
+ )} + + {moderatorInfoQuery.data && ( + <> +
+ +
+ + + )} + + {moderatorInfoQuery.isError && ( +

+ Failed to load moderator info. +

+ )} +
+ )} +
+ ) +} diff --git a/src/app/listings/components/shared/details/VoteDirectionIcon.test.tsx b/src/app/listings/components/shared/details/VoteDirectionIcon.test.tsx new file mode 100644 index 00000000..52ade6ac --- /dev/null +++ b/src/app/listings/components/shared/details/VoteDirectionIcon.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { VoteDirectionIcon } from './VoteDirectionIcon' + +describe('VoteDirectionIcon', () => { + it('renders upvote icon with aria-label "Upvote" when value is true', () => { + render() + const icon = screen.getByLabelText('Upvote') + expect(icon).toBeInTheDocument() + expect(icon).toHaveAttribute('role', 'img') + }) + + it('renders downvote icon with aria-label "Downvote" when value is false', () => { + render() + const icon = screen.getByLabelText('Downvote') + expect(icon).toBeInTheDocument() + expect(icon).toHaveAttribute('role', 'img') + }) + + it('applies green color class for upvote', () => { + render() + const icon = screen.getByLabelText('Upvote') + expect(icon).toHaveClass('text-green-600') + }) + + it('applies red color class for downvote', () => { + render() + const icon = screen.getByLabelText('Downvote') + expect(icon).toHaveClass('text-red-600') + }) +}) diff --git a/src/app/listings/components/shared/details/VoteDirectionIcon.tsx b/src/app/listings/components/shared/details/VoteDirectionIcon.tsx new file mode 100644 index 00000000..4fe1146a --- /dev/null +++ b/src/app/listings/components/shared/details/VoteDirectionIcon.tsx @@ -0,0 +1,14 @@ +import { ArrowDown, ArrowUp } from 'lucide-react' + +interface Props { + value: boolean +} + +export function VoteDirectionIcon(props: Props) { + const label = props.value ? 'Upvote' : 'Downvote' + return props.value ? ( + + ) : ( + + ) +} diff --git a/src/app/listings/components/shared/details/VoteRow.test.tsx b/src/app/listings/components/shared/details/VoteRow.test.tsx new file mode 100644 index 00000000..996de715 --- /dev/null +++ b/src/app/listings/components/shared/details/VoteRow.test.tsx @@ -0,0 +1,86 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { VoteRow } from './VoteRow' + +function makeVote( + overrides: Partial<{ + id: string + value: boolean + nullifiedAt: Date | null + userName: string | null + }> = {}, +) { + return { + id: overrides.id ?? 'vote-1', + value: overrides.value ?? true, + nullifiedAt: overrides.nullifiedAt ?? null, + createdAt: new Date('2026-01-01T00:00:00Z'), + user: { + id: 'user-1', + name: 'userName' in overrides ? (overrides.userName ?? null) : 'Alice', + trustScore: 50, + }, + } +} + +function renderRow(vote: ReturnType) { + return render( + + + + +
, + ) +} + +describe('VoteRow', () => { + it('renders an active (non-nullified) upvote row without strike-through or Nullified badge', () => { + const vote = makeVote({ value: true, nullifiedAt: null }) + renderRow(vote) + + expect(screen.queryByText('Nullified')).not.toBeInTheDocument() + + const row = screen.getByRole('row') + expect(row.className).not.toContain('line-through') + expect(row.className).not.toContain('opacity-60') + }) + + it('renders a nullified vote row with reduced opacity and a Nullified badge, but no line-through', () => { + const vote = makeVote({ value: true, nullifiedAt: new Date('2026-01-02T00:00:00Z') }) + renderRow(vote) + + expect(screen.getByText('Nullified')).toBeInTheDocument() + + const row = screen.getByRole('row') + expect(row.className).toContain('opacity-60') + expect(row.innerHTML).not.toContain('line-through') + }) + + it('links to the voter user in admin', () => { + const vote = makeVote({ userName: 'Alice' }) + renderRow(vote) + + expect(screen.getByText('Alice')).toBeInTheDocument() + }) + + it('falls back to "Unknown User" when voter name is null', () => { + const vote = makeVote({ userName: null }) + renderRow(vote) + + expect(screen.getByText('Unknown User')).toBeInTheDocument() + }) + + it('renders upvote icon with Upvote aria-label for value=true', () => { + const vote = makeVote({ value: true }) + renderRow(vote) + + expect(screen.getByLabelText('Upvote')).toBeInTheDocument() + }) + + it('renders downvote icon with Downvote aria-label for value=false', () => { + const vote = makeVote({ value: false }) + renderRow(vote) + + expect(screen.getByLabelText('Downvote')).toBeInTheDocument() + }) +}) diff --git a/src/app/listings/components/shared/details/VoteRow.tsx b/src/app/listings/components/shared/details/VoteRow.tsx new file mode 100644 index 00000000..7621b7a6 --- /dev/null +++ b/src/app/listings/components/shared/details/VoteRow.tsx @@ -0,0 +1,42 @@ +import { Badge, LocalizedDate, TrustLevelBadge } from '@/components/ui' +import { cn } from '@/lib/utils' +import { type RouterOutput } from '@/types/trpc' +import { AdminUserLink } from './AdminUserLink' +import { VoteDirectionIcon } from './VoteDirectionIcon' + +type ModeratorInfo = NonNullable +export type VoteEntry = ModeratorInfo['votes'][number] + +interface Props { + vote: VoteEntry +} + +export function VoteRow(props: Props) { + const isNullified = props.vote.nullifiedAt !== null + + return ( + + + + + + + + + + + + + + + {isNullified && ( + + Nullified + + )} + + + ) +} diff --git a/src/app/listings/components/shared/details/VotesSection.tsx b/src/app/listings/components/shared/details/VotesSection.tsx new file mode 100644 index 00000000..2c275406 --- /dev/null +++ b/src/app/listings/components/shared/details/VotesSection.tsx @@ -0,0 +1,43 @@ +import { type RouterOutput } from '@/types/trpc' +import { VoteRow } from './VoteRow' + +type ModeratorInfo = NonNullable + +interface Props { + votes: ModeratorInfo['votes'] + voteCounts: ModeratorInfo['voteCounts'] +} + +export function VotesSection(props: Props) { + const { up, down, nullified } = props.voteCounts + + return ( +
+

+ Votes ({up} up, {down} down{nullified > 0 && `, ${nullified} nullified`}) +

+ {props.votes.length === 0 ? ( +

No votes yet

+ ) : ( +
+ + + + + + + + + + {props.votes.map((vote) => ( + + ))} + +
+ UserTrustWhen +
+
+ )} +
+ ) +} diff --git a/src/app/listings/components/shared/details/utils/logVoteError.test.ts b/src/app/listings/components/shared/details/utils/logVoteError.test.ts new file mode 100644 index 00000000..8722b154 --- /dev/null +++ b/src/app/listings/components/shared/details/utils/logVoteError.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { logger } from '@/lib/logger' +import toast from '@/lib/toast' +import { logHandheldVoteError, logPcVoteError } from './logVoteError' + +vi.mock('@/lib/logger', () => ({ + logger: { + error: vi.fn(), + }, +})) + +vi.mock('@/lib/toast', () => ({ + default: { + error: vi.fn(), + }, +})) + +vi.mock('@/utils/getErrorMessage', () => ({ + default: (error: unknown) => (error instanceof Error ? error.message : String(error)), +})) + +afterEach(() => vi.clearAllMocks()) + +describe('logHandheldVoteError', () => { + const LISTING_ID = '00000000-0000-4000-a000-000000000010' + + it('passes listingId as extra context to logger.error', () => { + const error = new Error('Network down') + + logHandheldVoteError({ error, listingId: LISTING_ID }) + + expect(logger.error).toHaveBeenCalledTimes(1) + expect(logger.error).toHaveBeenCalledWith('[ListingDetailsClient] handleVoting:', error, { + listingId: LISTING_ID, + }) + }) + + it('calls toast.error with a user-facing message derived from the error', () => { + logHandheldVoteError({ error: new Error('Forbidden'), listingId: LISTING_ID }) + + expect(toast.error).toHaveBeenCalledTimes(1) + expect(toast.error).toHaveBeenCalledWith('Failed to vote: Forbidden') + }) + + it('handles non-Error thrown values without crashing', () => { + logHandheldVoteError({ error: 'bare string', listingId: LISTING_ID }) + + expect(logger.error).toHaveBeenCalledWith( + '[ListingDetailsClient] handleVoting:', + 'bare string', + { listingId: LISTING_ID }, + ) + expect(toast.error).toHaveBeenCalledWith('Failed to vote: bare string') + }) +}) + +describe('logPcVoteError', () => { + const PC_LISTING_ID = '00000000-0000-4000-a000-000000000011' + + it('passes pcListingId as extra context to logger.error', () => { + const error = new Error('CAPTCHA failed') + + logPcVoteError({ error, pcListingId: PC_LISTING_ID }) + + expect(logger.error).toHaveBeenCalledTimes(1) + expect(logger.error).toHaveBeenCalledWith('[PcListingDetailsClient] handleVoting:', error, { + pcListingId: PC_LISTING_ID, + }) + }) + + it('uses the PC-specific prefix (not the handheld one)', () => { + logPcVoteError({ error: new Error('err'), pcListingId: PC_LISTING_ID }) + + const firstArg = vi.mocked(logger.error).mock.calls[0]?.[0] + expect(firstArg).toBe('[PcListingDetailsClient] handleVoting:') + }) +}) diff --git a/src/app/listings/components/shared/details/utils/logVoteError.ts b/src/app/listings/components/shared/details/utils/logVoteError.ts new file mode 100644 index 00000000..a4533d9a --- /dev/null +++ b/src/app/listings/components/shared/details/utils/logVoteError.ts @@ -0,0 +1,27 @@ +import { logger } from '@/lib/logger' +import toast from '@/lib/toast' +import getErrorMessage from '@/utils/getErrorMessage' + +interface HandheldParams { + error: unknown + listingId: string +} + +export function logHandheldVoteError(params: HandheldParams): void { + logger.error('[ListingDetailsClient] handleVoting:', params.error, { + listingId: params.listingId, + }) + toast.error(`Failed to vote: ${getErrorMessage(params.error)}`) +} + +interface PcParams { + error: unknown + pcListingId: string +} + +export function logPcVoteError(params: PcParams): void { + logger.error('[PcListingDetailsClient] handleVoting:', params.error, { + pcListingId: params.pcListingId, + }) + toast.error(`Failed to vote: ${getErrorMessage(params.error)}`) +} diff --git a/src/app/listings/components/shared/details/utils/refreshListingDetail.test.ts b/src/app/listings/components/shared/details/utils/refreshListingDetail.test.ts new file mode 100644 index 00000000..20030893 --- /dev/null +++ b/src/app/listings/components/shared/details/utils/refreshListingDetail.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import { refreshHandheldListingDetail, refreshPcListingDetail } from './refreshListingDetail' + +function createMockUtils() { + return { + listings: { + byId: { + invalidate: vi.fn().mockResolvedValue(undefined), + refetch: vi.fn().mockResolvedValue(undefined), + }, + moderatorInfo: { + invalidate: vi.fn().mockResolvedValue(undefined), + }, + }, + pcListings: { + byId: { + invalidate: vi.fn().mockResolvedValue(undefined), + }, + }, + } +} + +describe('refreshHandheldListingDetail', () => { + const LISTING_ID = '00000000-0000-4000-a000-000000000010' + + it('invalidates listings.byId and listings.moderatorInfo with type=handheld', async () => { + const utils = createMockUtils() + + await refreshHandheldListingDetail({ utils, listingId: LISTING_ID }) + + expect(utils.listings.byId.invalidate).toHaveBeenCalledWith({ id: LISTING_ID }) + expect(utils.listings.moderatorInfo.invalidate).toHaveBeenCalledWith({ + id: LISTING_ID, + type: 'handheld', + }) + }) + + it('refetches listings.byId after invalidation completes', async () => { + const utils = createMockUtils() + const refetchOrder: string[] = [] + vi.mocked(utils.listings.byId.invalidate).mockImplementation(async () => { + refetchOrder.push('invalidate-byId') + }) + vi.mocked(utils.listings.moderatorInfo.invalidate).mockImplementation(async () => { + refetchOrder.push('invalidate-moderatorInfo') + }) + vi.mocked(utils.listings.byId.refetch).mockImplementation(async () => { + refetchOrder.push('refetch-byId') + }) + + await refreshHandheldListingDetail({ utils, listingId: LISTING_ID }) + + expect(refetchOrder.indexOf('refetch-byId')).toBeGreaterThan( + refetchOrder.indexOf('invalidate-byId'), + ) + expect(refetchOrder.indexOf('refetch-byId')).toBeGreaterThan( + refetchOrder.indexOf('invalidate-moderatorInfo'), + ) + }) + + it('does NOT touch the pcListings utils', async () => { + const utils = createMockUtils() + + await refreshHandheldListingDetail({ utils, listingId: LISTING_ID }) + + expect(utils.pcListings.byId.invalidate).not.toHaveBeenCalled() + }) +}) + +describe('refreshPcListingDetail', () => { + const PC_LISTING_ID = '00000000-0000-4000-a000-000000000011' + + it('invalidates pcListings.byId and listings.moderatorInfo with type=pc', async () => { + const utils = createMockUtils() + + await refreshPcListingDetail({ utils, pcListingId: PC_LISTING_ID }) + + expect(utils.pcListings.byId.invalidate).toHaveBeenCalledWith({ id: PC_LISTING_ID }) + expect(utils.listings.moderatorInfo.invalidate).toHaveBeenCalledWith({ + id: PC_LISTING_ID, + type: 'pc', + }) + }) + + it('does NOT touch the handheld listings.byId utils', async () => { + const utils = createMockUtils() + + await refreshPcListingDetail({ utils, pcListingId: PC_LISTING_ID }) + + expect(utils.listings.byId.invalidate).not.toHaveBeenCalled() + expect(utils.listings.byId.refetch).not.toHaveBeenCalled() + }) + + it('does NOT call refetch', async () => { + const utils = createMockUtils() + + await refreshPcListingDetail({ utils, pcListingId: PC_LISTING_ID }) + + expect(utils.listings.byId.refetch).not.toHaveBeenCalled() + }) +}) diff --git a/src/app/listings/components/shared/details/utils/refreshListingDetail.ts b/src/app/listings/components/shared/details/utils/refreshListingDetail.ts new file mode 100644 index 00000000..4503ed46 --- /dev/null +++ b/src/app/listings/components/shared/details/utils/refreshListingDetail.ts @@ -0,0 +1,40 @@ +import { type api } from '@/lib/api' + +type Utils = ReturnType + +interface RefreshHandheldParams { + utils: { + listings: { + byId: Pick + moderatorInfo: Pick + } + } + listingId: string +} + +export async function refreshHandheldListingDetail(params: RefreshHandheldParams): Promise { + await Promise.all([ + params.utils.listings.byId.invalidate({ id: params.listingId }), + params.utils.listings.moderatorInfo.invalidate({ id: params.listingId, type: 'handheld' }), + ]) + await params.utils.listings.byId.refetch({ id: params.listingId }) +} + +interface RefreshPcParams { + utils: { + pcListings: { + byId: Pick + } + listings: { + moderatorInfo: Pick + } + } + pcListingId: string +} + +export async function refreshPcListingDetail(params: RefreshPcParams): Promise { + await Promise.all([ + params.utils.pcListings.byId.invalidate({ id: params.pcListingId }), + params.utils.listings.moderatorInfo.invalidate({ id: params.pcListingId, type: 'pc' }), + ]) +} diff --git a/src/app/pc-listings/PcListingsPage.tsx b/src/app/pc-listings/PcListingsPage.tsx index f6e51349..f1e47f63 100644 --- a/src/app/pc-listings/PcListingsPage.tsx +++ b/src/app/pc-listings/PcListingsPage.tsx @@ -13,23 +13,23 @@ import { import CommunitySupportBanner from '@/components/banners/CommunitySupportBanner' import { EmulatorIcon, SystemIcon } from '@/components/icons' import { - PerformanceBadge, - PageSizeSelector, - Pagination, - LoadingSpinner, - LocalizedDate, - SortableHeader, + BannedUserBadge, Button, ColumnVisibilityControl, + DisplayToggleButton, + EditButton, + LoadingSpinner, + LocalizedDate, MobileColumnVisibilityControl, + PageSizeSelector, + Pagination, + PerformanceBadge, + SortableHeader, + SuccessRateBar, Tooltip, - TooltipTrigger, TooltipContent, - EditButton, + TooltipTrigger, ViewButton, - Badge, - DisplayToggleButton, - SuccessRateBar, } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { @@ -485,20 +485,12 @@ function PcListingsPage() { )} - {isModerator && - listing.author && - 'userBans' in listing.author && - Array.isArray(listing.author.userBans) && - listing.author.userBans.length > 0 && ( - - - - BANNED - - - This user has been banned - - )} +
)} diff --git a/src/app/pc-listings/[id]/components/PcListingDetailsClient.tsx b/src/app/pc-listings/[id]/components/PcListingDetailsClient.tsx index 2c5ffd24..9d58104b 100644 --- a/src/app/pc-listings/[id]/components/PcListingDetailsClient.tsx +++ b/src/app/pc-listings/[id]/components/PcListingDetailsClient.tsx @@ -13,17 +13,17 @@ import BookmarkButton from '@/app/listings/components/shared/details/BookmarkBut import { DetailFieldRow } from '@/app/listings/components/shared/details/DetailFieldRow' import { DetailsHeader } from '@/app/listings/components/shared/details/DetailsHeader' import { DetailsHeaderBadges } from '@/app/listings/components/shared/details/DetailsHeaderBadges' +import { ModeratorInfoPanel } from '@/app/listings/components/shared/details/ModeratorInfoPanel' +import { logPcVoteError } from '@/app/listings/components/shared/details/utils/logVoteError' +import { refreshPcListingDetail } from '@/app/listings/components/shared/details/utils/refreshListingDetail' import { VotingSection } from '@/app/listings/components/shared/details/VotingSection' import { NotesSection } from '@/app/listings/components/shared/NotesSection' import { GameImage } from '@/app/listings/shared/components' import CommunitySupportBanner from '@/components/banners/CommunitySupportBanner' -import { Card, Button, Badge } from '@/components/ui' +import { BannedUserBadge, Button, Card } from '@/components/ui' import { PC_OS_LABELS } from '@/data/pc-os' import { api } from '@/lib/api' -import { logger } from '@/lib/logger' -import toast from '@/lib/toast' import { type RouterOutput } from '@/types/trpc' -import getErrorMessage from '@/utils/getErrorMessage' import { roleIncludesRole } from '@/utils/permission-system' import { type PcOs, Role } from '@orm' import EditPcListingButton from './EditPcListingButton' @@ -89,9 +89,7 @@ function PcListingDetailsClient(props: Props) { }, ] as const - const refreshData = async () => { - await utils.pcListings.byId.invalidate({ id: props.pcListing.id }) - } + const refreshData = () => refreshPcListingDetail({ utils, pcListingId: props.pcListing.id }) const scrollToVoteSection = () => { voteSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }) @@ -99,10 +97,7 @@ function PcListingDetailsClient(props: Props) { const voteMutation = api.pcListings.vote.useMutation({ onSuccess: refreshData, - onError: (error) => { - logger.error('[PcListingDetailsClient] handleVoting:', error) - toast.error(`Failed to vote: ${getErrorMessage(error)}`) - }, + onError: (error) => logPcVoteError({ error, pcListingId: props.pcListing.id }), }) const handleVote = async (value: boolean | null) => { @@ -205,15 +200,11 @@ function PcListingDetailsClient(props: Props) { authorId={props.pcListing.author?.id} postedAt={props.pcListing.createdAt} bannedBadge={ - canViewBannedUsers && - props.pcListing?.author && - 'userBans' in props.pcListing.author && - Array.isArray(props.pcListing.author.userBans) && - props.pcListing.author.userBans.length > 0 ? ( - - BANNED USER - - ) : undefined + } /> @@ -269,6 +260,10 @@ function PcListingDetailsClient(props: Props) { /> + {canViewBannedUsers && ( + + )} + {/* Comments Section */}
{formatUserRole(userQuery.data.role)} - {canViewBannedUsers && - 'userBans' in userQuery.data && - Array.isArray(userQuery.data.userBans) && - userQuery.data.userBans.length > 0 && ( - - BANNED USER - - )} +
diff --git a/src/components/listings/AuthorDisplay.tsx b/src/components/listings/AuthorDisplay.tsx index 95c96b0f..663415ee 100644 --- a/src/components/listings/AuthorDisplay.tsx +++ b/src/components/listings/AuthorDisplay.tsx @@ -1,5 +1,5 @@ import Link from 'next/link' -import { Badge } from '@/components/ui' +import { BannedUserBadge } from '@/components/ui' interface Author { id?: string | null @@ -25,15 +25,7 @@ export function AuthorDisplay(props: Props) { ) : ( {props.author?.name ?? 'Anonymous'} )} - {props.canSeeBannedUsers && - props.author && - 'userBans' in props.author && - Array.isArray(props.author.userBans) && - props.author.userBans.length > 0 && ( - - BANNED - - )} +
) } diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx index ed7d03a0..d3c0223b 100644 --- a/src/components/ui/Badge.tsx +++ b/src/components/ui/Badge.tsx @@ -3,7 +3,7 @@ import { type PropsWithChildren } from 'react' import { cn } from '@/lib/utils' -type BadgeSize = 'sm' | 'md' | 'lg' +export type BadgeSize = 'sm' | 'md' | 'lg' export type BadgeVariant = 'default' | 'success' | 'warning' | 'danger' | 'info' | 'primary' const variantClasses: Record = { diff --git a/src/components/ui/BannedUserBadge.test.tsx b/src/components/ui/BannedUserBadge.test.tsx new file mode 100644 index 00000000..0d3a8c2d --- /dev/null +++ b/src/components/ui/BannedUserBadge.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { BannedUserBadge } from './BannedUserBadge' + +describe('BannedUserBadge', () => { + const bannedAuthor = { userBans: [{ id: 'ban-1' }] } + const cleanAuthor = { userBans: [] } + + it('renders nothing when canView is false (even if author is banned)', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('renders nothing when author has no active bans', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('renders nothing for null / undefined authors', () => { + const { container: nullContainer } = render() + expect(nullContainer).toBeEmptyDOMElement() + + const { container: undefContainer } = render( + , + ) + expect(undefContainer).toBeEmptyDOMElement() + }) + + it('renders the default BANNED USER label when author is banned and viewer can see', () => { + render() + expect(screen.getByText('BANNED USER')).toBeInTheDocument() + }) + + it('renders a custom label when provided (AuthorDisplay compact variant)', () => { + render() + expect(screen.getByText('BANNED')).toBeInTheDocument() + expect(screen.queryByText('BANNED USER')).not.toBeInTheDocument() + }) + + it('forwards className through to the underlying Badge', () => { + render() + const badge = screen.getByText('BANNED USER') + expect(badge.className).toContain('mt-1') + expect(badge.className).toContain('custom-class') + }) + + it('respects the size prop (profile-header md variant)', () => { + render() + const badge = screen.getByText('BANNED USER') + // size="md" → px-2 py-1 per Badge's sizeClasses map + expect(badge.className).toContain('px-2') + expect(badge.className).toContain('py-1') + }) +}) diff --git a/src/components/ui/BannedUserBadge.tsx b/src/components/ui/BannedUserBadge.tsx new file mode 100644 index 00000000..ace6426f --- /dev/null +++ b/src/components/ui/BannedUserBadge.tsx @@ -0,0 +1,34 @@ +'use client' + +import { Badge, type BadgeSize } from '@/components/ui/Badge' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/Tooltip' +import { hasActiveBans } from '@/utils/user-bans' + +interface Props { + author: unknown + canView: boolean + label?: string + size?: BadgeSize + className?: string + tooltip?: string +} + +export function BannedUserBadge(props: Props) { + if (!props.canView) return null + if (!hasActiveBans(props.author)) return null + + const badge = ( + + {props.label ?? 'BANNED USER'} + + ) + + if (!props.tooltip) return badge + + return ( + + {badge} + {props.tooltip} + + ) +} diff --git a/src/components/ui/TrustLevelBadge.test.tsx b/src/components/ui/TrustLevelBadge.test.tsx new file mode 100644 index 00000000..b4e2f96c --- /dev/null +++ b/src/components/ui/TrustLevelBadge.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { TrustLevelBadge } from './TrustLevelBadge' + +describe('TrustLevelBadge', () => { + it('shows Newcomer level for score 0', () => { + render() + expect(screen.getByText('Newcomer')).toBeInTheDocument() + }) + + it('shows Newcomer level for negative score (no crash)', () => { + render() + expect(screen.getByText('Newcomer')).toBeInTheDocument() + }) + + it('shows Contributor level at 100', () => { + render() + expect(screen.getByText('Contributor')).toBeInTheDocument() + }) + + it('shows Trusted level at 250', () => { + render() + expect(screen.getByText('Trusted')).toBeInTheDocument() + }) + + describe('progress display', () => { + it('shows 0% for negative scores (clamped, no "-5%")', () => { + render() + expect(screen.getByText('0%')).toBeInTheDocument() + expect(screen.queryByText(/-\d+%/)).not.toBeInTheDocument() + }) + + it('shows 0% at the bottom of a level', () => { + render() + expect(screen.getByText('0%')).toBeInTheDocument() + }) + + it('shows 50% at the midpoint between levels', () => { + // Newcomer (0) → Contributor (100), midpoint is 50 + render() + expect(screen.getByText('50%')).toBeInTheDocument() + }) + + it('does not render progress at the highest level (Core)', () => { + render() + expect(screen.getByText('Core')).toBeInTheDocument() + expect(screen.queryByText(/Progress to/)).not.toBeInTheDocument() + }) + + it('shows points needed (can be larger than typical when score is negative)', () => { + render() + // Next level Contributor at 100, so 100 - (-10) = 110 points needed + expect(screen.getByText('110 points needed')).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/ui/TrustLevelBadge.tsx b/src/components/ui/TrustLevelBadge.tsx index 1d7ed805..4c831c95 100644 --- a/src/components/ui/TrustLevelBadge.tsx +++ b/src/components/ui/TrustLevelBadge.tsx @@ -75,7 +75,7 @@ export function TrustLevelBadge(props: Props): JSX.Element {
Progress to {nextLevel.name} - {Math.round(progress * 100)}% + {Math.round(Math.min(Math.max(progress, 0), 1) * 100)}%
handleVote(true)} disabled={!isAuthenticated || props.isLoading} + aria-pressed={optimisticVote === true} className={`flex flex-col items-center p-3 rounded-lg transition-colors border-2 ${ optimisticVote === true ? 'bg-green-50 border-green-200 text-green-700 dark:bg-green-900/20 dark:border-green-700 dark:text-green-400' @@ -173,6 +174,7 @@ export function VoteButtons(props: VoteButtonsProps) { type="button" onClick={() => handleVote(false)} disabled={!isAuthenticated || props.isLoading} + aria-pressed={optimisticVote === false} className={`flex flex-col items-center p-3 rounded-lg transition-colors border-2 ${ optimisticVote === false ? 'bg-red-50 border-red-200 text-red-700 dark:bg-red-900/20 dark:border-red-700 dark:text-red-400' diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 44e27728..02088ad1 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -3,6 +3,7 @@ export * from './ApprovalStatusBadge' export * from './AuthorRiskIndicator' export * from './AuthorRiskWarningBanner' export * from './Badge' +export * from './BannedUserBadge' export * from './BulkActions' export * from './Button' export * from './Card' diff --git a/src/components/ui/modals/Modal.tsx b/src/components/ui/modals/Modal.tsx index b1dd7a17..186a117e 100644 --- a/src/components/ui/modals/Modal.tsx +++ b/src/components/ui/modals/Modal.tsx @@ -65,6 +65,9 @@ export function Modal({ onClose, ...props }: Props) { onClick={handleBackdropClick} >
AppError.badRequest('Trust adjustment cannot be zero'), + } } diff --git a/src/lib/trust/config.ts b/src/lib/trust/config.ts index 3b320df0..3ab72bbc 100644 --- a/src/lib/trust/config.ts +++ b/src/lib/trust/config.ts @@ -58,9 +58,13 @@ export const TRUST_ACTIONS = { description: 'Manual trust score decrease by admin', }, [TrustAction.VOTE_NULLIFICATION_REVERSAL]: { - weight: 0, // Dynamic weight set during nullification + weight: 0, description: 'Trust reversal due to vote nullification or restoration', }, + [TrustAction.VOTE_CHANGE_REVERSAL]: { + weight: 0, + description: 'Trust reversal due to vote change or removal', + }, } as const export const TRUST_LEVELS = [ diff --git a/src/lib/trust/service.test.ts b/src/lib/trust/service.test.ts new file mode 100644 index 00000000..c456fc8e --- /dev/null +++ b/src/lib/trust/service.test.ts @@ -0,0 +1,595 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { TrustAction } from '@orm' + +vi.mock('@/lib/analytics', () => ({ + default: { + trust: { + trustScoreChanged: vi.fn(), + trustLevelChanged: vi.fn(), + }, + }, +})) + +function createMockPrismaCtx() { + return { + user: { + findMany: vi.fn().mockResolvedValue([]), + findUnique: vi.fn().mockResolvedValue(null), + update: vi.fn().mockResolvedValue({}), + }, + trustActionLog: { + create: vi.fn().mockResolvedValue({}), + createMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + } +} + +type MockPrismaCtx = ReturnType + +describe('TrustService.applyBulkManualAdjustments', () => { + let prismaCtx: MockPrismaCtx + + beforeEach(async () => { + vi.clearAllMocks() + prismaCtx = createMockPrismaCtx() + }) + + async function createService() { + const { TrustService } = await import('./service') + return new TrustService(prismaCtx as never) + } + + it('returns 0 and makes no DB calls for an empty map', async () => { + const service = await createService() + + const result = await service.applyBulkManualAdjustments({ + adjustments: new Map(), + reason: 'test', + adminUserId: 'admin-1', + }) + + expect(result).toBe(0) + expect(prismaCtx.user.findMany).not.toHaveBeenCalled() + expect(prismaCtx.trustActionLog.createMany).not.toHaveBeenCalled() + }) + + it('returns 0 and makes no DB calls when all adjustments are zero', async () => { + const service = await createService() + + const result = await service.applyBulkManualAdjustments({ + adjustments: new Map([ + ['user-1', 0], + ['user-2', 0], + ]), + reason: 'test', + adminUserId: 'admin-1', + }) + + expect(result).toBe(0) + expect(prismaCtx.user.findMany).not.toHaveBeenCalled() + }) + + it('skips non-existent users gracefully', async () => { + prismaCtx.user.findMany.mockResolvedValue([{ id: 'user-1', trustScore: 50 }]) + prismaCtx.user.findUnique.mockResolvedValue({ name: 'Admin', email: 'admin@test.com' }) + + const service = await createService() + + const result = await service.applyBulkManualAdjustments({ + adjustments: new Map([ + ['user-1', -10], + ['deleted-user', -5], + ]), + reason: 'test', + adminUserId: 'admin-1', + }) + + expect(result).toBe(1) + expect(prismaCtx.user.update).toHaveBeenCalledTimes(1) + expect(prismaCtx.user.update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: { trustScore: 40, lastActiveAt: expect.any(Date) }, + }) + }) + + it('returns 0 when all users in map are non-existent', async () => { + prismaCtx.user.findMany.mockResolvedValue([]) + + const service = await createService() + + const result = await service.applyBulkManualAdjustments({ + adjustments: new Map([['deleted-user', -5]]), + reason: 'test', + adminUserId: 'admin-1', + }) + + expect(result).toBe(0) + expect(prismaCtx.user.findUnique).not.toHaveBeenCalled() + expect(prismaCtx.user.update).not.toHaveBeenCalled() + expect(prismaCtx.trustActionLog.createMany).not.toHaveBeenCalled() + }) + + it('applies a single adjustment correctly', async () => { + prismaCtx.user.findMany.mockResolvedValue([{ id: 'user-1', trustScore: 50 }]) + prismaCtx.user.findUnique.mockResolvedValue({ name: 'Admin', email: 'admin@test.com' }) + + const service = await createService() + + const result = await service.applyBulkManualAdjustments({ + adjustments: new Map([['user-1', -10]]), + reason: 'Vote nullification: Spam', + adminUserId: 'admin-1', + }) + + expect(result).toBe(1) + + expect(prismaCtx.user.update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: { trustScore: 40, lastActiveAt: expect.any(Date) }, + }) + + expect(prismaCtx.trustActionLog.createMany).toHaveBeenCalledWith({ + data: [ + { + userId: 'user-1', + action: TrustAction.ADMIN_ADJUSTMENT_NEGATIVE, + weight: -10, + metadata: { + reason: 'Vote nullification: Spam', + adminUserId: 'admin-1', + adminName: 'Admin', + adjustment: -10, + }, + }, + ], + }) + }) + + it('applies multiple adjustments with one createMany call', async () => { + prismaCtx.user.findMany.mockResolvedValue([ + { id: 'user-1', trustScore: 100 }, + { id: 'user-2', trustScore: 200 }, + ]) + prismaCtx.user.findUnique.mockResolvedValue({ name: 'Admin', email: 'admin@test.com' }) + + const service = await createService() + + const result = await service.applyBulkManualAdjustments({ + adjustments: new Map([ + ['user-1', -5], + ['user-2', 10], + ]), + reason: 'Vote nullification: test', + adminUserId: 'admin-1', + }) + + expect(result).toBe(2) + expect(prismaCtx.user.update).toHaveBeenCalledTimes(2) + expect(prismaCtx.trustActionLog.createMany).toHaveBeenCalledTimes(1) + + const logData = prismaCtx.trustActionLog.createMany.mock.calls[0][0].data as { + userId: string + action: TrustAction + }[] + expect(logData).toHaveLength(2) + expect(logData[0].userId).toBe('user-1') + expect(logData[0].action).toBe(TrustAction.ADMIN_ADJUSTMENT_NEGATIVE) + expect(logData[1].userId).toBe('user-2') + expect(logData[1].action).toBe(TrustAction.ADMIN_ADJUSTMENT_POSITIVE) + }) + + it('allows negative trust scores when adjustment exceeds current score (no clamping)', async () => { + prismaCtx.user.findMany.mockResolvedValue([{ id: 'user-1', trustScore: 5 }]) + prismaCtx.user.findUnique.mockResolvedValue({ name: 'Admin', email: 'admin@test.com' }) + + const service = await createService() + + await service.applyBulkManualAdjustments({ + adjustments: new Map([['user-1', -100]]), + reason: 'test', + adminUserId: 'admin-1', + }) + + // 5 + (-100) = -95 — negative scores are allowed (used for author-risk signals) + expect(prismaCtx.user.update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: { trustScore: -95, lastActiveAt: expect.any(Date) }, + }) + + const logData = prismaCtx.trustActionLog.createMany.mock.calls[0][0].data as { + weight: number + metadata: { adjustment: number } + }[] + expect(logData[0].weight).toBe(-100) + expect(logData[0].metadata.adjustment).toBe(-100) + }) + + it('emits trustLevelChanged analytics when level changes', async () => { + const analytics = (await import('@/lib/analytics')).default + + prismaCtx.user.findMany.mockResolvedValue([{ id: 'user-1', trustScore: 95 }]) + prismaCtx.user.findUnique.mockResolvedValue({ name: 'Admin', email: 'admin@test.com' }) + + const service = await createService() + + await service.applyBulkManualAdjustments({ + adjustments: new Map([['user-1', 10]]), + reason: 'test', + adminUserId: 'admin-1', + }) + + // 95 + 10 = 105, crosses from Newcomer (0) to Contributor (100) + expect(analytics.trust.trustLevelChanged).toHaveBeenCalledWith({ + userId: 'user-1', + oldLevel: 'Newcomer', + newLevel: 'Contributor', + score: 105, + }) + }) + + it('does not emit trustLevelChanged when level stays the same', async () => { + const analytics = (await import('@/lib/analytics')).default + + prismaCtx.user.findMany.mockResolvedValue([{ id: 'user-1', trustScore: 50 }]) + prismaCtx.user.findUnique.mockResolvedValue({ name: 'Admin', email: 'admin@test.com' }) + + const service = await createService() + + await service.applyBulkManualAdjustments({ + adjustments: new Map([['user-1', 10]]), + reason: 'test', + adminUserId: 'admin-1', + }) + + // 50 + 10 = 60, still Newcomer + expect(analytics.trust.trustLevelChanged).not.toHaveBeenCalled() + }) + + it('wraps in $transaction when prisma has it', async () => { + const mockTransaction = vi.fn(async (fn: (ctx: MockPrismaCtx) => Promise) => + fn(prismaCtx), + ) + const prismaWithTx = { + ...prismaCtx, + $transaction: mockTransaction, + } + + prismaCtx.user.findMany.mockResolvedValue([{ id: 'user-1', trustScore: 50 }]) + prismaCtx.user.findUnique.mockResolvedValue({ name: 'Admin', email: 'admin@test.com' }) + + const { TrustService } = await import('./service') + const service = new TrustService(prismaWithTx as never) + + await service.applyBulkManualAdjustments({ + adjustments: new Map([['user-1', 5]]), + reason: 'test', + adminUserId: 'admin-1', + }) + + expect(mockTransaction).toHaveBeenCalledTimes(1) + }) + + it('calls execute directly when prisma is a transaction client (no $transaction)', async () => { + prismaCtx.user.findMany.mockResolvedValue([{ id: 'user-1', trustScore: 50 }]) + prismaCtx.user.findUnique.mockResolvedValue({ name: 'Admin', email: 'admin@test.com' }) + + const service = await createService() + + const result = await service.applyBulkManualAdjustments({ + adjustments: new Map([['user-1', 5]]), + reason: 'test', + adminUserId: 'admin-1', + }) + + expect(result).toBe(1) + expect(prismaCtx.user.update).toHaveBeenCalled() + }) + + it('uses positive action type for positive adjustments', async () => { + prismaCtx.user.findMany.mockResolvedValue([{ id: 'user-1', trustScore: 50 }]) + prismaCtx.user.findUnique.mockResolvedValue({ name: 'Admin', email: 'admin@test.com' }) + + const service = await createService() + + await service.applyBulkManualAdjustments({ + adjustments: new Map([['user-1', 10]]), + reason: 'test', + adminUserId: 'admin-1', + }) + + const logData = prismaCtx.trustActionLog.createMany.mock.calls[0][0].data as { + action: TrustAction + }[] + expect(logData[0].action).toBe(TrustAction.ADMIN_ADJUSTMENT_POSITIVE) + }) + + it('falls back to email when admin has no name', async () => { + prismaCtx.user.findMany.mockResolvedValue([{ id: 'user-1', trustScore: 50 }]) + prismaCtx.user.findUnique.mockResolvedValue({ name: null, email: 'admin@test.com' }) + + const service = await createService() + + await service.applyBulkManualAdjustments({ + adjustments: new Map([['user-1', -5]]), + reason: 'test', + adminUserId: 'admin-1', + }) + + const logData = prismaCtx.trustActionLog.createMany.mock.calls[0][0].data as { + metadata: { adminName: string } + }[] + expect(logData[0].metadata.adminName).toBe('admin@test.com') + }) + + it('uses "Unknown Admin" when admin not found', async () => { + prismaCtx.user.findMany.mockResolvedValue([{ id: 'user-1', trustScore: 50 }]) + prismaCtx.user.findUnique.mockResolvedValue(null) + + const service = await createService() + + await service.applyBulkManualAdjustments({ + adjustments: new Map([['user-1', -5]]), + reason: 'test', + adminUserId: 'admin-1', + }) + + const logData = prismaCtx.trustActionLog.createMany.mock.calls[0][0].data as { + metadata: { adminName: string } + }[] + expect(logData[0].metadata.adminName).toBe('Unknown Admin') + }) +}) + +describe('TrustService.applyManualAdjustment', () => { + let prismaCtx: MockPrismaCtx + + beforeEach(async () => { + vi.clearAllMocks() + prismaCtx = createMockPrismaCtx() + }) + + async function createService() { + const { TrustService } = await import('./service') + return new TrustService(prismaCtx as never) + } + + it('throws when adjustment is zero', async () => { + const service = await createService() + + await expect( + service.applyManualAdjustment({ + userId: 'user-1', + adjustment: 0, + reason: 'test', + adminUserId: 'admin-1', + }), + ).rejects.toThrow() + }) + + it('throws when user not found', async () => { + prismaCtx.user.findUnique.mockResolvedValueOnce(null) + const service = await createService() + + await expect( + service.applyManualAdjustment({ + userId: 'user-1', + adjustment: 5, + reason: 'test', + adminUserId: 'admin-1', + }), + ).rejects.toThrow() + }) + + it('allows negative trust score when adjustment goes below zero (no clamping)', async () => { + prismaCtx.user.findUnique + .mockResolvedValueOnce({ trustScore: 5, name: 'User', email: 'user@test.com' }) + .mockResolvedValueOnce({ name: 'Admin', email: 'admin@test.com' }) + const service = await createService() + + await service.applyManualAdjustment({ + userId: 'user-1', + adjustment: -100, + reason: 'test', + adminUserId: 'admin-1', + }) + + // 5 + (-100) = -95, not clamped to 0 + expect(prismaCtx.user.update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: { trustScore: -95, lastActiveAt: expect.any(Date) }, + }) + }) + + it('logs the raw adjustment weight (no clamping adjustment)', async () => { + prismaCtx.user.findUnique + .mockResolvedValueOnce({ trustScore: 10, name: 'User', email: 'user@test.com' }) + .mockResolvedValueOnce({ name: 'Admin', email: 'admin@test.com' }) + const service = await createService() + + await service.applyManualAdjustment({ + userId: 'user-1', + adjustment: -50, + reason: 'Vote nullification', + adminUserId: 'admin-1', + }) + + expect(prismaCtx.trustActionLog.create).toHaveBeenCalledWith({ + data: { + userId: 'user-1', + action: TrustAction.ADMIN_ADJUSTMENT_NEGATIVE, + weight: -50, + metadata: { + reason: 'Vote nullification', + adminUserId: 'admin-1', + adminName: 'Admin', + adjustment: -50, + }, + }, + }) + }) +}) + +describe('TrustService.reverseLogAction', () => { + let prismaCtx: MockPrismaCtx + + beforeEach(async () => { + vi.clearAllMocks() + prismaCtx = createMockPrismaCtx() + }) + + async function createService() { + const { TrustService } = await import('./service') + return new TrustService(prismaCtx as never) + } + + it('negates positive action weight (LISTING_RECEIVED_UPVOTE: +2 → -2)', async () => { + prismaCtx.user.findUnique.mockResolvedValue({ trustScore: 10 }) + const service = await createService() + + await service.reverseLogAction({ + userId: 'author-1', + originalAction: TrustAction.LISTING_RECEIVED_UPVOTE, + metadata: { listingId: 'listing-1' }, + }) + + expect(prismaCtx.user.update).toHaveBeenCalledWith({ + where: { id: 'author-1' }, + data: { trustScore: 8, lastActiveAt: expect.any(Date) }, + }) + expect(prismaCtx.trustActionLog.create).toHaveBeenCalledWith({ + data: { + userId: 'author-1', + action: TrustAction.VOTE_CHANGE_REVERSAL, + weight: -2, + metadata: { + listingId: 'listing-1', + originalAction: TrustAction.LISTING_RECEIVED_UPVOTE, + reversed: true, + }, + }, + }) + }) + + it('negates negative action weight (COMMENT_RECEIVED_DOWNVOTE: -1 → +1)', async () => { + prismaCtx.user.findUnique.mockResolvedValue({ trustScore: 5 }) + const service = await createService() + + await service.reverseLogAction({ + userId: 'author-1', + originalAction: TrustAction.COMMENT_RECEIVED_DOWNVOTE, + metadata: { commentId: 'comment-1' }, + }) + + expect(prismaCtx.user.update).toHaveBeenCalledWith({ + where: { id: 'author-1' }, + data: { trustScore: 6, lastActiveAt: expect.any(Date) }, + }) + expect(prismaCtx.trustActionLog.create).toHaveBeenCalledWith({ + data: { + userId: 'author-1', + action: TrustAction.VOTE_CHANGE_REVERSAL, + weight: 1, + metadata: { + commentId: 'comment-1', + originalAction: TrustAction.COMMENT_RECEIVED_DOWNVOTE, + reversed: true, + }, + }, + }) + }) + + it('allows negative trust score on reversal (no clamping)', async () => { + prismaCtx.user.findUnique.mockResolvedValue({ trustScore: 1 }) + const service = await createService() + + await service.reverseLogAction({ + userId: 'author-1', + originalAction: TrustAction.LISTING_RECEIVED_UPVOTE, // weight +2, reversal = -2 + metadata: {}, + }) + + // 1 + (-2) = -1, allowed + expect(prismaCtx.user.update).toHaveBeenCalledWith({ + where: { id: 'author-1' }, + data: { trustScore: -1, lastActiveAt: expect.any(Date) }, + }) + }) + + it('no-ops when original action has weight 0', async () => { + const service = await createService() + + await service.reverseLogAction({ + userId: 'user-1', + originalAction: TrustAction.ADMIN_ADJUSTMENT_POSITIVE, // weight 0 + metadata: {}, + }) + + expect(prismaCtx.user.update).not.toHaveBeenCalled() + expect(prismaCtx.trustActionLog.create).not.toHaveBeenCalled() + }) + + it('throws on invalid action', async () => { + const service = await createService() + + await expect( + service.reverseLogAction({ + userId: 'user-1', + originalAction: 'NONEXISTENT_ACTION' as TrustAction, + metadata: {}, + }), + ).rejects.toThrow('Invalid trust action') + }) + + it('wraps in $transaction when prisma has it', async () => { + const mockTransaction = vi.fn(async (fn: (ctx: MockPrismaCtx) => Promise) => + fn(prismaCtx), + ) + const prismaWithTx = { ...prismaCtx, $transaction: mockTransaction } + prismaCtx.user.findUnique.mockResolvedValue({ trustScore: 10 }) + + const { TrustService } = await import('./service') + const service = new TrustService(prismaWithTx as never) + + await service.reverseLogAction({ + userId: 'user-1', + originalAction: TrustAction.LISTING_RECEIVED_UPVOTE, + metadata: {}, + }) + + expect(mockTransaction).toHaveBeenCalledTimes(1) + }) + + it('calls execute directly when prisma is a transaction client (no $transaction)', async () => { + prismaCtx.user.findUnique.mockResolvedValue({ trustScore: 10 }) + const service = await createService() + + await service.reverseLogAction({ + userId: 'user-1', + originalAction: TrustAction.LISTING_RECEIVED_UPVOTE, + metadata: {}, + }) + + expect(prismaCtx.user.update).toHaveBeenCalled() + }) + + it('emits trustLevelChanged when crossing a level boundary', async () => { + const analytics = (await import('@/lib/analytics')).default + + prismaCtx.user.findUnique.mockResolvedValue({ trustScore: 101 }) + const service = await createService() + + await service.reverseLogAction({ + userId: 'user-1', + originalAction: TrustAction.LISTING_RECEIVED_UPVOTE, // -2 + metadata: {}, + }) + + // 101 + (-2) = 99, drops from Contributor (>=100) back to Newcomer + expect(analytics.trust.trustLevelChanged).toHaveBeenCalledWith({ + userId: 'user-1', + oldLevel: 'Contributor', + newLevel: 'Newcomer', + score: 99, + }) + }) +}) diff --git a/src/lib/trust/service.ts b/src/lib/trust/service.ts index 7ebc3880..f399f562 100644 --- a/src/lib/trust/service.ts +++ b/src/lib/trust/service.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import analytics from '@/lib/analytics' +import { ResourceError } from '@/lib/errors' import { prisma } from '@/server/db' import { validateData } from '@/server/utils/validation' import { TrustAction, type Prisma, type PrismaClient } from '@orm' @@ -13,6 +14,7 @@ function resolveTrustLevelName(level: ReturnType | null): interface TrustActionContext { listingId?: string + pcListingId?: string targetUserId?: string voteType?: 'up' | 'down' adminUserId?: string @@ -112,7 +114,7 @@ export async function reverseTrustAction(params: { }) const currentTrustLevel = currentUser ? getTrustLevel(currentUser.trustScore) : null - const newTrustScore = Math.max(0, (currentUser?.trustScore || 0) + reversalWeight) + const newTrustScore = (currentUser?.trustScore ?? 0) + reversalWeight const newTrustLevel = getTrustLevel(newTrustScore) await tx.user.update({ @@ -228,44 +230,44 @@ export async function applyMonthlyActiveBonus(): Promise<{ return { processedUsers, errors } } -export async function applyManualTrustAdjustment(params: { - userId: string - adjustment: number - reason: string - adminUserId: string -}): Promise { - const { userId, adjustment, reason, adminUserId } = params +type PrismaTransaction = Omit< + PrismaClient, + '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends' +> - if (adjustment === 0) { - // TODO: Custom Error, or not to Custom Error, that is the question - throw new Error('Adjustment cannot be zero') - } +/** + * TrustService class for managing trust actions. + * Accepts either a PrismaClient (creates its own transactions) or a + * TransactionClient (participates in an outer transaction). + */ +export class TrustService { + constructor(private readonly prisma: PrismaClient | PrismaTransaction) {} - try { - // Get user's current trust score before applying adjustment - const currentUser = await prisma.user.findUnique({ - where: { id: userId }, - select: { trustScore: true, name: true, email: true }, - }) + async logAction(params: { + userId: string + action: TrustAction + targetUserId?: string + metadata?: Record + }): Promise { + const { userId, action, metadata } = params - if (!currentUser) { - // TODO: use proper error later, now i just want to sleep - throw new Error('User not found') + if (!TRUST_ACTIONS[action]) { + throw new Error(`Invalid trust action: ${action}`) } - const currentTrustLevel = getTrustLevel(currentUser.trustScore) - const newTrustScore = Math.max(0, currentUser.trustScore + adjustment) // Prevent negative trust scores - const newTrustLevel = getTrustLevel(newTrustScore) - const actualAdjustment = newTrustScore - currentUser.trustScore + const weight = TRUST_ACTIONS[action].weight - // Determine action type based on adjustment direction - const action = - adjustment > 0 ? TrustAction.ADMIN_ADJUSTMENT_POSITIVE : TrustAction.ADMIN_ADJUSTMENT_NEGATIVE + const executeInTransaction = async (prismaCtx: PrismaTransaction) => { + const currentUser = await prismaCtx.user.findUnique({ + where: { id: userId }, + select: { trustScore: true }, + }) + + const currentTrustLevel = currentUser ? getTrustLevel(currentUser.trustScore) : null + const newTrustScore = (currentUser?.trustScore || 0) + weight + const newTrustLevel = getTrustLevel(newTrustScore) - // Use a transaction to ensure atomicity - await prisma.$transaction(async (tx) => { - // Update user's trust score - await tx.user.update({ + await prismaCtx.user.update({ where: { id: userId }, data: { trustScore: newTrustScore, @@ -273,77 +275,58 @@ export async function applyManualTrustAdjustment(params: { }, }) - // Create audit log entry with admin context - await tx.trustActionLog.create({ + await prismaCtx.trustActionLog.create({ data: { userId, action, - weight: actualAdjustment, - metadata: { - reason, - adminUserId, - adminName: await tx.user - .findUnique({ - where: { id: adminUserId }, - select: { name: true, email: true }, - }) - .then((admin) => admin?.name || admin?.email || 'Unknown Admin'), - originalAdjustment: adjustment, - actualAdjustment, - }, + weight, + metadata: metadata as Prisma.InputJsonValue, }, }) - }) - - analytics.trust.trustScoreChanged({ - userId, - oldScore: currentUser.trustScore, - newScore: newTrustScore, - action, - weight: actualAdjustment, - }) - // Track trust level achievement if level changed - if ((currentTrustLevel.name ?? null) !== (newTrustLevel.name ?? null)) { - analytics.trust.trustLevelChanged({ + analytics.trust.trustScoreChanged({ userId, - oldLevel: resolveTrustLevelName(currentTrustLevel), - newLevel: resolveTrustLevelName(newTrustLevel), - score: newTrustScore, + oldScore: currentUser?.trustScore || 0, + newScore: newTrustScore, + action, + weight, }) - } - } catch (error) { - console.error('Failed to apply manual trust adjustment:', error) - throw new Error('Failed to update trust score') - } -} -type PrismaTransaction = Omit< - PrismaClient, - '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends' -> + const previousLevel = currentTrustLevel?.name ?? null + const nextLevel = newTrustLevel.name ?? null -/** - * TrustService class for managing trust actions - */ -export class TrustService { - constructor(private readonly prisma: PrismaClient | PrismaTransaction) {} + if (previousLevel !== nextLevel) { + analytics.trust.trustLevelChanged({ + userId, + oldLevel: resolveTrustLevelName(currentTrustLevel), + newLevel: resolveTrustLevelName(newTrustLevel), + score: newTrustScore, + }) + } + } - async logAction(params: { + if ('$transaction' in this.prisma) { + await this.prisma.$transaction(executeInTransaction) + } else { + await executeInTransaction(this.prisma) + } + } + + async reverseLogAction(params: { userId: string - action: TrustAction + originalAction: TrustAction targetUserId?: string metadata?: Record }): Promise { - const { userId, action, metadata } = params + const { userId, originalAction, metadata = {} } = params - if (!TRUST_ACTIONS[action]) { - throw new Error(`Invalid trust action: ${action}`) + if (!TRUST_ACTIONS[originalAction]) { + throw new Error(`Invalid trust action: ${originalAction}`) } - const weight = TRUST_ACTIONS[action].weight + const reversalWeight = -TRUST_ACTIONS[originalAction].weight + if (reversalWeight === 0) return - // Handle both regular prisma client and transaction context const executeInTransaction = async (prismaCtx: PrismaTransaction) => { const currentUser = await prismaCtx.user.findUnique({ where: { id: userId }, @@ -351,7 +334,7 @@ export class TrustService { }) const currentTrustLevel = currentUser ? getTrustLevel(currentUser.trustScore) : null - const newTrustScore = (currentUser?.trustScore || 0) + weight + const newTrustScore = (currentUser?.trustScore ?? 0) + reversalWeight const newTrustLevel = getTrustLevel(newTrustScore) await prismaCtx.user.update({ @@ -365,19 +348,18 @@ export class TrustService { await prismaCtx.trustActionLog.create({ data: { userId, - action, - weight, - metadata: metadata as Prisma.InputJsonValue, + action: TrustAction.VOTE_CHANGE_REVERSAL, + weight: reversalWeight, + metadata: { ...metadata, originalAction, reversed: true } as Prisma.InputJsonValue, }, }) - // Track analytics analytics.trust.trustScoreChanged({ userId, - oldScore: currentUser?.trustScore || 0, + oldScore: currentUser?.trustScore ?? 0, newScore: newTrustScore, - action, - weight, + action: TrustAction.VOTE_CHANGE_REVERSAL, + weight: reversalWeight, }) const previousLevel = currentTrustLevel?.name ?? null @@ -393,11 +375,199 @@ export class TrustService { } } - // If already in a transaction, use it; otherwise create a new one if ('$transaction' in this.prisma) { await this.prisma.$transaction(executeInTransaction) } else { await executeInTransaction(this.prisma) } } + + async applyManualAdjustment(params: { + userId: string + adjustment: number + reason: string + adminUserId: string + }): Promise { + const { userId, adjustment, reason, adminUserId } = params + + if (adjustment === 0) { + throw ResourceError.trust.adjustmentCannotBeZero() + } + + const executeAdjustment = async (prismaCtx: PrismaTransaction) => { + const currentUser = await prismaCtx.user.findUnique({ + where: { id: userId }, + select: { trustScore: true, name: true, email: true }, + }) + + if (!currentUser) { + throw ResourceError.user.notFound() + } + + const currentTrustLevel = getTrustLevel(currentUser.trustScore) + const newTrustScore = currentUser.trustScore + adjustment + const newTrustLevel = getTrustLevel(newTrustScore) + + const action = + adjustment > 0 + ? TrustAction.ADMIN_ADJUSTMENT_POSITIVE + : TrustAction.ADMIN_ADJUSTMENT_NEGATIVE + + await prismaCtx.user.update({ + where: { id: userId }, + data: { + trustScore: newTrustScore, + lastActiveAt: new Date(), + }, + }) + + const admin = await prismaCtx.user.findUnique({ + where: { id: adminUserId }, + select: { name: true, email: true }, + }) + + await prismaCtx.trustActionLog.create({ + data: { + userId, + action, + weight: adjustment, + metadata: { + reason, + adminUserId, + adminName: admin?.name || admin?.email || 'Unknown Admin', + adjustment, + }, + }, + }) + + analytics.trust.trustScoreChanged({ + userId, + oldScore: currentUser.trustScore, + newScore: newTrustScore, + action, + weight: adjustment, + }) + + if ((currentTrustLevel.name ?? null) !== (newTrustLevel.name ?? null)) { + analytics.trust.trustLevelChanged({ + userId, + oldLevel: resolveTrustLevelName(currentTrustLevel), + newLevel: resolveTrustLevelName(newTrustLevel), + score: newTrustScore, + }) + } + } + + if ('$transaction' in this.prisma) { + await this.prisma.$transaction(executeAdjustment) + } else { + await executeAdjustment(this.prisma) + } + } + + async applyBulkManualAdjustments(params: { + adjustments: Map + reason: string + adminUserId: string + }): Promise { + const { adjustments, reason, adminUserId } = params + + const nonZeroEntries = [...adjustments.entries()].filter(([, adj]) => adj !== 0) + if (nonZeroEntries.length === 0) return 0 + + const userIds = nonZeroEntries.map(([id]) => id) + + const executeBulk = async (prismaCtx: PrismaTransaction) => { + const users = await prismaCtx.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, trustScore: true }, + }) + + const userMap = new Map(users.map((u) => [u.id, u.trustScore])) + const foundEntries = nonZeroEntries.filter(([id]) => userMap.has(id)) + if (foundEntries.length === 0) return 0 + + const admin = await prismaCtx.user.findUnique({ + where: { id: adminUserId }, + select: { name: true, email: true }, + }) + const adminName = admin?.name || admin?.email || 'Unknown Admin' + + const logEntries: { + userId: string + action: TrustAction + weight: number + metadata: Prisma.InputJsonValue + }[] = [] + + const levelChanges: { + userId: string + oldLevel: string + newLevel: string + score: number + }[] = [] + + for (const [userId, adjustment] of foundEntries) { + const currentScore = userMap.get(userId) ?? 0 + const newTrustScore = currentScore + adjustment + + const action = + adjustment > 0 + ? TrustAction.ADMIN_ADJUSTMENT_POSITIVE + : TrustAction.ADMIN_ADJUSTMENT_NEGATIVE + + await prismaCtx.user.update({ + where: { id: userId }, + data: { + trustScore: newTrustScore, + lastActiveAt: new Date(), + }, + }) + + logEntries.push({ + userId, + action, + weight: adjustment, + metadata: { + reason, + adminUserId, + adminName, + adjustment, + }, + }) + + analytics.trust.trustScoreChanged({ + userId, + oldScore: currentScore, + newScore: newTrustScore, + action, + weight: adjustment, + }) + + const currentTrustLevel = getTrustLevel(currentScore) + const newTrustLevel = getTrustLevel(newTrustScore) + if ((currentTrustLevel.name ?? null) !== (newTrustLevel.name ?? null)) { + levelChanges.push({ + userId, + oldLevel: resolveTrustLevelName(currentTrustLevel), + newLevel: resolveTrustLevelName(newTrustLevel), + score: newTrustScore, + }) + } + } + + await prismaCtx.trustActionLog.createMany({ data: logEntries }) + + for (const change of levelChanges) { + analytics.trust.trustLevelChanged(change) + } + + return foundEntries.length + } + + if ('$transaction' in this.prisma) { + return this.prisma.$transaction(executeBulk) + } + return executeBulk(this.prisma) + } } diff --git a/src/schemas/listing.ts b/src/schemas/listing.ts index 9ff49aca..03173cd6 100644 --- a/src/schemas/listing.ts +++ b/src/schemas/listing.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { PAGINATION } from '@/data/constants' -import { JsonValueSchema } from '@/schemas/common' +import { JsonValueSchema, ListingType } from '@/schemas/common' import { ApprovalStatus } from '@orm' export const CreateListingSchema = z.object({ @@ -50,6 +50,11 @@ export const GetListingsSchema = z.object({ export const GetListingByIdSchema = z.object({ id: z.string().uuid() }) +export const GetListingModeratorInfoSchema = z.object({ + id: z.string().uuid(), + type: ListingType, +}) + export const GetPendingListingsSchema = z .object({ search: z.string().nullable().optional(), @@ -77,6 +82,20 @@ export const GetProcessedSchema = z.object({ limit: z.number().default(10), filterStatus: z.nativeEnum(ApprovalStatus).nullable().optional(), search: z.string().nullable().optional(), + sortField: z + .enum([ + 'processedAt', + 'createdAt', + 'status', + 'game.title', + 'game.system.name', + 'device', + 'emulator.name', + 'author.name', + ]) + .nullable() + .optional(), + sortDirection: z.enum(['asc', 'desc']).nullable().optional(), }) export const OverrideApprovalStatusSchema = z.object({ diff --git a/src/schemas/pcListing.ts b/src/schemas/pcListing.ts index 963a7290..441102ff 100644 --- a/src/schemas/pcListing.ts +++ b/src/schemas/pcListing.ts @@ -78,6 +78,16 @@ export const GetPendingPcListingsSchema = z export const DeletePcListingSchema = z.object({ id: z.string().uuid() }) +// TODO: Wire up a PC admin processed-listings page + router procedure for +// parity with handheld (`admin.getProcessed` + `src/app/admin/processed-listings/`). +// When doing so, extend this schema with `sortField` / `sortDirection` using the +// same shape as `GetProcessedSchema` in `./listing.ts`, and ideally share as much +// of the admin router logic as possible (the two codebases are drifting — fixes +// applied to handheld listings often miss their PC counterpart). Candidates for +// shared code: `buildProcessedOrderBy`, the search `where` builder, the +// approval-flow branches. See also: `src/server/api/utils/listingHelpers.ts` +// (handheld) vs `pcListingHelpers.ts` (PC) — these helpers already exist and +// should be the basis for a shared abstraction. export const GetProcessedPcSchema = z.object({ page: z.number().default(1), limit: z.number().default(10), @@ -225,6 +235,7 @@ export const GetPcPresetsSchema = z.object({ export const VotePcListingSchema = z.object({ pcListingId: z.string().uuid(), value: z.boolean(), // true = upvote, false = downvote + recaptchaToken: z.string().nullable().optional(), }) export const GetPcListingUserVoteSchema = z.object({ diff --git a/src/server/api/routers/listings.ts b/src/server/api/routers/listings.ts index 9d75104c..77922ec7 100644 --- a/src/server/api/routers/listings.ts +++ b/src/server/api/routers/listings.ts @@ -18,6 +18,7 @@ export const listingsRouter = createTRPCRouter({ unpinComment: commentsRouter.unpinComment, // Admin operations + moderatorInfo: adminRouter.moderatorInfo, getPending: adminRouter.getPending, approveListing: adminRouter.approve, rejectListing: adminRouter.reject, diff --git a/src/server/api/routers/listings/admin.ts b/src/server/api/routers/listings/admin.ts index 79afa327..ac06029d 100644 --- a/src/server/api/routers/listings/admin.ts +++ b/src/server/api/routers/listings/admin.ts @@ -1,10 +1,12 @@ import { ResourceError, AppError } from '@/lib/errors' import { applyTrustAction } from '@/lib/trust/service' +import { ListingType } from '@/schemas/common' import { ApproveListingSchema, RejectListingSchema, GetProcessedSchema, GetPendingListingsSchema, + GetListingModeratorInfoSchema, OverrideApprovalStatusSchema, DeleteListingSchema, BulkApproveListingsSchema, @@ -24,6 +26,7 @@ import { viewStatisticsProcedure, protectedProcedure, } from '@/server/api/trpc' +import { buildProcessedOrderBy } from '@/server/api/utils/listingHelpers' import { invalidateListing, invalidateListPages, @@ -31,6 +34,8 @@ import { revalidateByTag, } from '@/server/cache/invalidation' import { notificationEventEmitter, NOTIFICATION_EVENTS } from '@/server/notifications/eventEmitter' +import { ListingsRepository } from '@/server/repositories/listings.repository' +import { PcListingsRepository } from '@/server/repositories/pc-listings.repository' import { computeAuthorRiskProfiles } from '@/server/services/author-risk.service' import { listingStatsCache } from '@/server/utils/cache/instances' import { generateEmulatorConfig } from '@/server/utils/emulator-config/emulator-detector' @@ -43,6 +48,15 @@ const LISTING_STATS_CACHE_KEY = 'listing-stats' const mode = Prisma.QueryMode.insensitive export const adminRouter = createTRPCRouter({ + moderatorInfo: moderatorProcedure + .input(GetListingModeratorInfoSchema) + .query(async ({ ctx, input }) => { + return input.type === ListingType.enum.handheld + ? new ListingsRepository(ctx.prisma).getModeratorInfo(input.id) + : new PcListingsRepository(ctx.prisma).getModeratorInfo(input.id) + }), + + // TODO: abstract to service or repository getPending: developerProcedure.input(GetPendingListingsSchema).query(async ({ ctx, input }) => { const { search, page = 1, limit = 20, sortField, sortDirection } = input ?? {} const skip = (page - 1) * limit @@ -123,7 +137,6 @@ export const adminRouter = createTRPCRouter({ select: { id: true, name: true, - email: true, userBans: { where: { isActive: true, @@ -267,7 +280,7 @@ export const adminRouter = createTRPCRouter({ }, }) - ResourceError.listing.cannotApproveBannedUser(banReason) + return ResourceError.listing.cannotApproveBannedUser(banReason) } // Update listing status @@ -450,7 +463,7 @@ export const adminRouter = createTRPCRouter({ }), getProcessed: superAdminProcedure.input(GetProcessedSchema).query(async ({ ctx, input }) => { - const { page, limit, filterStatus, search } = input + const { page, limit, filterStatus, search, sortField, sortDirection } = input const skip = (page - 1) * limit const baseWhere: Prisma.ListingWhereInput = { @@ -474,19 +487,19 @@ export const adminRouter = createTRPCRouter({ ...searchWhere, } + const orderBy = buildProcessedOrderBy(sortField, sortDirection) + const listings = await ctx.prisma.listing.findMany({ where: whereClause, include: { game: { include: { system: true } }, device: { include: { brand: true } }, emulator: true, - author: { select: { id: true, name: true, email: true } }, + author: { select: { id: true, name: true } }, performance: true, - processedByUser: { select: { id: true, name: true, email: true } }, // Admin who processed - }, - orderBy: { - processedAt: 'desc', // Show most recently processed first + processedByUser: { select: { id: true, name: true } }, }, + orderBy, skip, take: limit, }) @@ -497,7 +510,7 @@ export const adminRouter = createTRPCRouter({ return { listings, - pagination: paginate({ total: totalListings, page, limit: limit }), + pagination: paginate({ total: totalListings, page, limit }), } }), @@ -666,22 +679,9 @@ export const adminRouter = createTRPCRouter({ }) } - // Apply trust actions for approved listings only - for (const listing of validListings) { - if (listing.authorId) { - await applyTrustAction({ - userId: listing.authorId, - action: TrustAction.LISTING_APPROVED, - context: { - listingId: listing.id, - adminUserId, - reason: 'bulk_listing_approved', - }, - }) - } - } - - // Return data needed for post-transaction operations + // Return data needed for post-transaction operations. + // Trust actions intentionally run AFTER the transaction commits — applyTrustAction + // opens its own internal transaction, so nesting would deadlock or surprise. return { validListings, bannedUserListings, @@ -689,6 +689,24 @@ export const adminRouter = createTRPCRouter({ } }) + // Apply trust actions in parallel for approved listings (post-commit, distinct authors). + const approvedListingsWithAuthor = transactionResult.validListings.filter( + (l): l is typeof l & { authorId: string } => l.authorId !== null, + ) + await Promise.all( + approvedListingsWithAuthor.map((listing) => + applyTrustAction({ + userId: listing.authorId, + action: TrustAction.LISTING_APPROVED, + context: { + listingId: listing.id, + adminUserId, + reason: 'bulk_listing_approved', + }, + }), + ), + ) + // Emit notification events AFTER transaction completes successfully try { const { validListings } = transactionResult @@ -827,28 +845,33 @@ export const adminRouter = createTRPCRouter({ }, }) - // Apply trust actions for all rejected listings - for (const listing of listingsToReject) { - if (listing.authorId) { - await applyTrustAction({ - userId: listing.authorId, - action: TrustAction.LISTING_REJECTED, - context: { - listingId: listing.id, - adminUserId, - reason: notes || 'bulk_listing_rejected', - }, - }) - } - } - - // Return data needed for post-transaction operations + // Return data needed for post-transaction operations. + // Trust actions intentionally run AFTER the transaction commits — applyTrustAction + // opens its own internal transaction, so nesting would deadlock or surprise. return { listingsToReject, notFoundOrNotPendingIds, } }) + // Apply trust actions in parallel for rejected listings (post-commit, distinct authors). + const rejectedListingsWithAuthor = transactionResult.listingsToReject.filter( + (l): l is typeof l & { authorId: string } => l.authorId !== null, + ) + await Promise.all( + rejectedListingsWithAuthor.map((listing) => + applyTrustAction({ + userId: listing.authorId, + action: TrustAction.LISTING_REJECTED, + context: { + listingId: listing.id, + adminUserId, + reason: notes || 'bulk_listing_rejected', + }, + }), + ), + ) + // NOTE: Emit notification events AFTER transaction completes successfully try { const { listingsToReject } = transactionResult @@ -990,7 +1013,7 @@ export const adminRouter = createTRPCRouter({ game: { include: { system: true } }, device: { include: { brand: true, soc: true } }, emulator: true, - author: { select: { id: true, name: true, email: true } }, + author: { select: { id: true, name: true } }, performance: true, }, orderBy, @@ -1027,7 +1050,7 @@ export const adminRouter = createTRPCRouter({ }, }, }, - author: { select: { id: true, name: true, email: true } }, + author: { select: { id: true, name: true } }, performance: true, customFieldValues: { include: { customFieldDefinition: { include: { category: true } } }, @@ -1055,7 +1078,7 @@ export const adminRouter = createTRPCRouter({ if (!existingListing) return ResourceError.listing.notFound() - return ctx.prisma.$transaction(async (tx) => { + return await ctx.prisma.$transaction(async (tx) => { // Update the main listing fields const updatedListing = await tx.listing.update({ where: { id }, diff --git a/src/server/api/routers/listings/comments.test.ts b/src/server/api/routers/listings/comments.test.ts new file mode 100644 index 00000000..d25b3afe --- /dev/null +++ b/src/server/api/routers/listings/comments.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { Role } from '@orm' + +vi.unmock('@/server/api/trpc') +vi.unmock('@/server/api/root') + +const mockHandleCommentVoteTrustEffects = vi.fn().mockResolvedValue(undefined) +const mockEmitNotificationEvent = vi.fn() + +vi.mock('@/server/utils/vote-trust-effects', () => ({ + handleCommentVoteTrustEffects: (...args: unknown[]) => mockHandleCommentVoteTrustEffects(...args), + handleListingVoteTrustEffects: vi.fn(), + handleVoteTrustEffects: vi.fn(), +})) + +vi.mock('@/server/notifications/eventEmitter', () => ({ + notificationEventEmitter: { emitNotificationEvent: mockEmitNotificationEvent }, + NOTIFICATION_EVENTS: { + COMMENT_VOTED: 'COMMENT_VOTED', + LISTING_COMMENTED: 'LISTING_COMMENTED', + COMMENT_REPLIED: 'COMMENT_REPLIED', + }, +})) + +vi.mock('@/server/utils/query-builders', () => ({ + isUserBanned: vi.fn().mockResolvedValue(false), +})) + +vi.mock('@/lib/analytics', () => ({ + default: { + engagement: { commentVote: vi.fn() }, + }, +})) + +const { commentsRouter } = await import('./comments') + +const USER_ID = '00000000-0000-4000-a000-000000000001' +const AUTHOR_ID = '00000000-0000-4000-a000-000000000002' +const LISTING_ID = '00000000-0000-4000-a000-000000000010' +const COMMENT_ID = '00000000-0000-4000-a000-000000000020' + +function createMockPrisma() { + const mockTx = { + commentVote: { + create: vi.fn().mockResolvedValue({ userId: USER_ID, commentId: COMMENT_ID, value: true }), + delete: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue({ userId: USER_ID, commentId: COMMENT_ID, value: false }), + findUnique: vi.fn().mockResolvedValue(null), + }, + comment: { + findUnique: vi.fn(), + update: vi.fn().mockResolvedValue({ id: COMMENT_ID, score: 1 }), + }, + } + + return { + ...mockTx, + $transaction: vi.fn(async (cb: (tx: typeof mockTx) => Promise) => cb(mockTx)), + } +} + +type MockPrisma = ReturnType + +function createCaller(overrides: { userId?: string; role?: Role; prisma?: MockPrisma } = {}) { + const prisma = overrides.prisma ?? createMockPrisma() + return { + caller: commentsRouter.createCaller({ + session: { + user: { + id: overrides.userId ?? USER_ID, + email: 'test@test.com', + name: 'Test User', + role: overrides.role ?? Role.USER, + permissions: [], + showNsfw: false, + }, + }, + prisma: prisma as never, + headers: new Headers(), + }), + prisma, + } +} + +describe('handheld comments router — voteComment', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function setupCommentMocks(prisma: MockPrisma) { + prisma.comment.findUnique.mockResolvedValue({ + id: COMMENT_ID, + userId: AUTHOR_ID, + listingId: LISTING_ID, + }) + prisma.commentVote.findUnique.mockResolvedValue(null) + prisma.comment.update.mockResolvedValue({ id: COMMENT_ID, score: 1 }) + } + + it('dispatches trust effects with listingType handheld on new upvote', async () => { + const { caller, prisma } = createCaller() + setupCommentMocks(prisma) + + await caller.vote({ commentId: COMMENT_ID, value: true }) + + expect(mockHandleCommentVoteTrustEffects).toHaveBeenCalledWith( + expect.objectContaining({ + trustAction: 'upvote', + commentAuthorId: AUTHOR_ID, + voterId: USER_ID, + commentId: COMMENT_ID, + parentEntityId: LISTING_ID, + listingType: 'handheld', + }), + ) + }) + + it('dispatches trust effects with change action on vote flip', async () => { + const { caller, prisma } = createCaller() + setupCommentMocks(prisma) + prisma.commentVote.findUnique.mockResolvedValue({ + userId: USER_ID, + commentId: COMMENT_ID, + value: true, + }) + + await caller.vote({ commentId: COMMENT_ID, value: false }) + + expect(mockHandleCommentVoteTrustEffects).toHaveBeenCalledWith( + expect.objectContaining({ + trustAction: 'change', + previousValue: true, + newValue: false, + listingType: 'handheld', + }), + ) + }) + + it('dispatches trust effects with remove action on toggle-off', async () => { + const { caller, prisma } = createCaller() + setupCommentMocks(prisma) + prisma.commentVote.findUnique.mockResolvedValue({ + userId: USER_ID, + commentId: COMMENT_ID, + value: true, + }) + + await caller.vote({ commentId: COMMENT_ID, value: true }) + + expect(mockHandleCommentVoteTrustEffects).toHaveBeenCalledWith( + expect.objectContaining({ + trustAction: 'remove', + listingType: 'handheld', + }), + ) + }) + + it('emits COMMENT_VOTED on new upvote', async () => { + const { caller, prisma } = createCaller() + setupCommentMocks(prisma) + + await caller.vote({ commentId: COMMENT_ID, value: true }) + + expect(mockEmitNotificationEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'COMMENT_VOTED', + entityId: COMMENT_ID, + payload: expect.objectContaining({ voteValue: true, commentId: COMMENT_ID }), + }), + ) + }) + + it('does NOT emit COMMENT_VOTED on toggle-off', async () => { + const { caller, prisma } = createCaller() + setupCommentMocks(prisma) + prisma.commentVote.findUnique.mockResolvedValue({ + userId: USER_ID, + commentId: COMMENT_ID, + value: true, + }) + + await caller.vote({ commentId: COMMENT_ID, value: true }) + + expect(mockEmitNotificationEvent).not.toHaveBeenCalled() + }) + + it('fetches existingVote inside the $transaction callback', async () => { + const { caller, prisma } = createCaller() + setupCommentMocks(prisma) + + await caller.vote({ commentId: COMMENT_ID, value: true }) + + const txCall = vi.mocked(prisma.$transaction).mock.invocationCallOrder[0] + const findUniqueCall = prisma.commentVote.findUnique.mock.invocationCallOrder[0] + expect(txCall).toBeDefined() + expect(findUniqueCall).toBeDefined() + expect(findUniqueCall).toBeGreaterThan(txCall as number) + }) +}) diff --git a/src/server/api/routers/listings/comments.ts b/src/server/api/routers/listings/comments.ts index 878f89d7..884d2890 100644 --- a/src/server/api/routers/listings/comments.ts +++ b/src/server/api/routers/listings/comments.ts @@ -2,7 +2,6 @@ import analytics from '@/lib/analytics' import { RECAPTCHA_CONFIG } from '@/lib/captcha/config' import { getClientIP, verifyRecaptcha } from '@/lib/captcha/verify' import { AppError, ResourceError } from '@/lib/errors' -import { TrustService } from '@/lib/trust/service' import { CreateCommentSchema, EditCommentSchema, @@ -20,15 +19,20 @@ import { notificationEventEmitter, NOTIFICATION_EVENTS } from '@/server/notifica import { CommentsRepository } from '@/server/repositories/comments.repository' import { logAudit } from '@/server/services/audit.service' import { isUserBanned } from '@/server/utils/query-builders' +import { handleCommentVoteTrustEffects } from '@/server/utils/vote-trust-effects' import { roleIncludesRole } from '@/utils/permission-system' import { canDeleteComment, canEditComment } from '@/utils/permissions' -import { AuditAction, AuditEntityType, Role, TrustAction } from '@orm' +import { AuditAction, AuditEntityType, Role } from '@orm' export const commentsRouter = createTRPCRouter({ create: protectedProcedure.input(CreateCommentSchema).mutation(async ({ ctx, input }) => { const { listingId, content, parentId, recaptchaToken } = input const userId = ctx.session.user.id + // TODO: Add spam detection via `checkSpamContent` from + // `@/server/utils/spam-check` (currently only applied in mobile routes). + // Block: UX/product sign-off needed since existing web users would start + // seeing spam-block errors. Mirror mobile: `{ userId, content, entityType: 'comment' }`. // Verify CAPTCHA if token is provided if (recaptchaToken) { const clientIP = ctx.headers ? getClientIP(ctx.headers) : undefined @@ -322,13 +326,15 @@ export const commentsRouter = createTRPCRouter({ if (!comment) return ResourceError.comment.notFound() - // Check if user already voted on this comment - const existingVote = await ctx.prisma.commentVote.findUnique({ - where: { userId_commentId: { userId, commentId } }, - }) + // Fetch `existingVote` inside the transaction: two concurrent votes from + // the same user could both read null and both attempt to insert, + // producing a Prisma P2002 on the second. Keeping the read and write + // under the same isolation avoids the race. + return await ctx.prisma.$transaction(async (tx) => { + const existingVote = await tx.commentVote.findUnique({ + where: { userId_commentId: { userId, commentId } }, + }) - // Start a transaction to handle both the vote and score update - return ctx.prisma.$transaction(async (tx) => { let voteResult let scoreChange: number let trustActionNeeded: 'upvote' | 'downvote' | 'change' | 'remove' | null = null @@ -370,126 +376,26 @@ export const commentsRouter = createTRPCRouter({ data: { score: { increment: scoreChange } }, }) - // Award trust points to the comment author (not the voter) - if (comment && comment.userId !== userId) { - // Don't award points for self-votes - const trustService = new TrustService(tx) - - if (trustActionNeeded === 'upvote') { - // New upvote: +2 points to comment author - await trustService.logAction({ - userId: comment.userId, - action: TrustAction.COMMENT_RECEIVED_UPVOTE, - targetUserId: userId, - metadata: { - commentId, - voterId: userId, - listingId: comment.listingId, - }, - }) - } else if (trustActionNeeded === 'downvote') { - // New downvote: -1 point to comment author - await trustService.logAction({ - userId: comment.userId, - action: TrustAction.COMMENT_RECEIVED_DOWNVOTE, - targetUserId: userId, - metadata: { - commentId, - voterId: userId, - listingId: comment.listingId, - }, - }) - } else if (trustActionNeeded === 'change') { - // Vote changed: reverse previous and apply new - if (value) { - // Changed from downvote to upvote: +1 (reverse) +2 (new) = +3 net - await trustService.logAction({ - userId: comment.userId, - action: TrustAction.COMMENT_RECEIVED_DOWNVOTE, - targetUserId: userId, - metadata: { - commentId, - voterId: userId, - listingId: comment.listingId, - reversed: true, - }, - }) - await trustService.logAction({ - userId: comment.userId, - action: TrustAction.COMMENT_RECEIVED_UPVOTE, - targetUserId: userId, - metadata: { - commentId, - voterId: userId, - listingId: comment.listingId, - }, - }) - } else { - // Changed from upvote to downvote: -2 (reverse) -1 (new) = -3 net - await trustService.logAction({ - userId: comment.userId, - action: TrustAction.COMMENT_RECEIVED_UPVOTE, - targetUserId: userId, - metadata: { - commentId, - voterId: userId, - listingId: comment.listingId, - reversed: true, - }, - }) - await trustService.logAction({ - userId: comment.userId, - action: TrustAction.COMMENT_RECEIVED_DOWNVOTE, - targetUserId: userId, - metadata: { - commentId, - voterId: userId, - listingId: comment.listingId, - }, - }) - } - } else if (trustActionNeeded === 'remove' && existingVote) { - // Vote removed: reverse the previous vote's effect - const action = existingVote.value - ? TrustAction.COMMENT_RECEIVED_UPVOTE - : TrustAction.COMMENT_RECEIVED_DOWNVOTE - await trustService.logAction({ - userId: comment.userId, - action, - targetUserId: userId, - metadata: { - commentId, - voterId: userId, - listingId: comment.listingId, - reversed: true, - }, - }) - } - - // Check if comment has reached the helpful threshold (5+ score) - // Award bonus only when crossing the threshold for the first time - const HELPFUL_THRESHOLD = 5 - const previousScore = updatedComment.score - scoreChange - - if (previousScore < HELPFUL_THRESHOLD && updatedComment.score >= HELPFUL_THRESHOLD) { - // Comment just crossed the threshold - award bonus - await trustService.logAction({ - userId: comment.userId, - action: TrustAction.HELPFUL_COMMENT, - metadata: { - commentId, - listingId: comment.listingId, - score: updatedComment.score, - threshold: HELPFUL_THRESHOLD, - }, - }) - } + if (trustActionNeeded) { + await handleCommentVoteTrustEffects({ + tx, + trustAction: trustActionNeeded, + newValue: value, + previousValue: existingVote?.value ?? null, + commentAuthorId: comment.userId, + voterId: userId, + commentId, + parentEntityId: comment.listingId, + listingType: 'handheld', + updatedScore: updatedComment.score, + scoreChange, + }) } - // Emit notification event - if (comment) { + // Notify comment author on new votes / direction changes; skip on toggle-off. + if (comment && trustActionNeeded !== null && trustActionNeeded !== 'remove') { notificationEventEmitter.emitNotificationEvent({ - eventType: value ? NOTIFICATION_EVENTS.COMMENT_VOTED : NOTIFICATION_EVENTS.COMMENT_VOTED, + eventType: NOTIFICATION_EVENTS.COMMENT_VOTED, entityType: 'comment', entityId: comment.id, triggeredBy: ctx.session.user.id, diff --git a/src/server/api/routers/listings/core.test.ts b/src/server/api/routers/listings/core.test.ts new file mode 100644 index 00000000..3a8d5479 --- /dev/null +++ b/src/server/api/routers/listings/core.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { Role } from '@orm' + +vi.unmock('@/server/api/trpc') +vi.unmock('@/server/api/root') + +const mockApplyTrustAction = vi.fn().mockResolvedValue(undefined) +const mockHandleListingVoteTrustEffects = vi.fn().mockResolvedValue(undefined) +const mockHandleCommentVoteTrustEffects = vi.fn().mockResolvedValue(undefined) +const mockLogAction = vi.fn().mockResolvedValue(undefined) +const mockReverseLogAction = vi.fn().mockResolvedValue(undefined) + +vi.mock('@/lib/trust/service', () => ({ + applyTrustAction: (...args: unknown[]) => mockApplyTrustAction(...args), + TrustService: vi.fn().mockImplementation(() => ({ + logAction: mockLogAction, + reverseLogAction: mockReverseLogAction, + })), +})) + +vi.mock('@/server/utils/vote-trust-effects', () => ({ + handleListingVoteTrustEffects: (...args: unknown[]) => mockHandleListingVoteTrustEffects(...args), + handleCommentVoteTrustEffects: (...args: unknown[]) => mockHandleCommentVoteTrustEffects(...args), +})) + +vi.mock('@/server/utils/vote-counts', () => ({ + updateListingVoteCounts: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/server/notifications/eventEmitter', () => ({ + notificationEventEmitter: { emitNotificationEvent: vi.fn() }, + NOTIFICATION_EVENTS: { + LISTING_VOTED: 'LISTING_VOTED', + COMMENT_VOTED: 'COMMENT_VOTED', + LISTING_APPROVED: 'LISTING_APPROVED', + LISTING_REJECTED: 'LISTING_REJECTED', + COMMENT_REPLIED: 'COMMENT_REPLIED', + LISTING_COMMENTED: 'LISTING_COMMENTED', + }, +})) + +vi.mock('@/server/utils/query-builders', () => ({ + isUserBanned: vi.fn().mockResolvedValue(false), + listingWhereClause: vi.fn(() => ({})), +})) + +vi.mock('@/lib/captcha/verify', () => ({ + verifyRecaptcha: vi.fn().mockResolvedValue({ success: true }), + getClientIP: vi.fn().mockReturnValue('127.0.0.1'), +})) + +vi.mock('@/lib/analytics', () => ({ + default: { + engagement: { vote: vi.fn(), commentVote: vi.fn() }, + userJourney: { firstTimeAction: vi.fn() }, + listing: { created: vi.fn() }, + }, +})) + +vi.mock('@/server/services/audit.service', () => ({ + logAudit: vi.fn().mockResolvedValue(undefined), + buildDiff: vi.fn().mockReturnValue({}), +})) + +vi.mock('@/server/utils/security-validation', () => ({ + validatePagination: vi.fn((page, limit, max) => ({ page: page ?? 1, limit: limit ?? max ?? 20 })), + sanitizeInput: vi.fn((s: string) => s), +})) + +vi.mock('@/server/repositories/listings.repository', () => ({ + ListingsRepository: vi.fn().mockImplementation(() => ({ + getExistingVote: vi.fn().mockResolvedValue(null), + })), +})) + +const { coreRouter } = await import('./core') + +const USER_ID = '00000000-0000-4000-a000-000000000001' +const AUTHOR_ID = '00000000-0000-4000-a000-000000000002' +const LISTING_ID = '00000000-0000-4000-a000-000000000010' + +function createMockPrisma() { + const mockTx = { + vote: { + create: vi + .fn() + .mockResolvedValue({ id: 'vote-1', value: true, userId: USER_ID, listingId: LISTING_ID }), + delete: vi.fn().mockResolvedValue(undefined), + update: vi + .fn() + .mockResolvedValue({ id: 'vote-1', value: false, userId: USER_ID, listingId: LISTING_ID }), + findUnique: vi.fn().mockResolvedValue(null), + count: vi.fn().mockResolvedValue(1), + }, + listing: { + findUnique: vi.fn(), + update: vi.fn().mockResolvedValue({}), + }, + user: { + findUnique: vi.fn().mockResolvedValue({ id: USER_ID }), + }, + } + + return { + ...mockTx, + $transaction: vi.fn(async (cb: (tx: typeof mockTx) => Promise) => cb(mockTx)), + } +} + +type MockPrisma = ReturnType + +function createCaller(overrides: { userId?: string; role?: Role; prisma?: MockPrisma } = {}) { + const prisma = overrides.prisma ?? createMockPrisma() + return { + caller: coreRouter.createCaller({ + session: { + user: { + id: overrides.userId ?? USER_ID, + email: 'test@test.com', + name: 'Test User', + role: overrides.role ?? Role.USER, + permissions: [], + showNsfw: false, + }, + }, + prisma: prisma as never, + headers: new Headers(), + }), + prisma, + } +} + +describe('handheld listings trust integration (core.ts)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('vote', () => { + it('calls handleListingVoteTrustEffects with tx on new upvote', async () => { + const { caller, prisma } = createCaller() + prisma.listing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID }) + + await caller.vote({ listingId: LISTING_ID, value: true }) + + expect(mockHandleListingVoteTrustEffects).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'created', + currentValue: true, + previousValue: null, + userId: USER_ID, + listingId: LISTING_ID, + listingType: 'handheld', + authorId: AUTHOR_ID, + tx: expect.any(Object), + }), + ) + }) + + it('calls handleListingVoteTrustEffects with listingType handheld (not pc)', async () => { + const { caller, prisma } = createCaller() + prisma.listing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID }) + + await caller.vote({ listingId: LISTING_ID, value: true }) + + const firstCall = mockHandleListingVoteTrustEffects.mock.calls[0][0] as { + listingType: string + } + expect(firstCall.listingType).toBe('handheld') + }) + + it('calls handleListingVoteTrustEffects with action deleted on vote toggle', async () => { + const { caller, prisma } = createCaller() + prisma.listing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID }) + prisma.vote.findUnique.mockResolvedValue({ + id: 'vote-1', + value: true, + userId: USER_ID, + listingId: LISTING_ID, + }) + + await caller.vote({ listingId: LISTING_ID, value: true }) + + expect(mockHandleListingVoteTrustEffects).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'deleted', + previousValue: true, + listingType: 'handheld', + tx: expect.any(Object), + }), + ) + }) + + it('calls handleListingVoteTrustEffects with action updated on vote change', async () => { + const { caller, prisma } = createCaller() + prisma.listing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID }) + prisma.vote.findUnique.mockResolvedValue({ + id: 'vote-1', + value: true, + userId: USER_ID, + listingId: LISTING_ID, + }) + + await caller.vote({ listingId: LISTING_ID, value: false }) // change to downvote + + expect(mockHandleListingVoteTrustEffects).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'updated', + currentValue: false, + previousValue: true, + listingType: 'handheld', + tx: expect.any(Object), + }), + ) + }) + + it('passes the transaction client to handleListingVoteTrustEffects', async () => { + const { caller, prisma } = createCaller() + prisma.listing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID }) + + await caller.vote({ listingId: LISTING_ID, value: true }) + + expect(prisma.$transaction).toHaveBeenCalledTimes(1) + + const trustCall = mockHandleListingVoteTrustEffects.mock.calls[0][0] as { + tx: unknown + } + // The tx passed should be the same mockTx object passed to the transaction callback + expect(trustCall.tx).toBeDefined() + expect(trustCall.tx).toHaveProperty('vote') + expect(trustCall.tx).toHaveProperty('listing') + }) + }) +}) diff --git a/src/server/api/routers/listings/core.ts b/src/server/api/routers/listings/core.ts index 4200d69e..5bc6d54f 100644 --- a/src/server/api/routers/listings/core.ts +++ b/src/server/api/routers/listings/core.ts @@ -32,7 +32,7 @@ import { isUserBanned } from '@/server/utils/query-builders' import { sanitizeInput, validatePagination } from '@/server/utils/security-validation' import { withSavepoint } from '@/server/utils/transactions' import { updateListingVoteCounts } from '@/server/utils/vote-counts' -import { handleVoteTrustEffects } from '@/server/utils/vote-trust-effects' +import { handleListingVoteTrustEffects } from '@/server/utils/vote-trust-effects' import { roleIncludesRole } from '@/utils/permission-system' import { ms } from '@/utils/time' import { ApprovalStatus, Prisma, Role, TrustAction } from '@orm' @@ -145,6 +145,10 @@ export const coreRouter = createTRPCRouter({ const { recaptchaToken, ...payload } = input const authorId = ctx.session.user.id + // TODO: Add spam detection via `checkSpamContent` from + // `@/server/utils/spam-check` (currently only applied in mobile routes). + // Block: UX/product sign-off needed since existing web users would start + // seeing spam-block errors. Mirror mobile: `{ userId, content: notes, entityType: 'listing' }`. // Verify CAPTCHA if token is provided if (recaptchaToken) { const clientIP = ctx.headers ? getClientIP(ctx.headers) : undefined @@ -269,6 +273,12 @@ export const coreRouter = createTRPCRouter({ where: { userId_listingId: { userId, listingId: input.listingId } }, }) + let result: { + vote: { id: string; value: boolean; userId: string; listingId: string } | null + action: 'created' | 'updated' | 'deleted' + previousValue: boolean | null + } + if (!existingVote) { // Create new vote const newVote = await tx.vote.create({ @@ -277,39 +287,44 @@ export const coreRouter = createTRPCRouter({ await updateListingVoteCounts(tx, input.listingId, 'create', input.value) - return { vote: newVote, action: 'created' as const, previousValue: null } - } - - // If value is the same, remove the vote (toggle) - if (existingVote.value === input.value) { + result = { vote: newVote, action: 'created', previousValue: null } + } else if (existingVote.value === input.value) { await tx.vote.delete({ where: { userId_listingId: { userId, listingId: input.listingId } }, }) await updateListingVoteCounts(tx, input.listingId, 'delete', undefined, existingVote.value) - return { vote: null, action: 'deleted' as const, previousValue: existingVote.value } - } + result = { vote: null, action: 'deleted', previousValue: existingVote.value } + } else { + const updatedVote = await tx.vote.update({ + where: { userId_listingId: { userId, listingId: input.listingId } }, + data: { value: input.value }, + }) - // Update vote to new value - const updatedVote = await tx.vote.update({ - where: { userId_listingId: { userId, listingId: input.listingId } }, - data: { value: input.value }, - }) + await updateListingVoteCounts( + tx, + input.listingId, + 'update', + input.value, + existingVote.value, + ) - await updateListingVoteCounts(tx, input.listingId, 'update', input.value, existingVote.value) + result = { vote: updatedVote, action: 'updated', previousValue: existingVote.value } + } - return { vote: updatedVote, action: 'updated' as const, previousValue: existingVote.value } - }) + await handleListingVoteTrustEffects({ + tx, + action: result.action, + currentValue: input.value, + previousValue: result.previousValue, + userId, + listingId: input.listingId, + listingType: 'handheld', + authorId: listing.authorId, + }) - // Handle trust effects for all vote actions - await handleVoteTrustEffects({ - action: voteResult.action, - currentValue: input.value, - previousValue: voteResult.previousValue, - userId, - listingId: input.listingId, - authorId: listing.authorId, + return result }) // Handle post-transaction side effects for created/updated votes (TODO: consider abstracting) @@ -342,7 +357,7 @@ export const coreRouter = createTRPCRouter({ } } - return voteResult.vote || { id: '', value: false, listingId: input.listingId, userId } + return voteResult.vote }), performanceScales: publicProcedure.query(async ({ ctx }) => diff --git a/src/server/api/routers/mobile/listings.test.ts b/src/server/api/routers/mobile/listings.test.ts new file mode 100644 index 00000000..d3d24cf5 --- /dev/null +++ b/src/server/api/routers/mobile/listings.test.ts @@ -0,0 +1,370 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { Role } from '@orm' + +vi.unmock('@/server/api/trpc') +vi.unmock('@/server/api/root') + +// Stub the api-keys schema so it doesn't pull unmocked enums from @orm +vi.mock('@/schemas/apiAccess', () => ({ + GetApiKeyUsageSchema: {}, + CreateApiKeySchema: {}, + UpdateApiKeySchema: {}, + RevokeApiKeySchema: {}, + ListApiKeysSchema: {}, +})) + +vi.mock('@/server/repositories/api-keys.repository', () => ({ + ApiKeysRepository: vi.fn().mockImplementation(() => ({})), +})) + +const mockApplyTrustAction = vi.fn().mockResolvedValue(undefined) +const mockHandleListingVoteTrustEffects = vi.fn().mockResolvedValue(undefined) +const mockHandleCommentVoteTrustEffects = vi.fn().mockResolvedValue(undefined) +const mockLogAction = vi.fn().mockResolvedValue(undefined) + +vi.mock('@/lib/trust/service', () => ({ + applyTrustAction: (...args: unknown[]) => mockApplyTrustAction(...args), + TrustService: vi.fn().mockImplementation(() => ({ logAction: mockLogAction })), +})) + +vi.mock('@/server/utils/vote-trust-effects', () => ({ + handleListingVoteTrustEffects: (...args: unknown[]) => mockHandleListingVoteTrustEffects(...args), + handleCommentVoteTrustEffects: (...args: unknown[]) => mockHandleCommentVoteTrustEffects(...args), +})) + +vi.mock('@/server/utils/vote-counts', () => ({ + updateListingVoteCounts: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/lib/analytics', () => ({ + default: { + engagement: { vote: vi.fn(), commentVote: vi.fn() }, + }, +})) + +vi.mock('@/server/repositories/comments.repository', () => ({ + CommentsRepository: vi.fn().mockImplementation(() => ({ + listByListing: vi.fn().mockResolvedValue([]), + })), +})) + +const mockEmitNotificationEvent = vi.fn() +vi.mock('@/server/notifications/eventEmitter', () => ({ + notificationEventEmitter: { emitNotificationEvent: mockEmitNotificationEvent }, + NOTIFICATION_EVENTS: { + LISTING_VOTED: 'LISTING_VOTED', + COMMENT_VOTED: 'COMMENT_VOTED', + }, +})) + +const { mobileListingsRouter } = await import('./listings') + +const USER_ID = '00000000-0000-4000-a000-000000000001' +const AUTHOR_ID = '00000000-0000-4000-a000-000000000002' +const LISTING_ID = '00000000-0000-4000-a000-000000000010' +const COMMENT_ID = '00000000-0000-4000-a000-000000000020' + +function createMockPrisma() { + const mockTx = { + vote: { + create: vi + .fn() + .mockResolvedValue({ id: 'vote-1', value: true, userId: USER_ID, listingId: LISTING_ID }), + delete: vi.fn().mockResolvedValue(undefined), + update: vi + .fn() + .mockResolvedValue({ id: 'vote-1', value: false, userId: USER_ID, listingId: LISTING_ID }), + findUnique: vi.fn().mockResolvedValue(null), + }, + commentVote: { + create: vi.fn().mockResolvedValue({ userId: USER_ID, commentId: COMMENT_ID, value: true }), + delete: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue({ userId: USER_ID, commentId: COMMENT_ID, value: false }), + findUnique: vi.fn().mockResolvedValue(null), + }, + comment: { + findUnique: vi.fn(), + update: vi.fn().mockResolvedValue({ id: COMMENT_ID, score: 1 }), + }, + listing: { + findUnique: vi.fn(), + }, + } + return { + ...mockTx, + $transaction: vi.fn(async (cb: (tx: typeof mockTx) => Promise) => cb(mockTx)), + } +} + +type MockPrisma = ReturnType + +function createCaller(overrides: { userId?: string; role?: Role; prisma?: MockPrisma } = {}) { + const prisma = overrides.prisma ?? createMockPrisma() + return { + caller: mobileListingsRouter.createCaller({ + session: { + user: { + id: overrides.userId ?? USER_ID, + email: 'test@test.com', + name: 'Test User', + role: overrides.role ?? Role.USER, + permissions: [], + showNsfw: false, + }, + }, + prisma: prisma as never, + headers: new Headers(), + apiKey: null, + }), + prisma, + } +} + +describe('mobile listings trust integration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('vote', () => { + it('calls handleListingVoteTrustEffects with tx on new upvote', async () => { + const { caller, prisma } = createCaller() + prisma.listing.findUnique.mockResolvedValue({ authorId: AUTHOR_ID }) + + await caller.vote({ listingId: LISTING_ID, value: true }) + + expect(mockHandleListingVoteTrustEffects).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'created', + currentValue: true, + previousValue: null, + userId: USER_ID, + listingId: LISTING_ID, + listingType: 'handheld', + authorId: AUTHOR_ID, + tx: expect.any(Object), + }), + ) + }) + + it('calls handleListingVoteTrustEffects with action deleted on vote toggle', async () => { + const { caller, prisma } = createCaller() + prisma.listing.findUnique.mockResolvedValue({ authorId: AUTHOR_ID }) + prisma.vote.findUnique.mockResolvedValue({ + id: 'vote-1', + value: true, + userId: USER_ID, + listingId: LISTING_ID, + }) + + await caller.vote({ listingId: LISTING_ID, value: true }) + + expect(mockHandleListingVoteTrustEffects).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'deleted', + previousValue: true, + listingType: 'handheld', + tx: expect.any(Object), + }), + ) + }) + + it('calls handleListingVoteTrustEffects with action updated on vote change', async () => { + const { caller, prisma } = createCaller() + prisma.listing.findUnique.mockResolvedValue({ authorId: AUTHOR_ID }) + prisma.vote.findUnique.mockResolvedValue({ + id: 'vote-1', + value: true, + userId: USER_ID, + listingId: LISTING_ID, + }) + + await caller.vote({ listingId: LISTING_ID, value: false }) + + expect(mockHandleListingVoteTrustEffects).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'updated', + currentValue: false, + previousValue: true, + tx: expect.any(Object), + }), + ) + }) + + it('runs trust call inside the transaction', async () => { + const { caller, prisma } = createCaller() + prisma.listing.findUnique.mockResolvedValue({ authorId: AUTHOR_ID }) + + await caller.vote({ listingId: LISTING_ID, value: true }) + + expect(prisma.$transaction).toHaveBeenCalledTimes(1) + const trustCall = mockHandleListingVoteTrustEffects.mock.calls[0][0] as { tx: unknown } + expect(trustCall.tx).toHaveProperty('vote') + }) + + it('emits LISTING_VOTED on a new vote', async () => { + const { caller, prisma } = createCaller() + prisma.listing.findUnique.mockResolvedValue({ authorId: AUTHOR_ID }) + + await caller.vote({ listingId: LISTING_ID, value: true }) + + expect(mockEmitNotificationEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'LISTING_VOTED', + entityType: 'listing', + entityId: LISTING_ID, + payload: expect.objectContaining({ voteValue: true, listingId: LISTING_ID }), + }), + ) + }) + + it('does NOT emit LISTING_VOTED on toggle-off', async () => { + const { caller, prisma } = createCaller() + prisma.listing.findUnique.mockResolvedValue({ authorId: AUTHOR_ID }) + prisma.vote.findUnique.mockResolvedValue({ + id: 'vote-1', + value: true, + userId: USER_ID, + listingId: LISTING_ID, + }) + + await caller.vote({ listingId: LISTING_ID, value: true }) + + expect(mockEmitNotificationEvent).not.toHaveBeenCalled() + }) + }) + + describe('voteComment', () => { + it('calls handleCommentVoteTrustEffects with listingType handheld on new upvote', async () => { + const { caller, prisma } = createCaller() + prisma.comment.findUnique.mockResolvedValue({ + id: COMMENT_ID, + userId: AUTHOR_ID, + listingId: LISTING_ID, + }) + prisma.commentVote.findUnique.mockResolvedValue(null) + + await caller.voteComment({ commentId: COMMENT_ID, value: true }) + + expect(mockHandleCommentVoteTrustEffects).toHaveBeenCalledWith( + expect.objectContaining({ + trustAction: 'upvote', + newValue: true, + commentAuthorId: AUTHOR_ID, + voterId: USER_ID, + commentId: COMMENT_ID, + parentEntityId: LISTING_ID, + listingType: 'handheld', + }), + ) + }) + + it('calls handleCommentVoteTrustEffects with change action on vote flip', async () => { + const { caller, prisma } = createCaller() + prisma.comment.findUnique.mockResolvedValue({ + id: COMMENT_ID, + userId: AUTHOR_ID, + listingId: LISTING_ID, + }) + prisma.commentVote.findUnique.mockResolvedValue({ + userId: USER_ID, + commentId: COMMENT_ID, + value: true, + }) + + await caller.voteComment({ commentId: COMMENT_ID, value: false }) + + expect(mockHandleCommentVoteTrustEffects).toHaveBeenCalledWith( + expect.objectContaining({ + trustAction: 'change', + newValue: false, + previousValue: true, + listingType: 'handheld', + }), + ) + }) + + it('calls handleCommentVoteTrustEffects with remove action on vote toggle', async () => { + const { caller, prisma } = createCaller() + prisma.comment.findUnique.mockResolvedValue({ + id: COMMENT_ID, + userId: AUTHOR_ID, + listingId: LISTING_ID, + }) + prisma.commentVote.findUnique.mockResolvedValue({ + userId: USER_ID, + commentId: COMMENT_ID, + value: true, + }) + + await caller.voteComment({ commentId: COMMENT_ID, value: true }) + + expect(mockHandleCommentVoteTrustEffects).toHaveBeenCalledWith( + expect.objectContaining({ + trustAction: 'remove', + listingType: 'handheld', + }), + ) + }) + + it('emits COMMENT_VOTED on a new upvote', async () => { + const { caller, prisma } = createCaller() + prisma.comment.findUnique.mockResolvedValue({ + id: COMMENT_ID, + userId: AUTHOR_ID, + listingId: LISTING_ID, + }) + prisma.commentVote.findUnique.mockResolvedValue(null) + + await caller.voteComment({ commentId: COMMENT_ID, value: true }) + + expect(mockEmitNotificationEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'COMMENT_VOTED', + entityType: 'comment', + entityId: COMMENT_ID, + payload: expect.objectContaining({ + commentId: COMMENT_ID, + listingId: LISTING_ID, + voteValue: true, + }), + }), + ) + }) + + it('does NOT emit COMMENT_VOTED on toggle-off (remove)', async () => { + const { caller, prisma } = createCaller() + prisma.comment.findUnique.mockResolvedValue({ + id: COMMENT_ID, + userId: AUTHOR_ID, + listingId: LISTING_ID, + }) + prisma.commentVote.findUnique.mockResolvedValue({ + userId: USER_ID, + commentId: COMMENT_ID, + value: true, + }) + + await caller.voteComment({ commentId: COMMENT_ID, value: true }) + + expect(mockEmitNotificationEvent).not.toHaveBeenCalled() + }) + + it('fetches existingVote inside the $transaction callback', async () => { + const { caller, prisma } = createCaller() + prisma.comment.findUnique.mockResolvedValue({ + id: COMMENT_ID, + userId: AUTHOR_ID, + listingId: LISTING_ID, + }) + prisma.commentVote.findUnique.mockResolvedValue(null) + + await caller.voteComment({ commentId: COMMENT_ID, value: true }) + + const txCall = vi.mocked(prisma.$transaction).mock.invocationCallOrder[0] + const findUniqueCall = prisma.commentVote.findUnique.mock.invocationCallOrder[0] + expect(txCall).toBeDefined() + expect(findUniqueCall).toBeDefined() + expect(findUniqueCall).toBeGreaterThan(txCall as number) + }) + }) +}) diff --git a/src/server/api/routers/mobile/listings.ts b/src/server/api/routers/mobile/listings.ts index ffe38d6e..0312b56e 100644 --- a/src/server/api/routers/mobile/listings.ts +++ b/src/server/api/routers/mobile/listings.ts @@ -1,6 +1,5 @@ import analytics from '@/lib/analytics' import { AppError, ResourceError } from '@/lib/errors' -import { TrustService } from '@/lib/trust/service' import { CreateListingSchema } from '@/schemas/listing' import { CreateCommentSchema, @@ -26,6 +25,7 @@ import { mobileProtectedProcedure, mobilePublicProcedure, } from '@/server/api/mobileContext' +import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifications/eventEmitter' import { CommentsRepository } from '@/server/repositories/comments.repository' import { ListingsRepository } from '@/server/repositories/listings.repository' import { getDriverVersions } from '@/server/utils/driver-versions' @@ -34,10 +34,14 @@ import { detectEmulatorConfigType, generateEmulatorConfig, } from '@/server/utils/emulator-config/emulator-detector' -import { SpamDetectionService } from '@/server/utils/spamDetection' +import { checkSpamContent } from '@/server/utils/spam-check' import { updateListingVoteCounts } from '@/server/utils/vote-counts' +import { + handleCommentVoteTrustEffects, + handleListingVoteTrustEffects, +} from '@/server/utils/vote-trust-effects' import { isModerator } from '@/utils/permissions' -import { ApprovalStatus, Prisma, TrustAction, type PrismaClient, type Role } from '@orm' +import { ApprovalStatus, Prisma, type PrismaClient, type Role } from '@orm' // Helper for getting listings using the repository async function getListingsHelper( @@ -144,28 +148,13 @@ export const mobileListingsRouter = createMobileTRPCRouter({ const { ...payload } = input const repository = new ListingsRepository(ctx.prisma) - // Spam detection - const spamDetector = new SpamDetectionService(ctx.prisma) - const spamResult = await spamDetector.detectSpam({ + await checkSpamContent({ + prisma: ctx.prisma, userId: ctx.session.user.id, content: payload.notes || '', entityType: 'listing', }) - if (spamResult.isSpam) { - // Track spam detection in analytics - analytics.contentQuality.spamDetected({ - entityType: 'listing', - entityId: ctx.session.user.id, // Use userId as entityId since listing not created yet - confidence: spamResult.confidence, - method: spamResult.method, - }) - - throw AppError.badRequest( - `Spam detected: ${spamResult.reason || 'Your content appears to be spam. Please review our community guidelines.'}`, - ) - } - return await repository.create({ authorId: ctx.session.user.id, userRole: ctx.session.user.role, @@ -239,68 +228,89 @@ export const mobileListingsRouter = createMobileTRPCRouter({ * Vote on a listing */ vote: mobileProtectedProcedure.input(VoteListingSchema).mutation(async ({ ctx, input }) => { - return await ctx.prisma.$transaction(async (tx) => { - // Check if user already voted - const existingVote = await tx.vote.findUnique({ - where: { - userId_listingId: { - userId: ctx.session.user.id, - listingId: input.listingId, - }, - }, - }) + const userId = ctx.session.user.id - let vote + const listing = await ctx.prisma.listing.findUnique({ + where: { id: input.listingId }, + select: { authorId: true }, + }) - if (existingVote) { - if (existingVote.value === input.value) { - // Same vote = toggle off (remove vote) - await tx.vote.delete({ - where: { id: existingVote.id }, - }) + if (!listing) return ResourceError.listing.notFound() - // Update counts for deletion - await updateListingVoteCounts( - tx, - input.listingId, - 'delete', - undefined, - existingVote.value, - ) + const voteResult = await ctx.prisma.$transaction(async (tx) => { + const existingVote = await tx.vote.findUnique({ + where: { userId_listingId: { userId, listingId: input.listingId } }, + }) - return { id: existingVote.id, value: null, removed: true } - } else { - // Different vote = update - vote = await tx.vote.update({ - where: { id: existingVote.id }, - data: { value: input.value }, - }) + let result: { + vote: + | { id: string; value: boolean; userId: string; listingId: string } + | { id: string; value: null; removed: true } + action: 'created' | 'updated' | 'deleted' + previousValue: boolean | null + } - // Update counts for change - await updateListingVoteCounts( - tx, - input.listingId, - 'update', - input.value, - existingVote.value, - ) + if (!existingVote) { + const vote = await tx.vote.create({ + data: { value: input.value, listingId: input.listingId, userId }, + }) + await updateListingVoteCounts(tx, input.listingId, 'create', input.value) + result = { vote, action: 'created', previousValue: null } + } else if (existingVote.value === input.value) { + await tx.vote.delete({ where: { id: existingVote.id } }) + await updateListingVoteCounts(tx, input.listingId, 'delete', undefined, existingVote.value) + result = { + vote: { id: existingVote.id, value: null, removed: true }, + action: 'deleted', + previousValue: existingVote.value, } } else { - // New vote - vote = await tx.vote.create({ - data: { - value: input.value, + const vote = await tx.vote.update({ + where: { id: existingVote.id }, + data: { value: input.value }, + }) + await updateListingVoteCounts( + tx, + input.listingId, + 'update', + input.value, + existingVote.value, + ) + result = { vote, action: 'updated', previousValue: existingVote.value } + } + + await handleListingVoteTrustEffects({ + tx, + action: result.action, + currentValue: input.value, + previousValue: result.previousValue, + userId, + listingId: input.listingId, + listingType: 'handheld', + authorId: listing.authorId, + }) + + return result + }) + + // Notify listing author on new votes / direction changes; skip on toggle-off. + if (voteResult.action === 'created' || voteResult.action === 'updated') { + if (voteResult.vote && 'id' in voteResult.vote) { + notificationEventEmitter.emitNotificationEvent({ + eventType: NOTIFICATION_EVENTS.LISTING_VOTED, + entityType: 'listing', + entityId: input.listingId, + triggeredBy: userId, + payload: { listingId: input.listingId, - userId: ctx.session.user.id, + voteId: voteResult.vote.id, + voteValue: input.value, }, }) - - // Update counts for new vote - await updateListingVoteCounts(tx, input.listingId, 'create', input.value) } + } - return vote - }) + return voteResult.vote }), /** @@ -371,28 +381,13 @@ export const mobileListingsRouter = createMobileTRPCRouter({ .mutation(async ({ ctx, input }) => { const { listingId, content, parentId } = input - // Spam detection - const spamDetector = new SpamDetectionService(ctx.prisma) - const spamResult = await spamDetector.detectSpam({ + await checkSpamContent({ + prisma: ctx.prisma, userId: ctx.session.user.id, content, entityType: 'comment', }) - if (spamResult.isSpam) { - // Track spam detection in analytics - analytics.contentQuality.spamDetected({ - entityType: 'comment', - entityId: ctx.session.user.id, // Use userId as entityId since comment not created yet - confidence: spamResult.confidence, - method: spamResult.method, - }) - - throw AppError.badRequest( - `Spam detected: ${spamResult.reason || 'Your content appears to be spam. Please review our community guidelines.'}`, - ) - } - // If replying to a comment, validate parent existence and ownership if (parentId) { const parent = await ctx.prisma.comment.findUnique({ @@ -526,11 +521,15 @@ export const mobileListingsRouter = createMobileTRPCRouter({ const comment = await ctx.prisma.comment.findUnique({ where: { id: input.commentId } }) if (!comment) return ResourceError.comment.notFound() - const existingVote = await ctx.prisma.commentVote.findUnique({ - where: { userId_commentId: { userId, commentId: input.commentId } }, - }) + // Fetch `existingVote` inside the transaction: two concurrent votes + // from the same user could both read null and both attempt to insert, + // producing a Prisma P2002 on the second. Keeping the read and write + // under the same isolation avoids the race. + return await ctx.prisma.$transaction(async (tx) => { + const existingVote = await tx.commentVote.findUnique({ + where: { userId_commentId: { userId, commentId: input.commentId } }, + }) - return ctx.prisma.$transaction(async (tx) => { let voteResult: unknown let scoreChange: number let trustActionNeeded: 'upvote' | 'downvote' | 'change' | 'remove' | null @@ -568,110 +567,37 @@ export const mobileListingsRouter = createMobileTRPCRouter({ data: { score: { increment: scoreChange } }, }) - // Award trust points to the comment author (not the voter) - if (comment && comment.userId !== userId) { - const trustService = new TrustService(tx) - - if (trustActionNeeded === 'upvote') { - await trustService.logAction({ - userId: comment.userId, - action: TrustAction.COMMENT_RECEIVED_UPVOTE, - targetUserId: userId, - metadata: { - commentId: input.commentId, - voterId: userId, - listingId: comment.listingId, - }, - }) - } else if (trustActionNeeded === 'downvote') { - await trustService.logAction({ - userId: comment.userId, - action: TrustAction.COMMENT_RECEIVED_DOWNVOTE, - targetUserId: userId, - metadata: { - commentId: input.commentId, - voterId: userId, - listingId: comment.listingId, - }, - }) - } else if (trustActionNeeded === 'change') { - if (input.value) { - await trustService.logAction({ - userId: comment.userId, - action: TrustAction.COMMENT_RECEIVED_DOWNVOTE, - targetUserId: userId, - metadata: { - commentId: input.commentId, - voterId: userId, - listingId: comment.listingId, - reversed: true, - }, - }) - await trustService.logAction({ - userId: comment.userId, - action: TrustAction.COMMENT_RECEIVED_UPVOTE, - targetUserId: userId, - metadata: { - commentId: input.commentId, - voterId: userId, - listingId: comment.listingId, - }, - }) - } else { - await trustService.logAction({ - userId: comment.userId, - action: TrustAction.COMMENT_RECEIVED_UPVOTE, - targetUserId: userId, - metadata: { - commentId: input.commentId, - voterId: userId, - listingId: comment.listingId, - reversed: true, - }, - }) - await trustService.logAction({ - userId: comment.userId, - action: TrustAction.COMMENT_RECEIVED_DOWNVOTE, - targetUserId: userId, - metadata: { - commentId: input.commentId, - voterId: userId, - listingId: comment.listingId, - }, - }) - } - } else if (trustActionNeeded === 'remove' && existingVote) { - const action = existingVote.value - ? TrustAction.COMMENT_RECEIVED_UPVOTE - : TrustAction.COMMENT_RECEIVED_DOWNVOTE - await trustService.logAction({ - userId: comment.userId, - action, - targetUserId: userId, - metadata: { - commentId: input.commentId, - voterId: userId, - listingId: comment.listingId, - reversed: true, - }, - }) - } + if (trustActionNeeded) { + await handleCommentVoteTrustEffects({ + tx, + trustAction: trustActionNeeded, + newValue: !!input.value, + previousValue: existingVote?.value ?? null, + commentAuthorId: comment.userId, + voterId: userId, + commentId: input.commentId, + parentEntityId: comment.listingId, + listingType: 'handheld', + updatedScore: updatedComment.score, + scoreChange, + }) + } - // Helpful threshold bonus - const HELPFUL_THRESHOLD = 5 - const previousScore = updatedComment.score - scoreChange - if (previousScore < HELPFUL_THRESHOLD && updatedComment.score >= HELPFUL_THRESHOLD) { - await trustService.logAction({ - userId: comment.userId, - action: TrustAction.HELPFUL_COMMENT, - metadata: { - commentId: input.commentId, - listingId: comment.listingId, - score: updatedComment.score, - threshold: HELPFUL_THRESHOLD, - }, - }) - } + // Notify comment author on new votes / direction changes; skip on toggle-off. + // When trustActionNeeded is upvote/downvote/change, input.value is guaranteed non-null + // (null input.value always maps to 'remove' in the branching above). + if (trustActionNeeded !== null && trustActionNeeded !== 'remove' && input.value !== null) { + notificationEventEmitter.emitNotificationEvent({ + eventType: NOTIFICATION_EVENTS.COMMENT_VOTED, + entityType: 'comment', + entityId: comment.id, + triggeredBy: userId, + payload: { + listingId: comment.listingId, + commentId: comment.id, + voteValue: input.value, + }, + }) } // Analytics diff --git a/src/server/api/routers/mobile/pcListings.ts b/src/server/api/routers/mobile/pcListings.ts index c549222c..afa8496a 100644 --- a/src/server/api/routers/mobile/pcListings.ts +++ b/src/server/api/routers/mobile/pcListings.ts @@ -1,4 +1,5 @@ import { ResourceError } from '@/lib/errors' +import { applyTrustAction } from '@/lib/trust/service' import { GetCpusSchema, GetGpusSchema, @@ -21,7 +22,7 @@ import { PcListingsRepository } from '@/server/repositories/pc-listings.reposito import { listingStatsCache } from '@/server/utils/cache' import { paginate } from '@/server/utils/pagination' import { isModerator } from '@/utils/permissions' -import { Prisma, ApprovalStatus } from '@orm' +import { Prisma, ApprovalStatus, TrustAction } from '@orm' export const mobilePcListingsRouter = createMobileTRPCRouter({ /** @@ -166,6 +167,12 @@ export const mobilePcListingsRouter = createMobileTRPCRouter({ : null) as { customFieldDefinitionId: string; value: unknown }[] | null, }) + await applyTrustAction({ + userId: ctx.session.user.id, + action: TrustAction.LISTING_CREATED, + context: { pcListingId: created.id }, + }) + // Invalidate stats cache listingStatsCache.delete('pc-listing-stats') diff --git a/src/server/api/routers/pcListings.test.ts b/src/server/api/routers/pcListings.test.ts new file mode 100644 index 00000000..6aeb4af9 --- /dev/null +++ b/src/server/api/routers/pcListings.test.ts @@ -0,0 +1,627 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { ApprovalStatus, Role, TrustAction } from '@orm' + +vi.unmock('@/server/api/trpc') +vi.unmock('@/server/api/root') + +const mockApplyTrustAction = vi.fn().mockResolvedValue(undefined) +const mockHandleListingVoteTrustEffects = vi.fn().mockResolvedValue(undefined) +const mockHandleCommentVoteTrustEffects = vi.fn().mockResolvedValue(undefined) +const mockLogAction = vi.fn().mockResolvedValue(undefined) + +vi.mock('@/lib/trust/service', () => ({ + applyTrustAction: (...args: unknown[]) => mockApplyTrustAction(...args), + TrustService: vi.fn().mockImplementation(() => ({ logAction: mockLogAction })), +})) + +vi.mock('@/server/utils/vote-trust-effects', () => ({ + handleListingVoteTrustEffects: (...args: unknown[]) => mockHandleListingVoteTrustEffects(...args), + handleCommentVoteTrustEffects: (...args: unknown[]) => mockHandleCommentVoteTrustEffects(...args), + handleVoteTrustEffects: vi.fn(), +})) + +vi.mock('@/server/utils/vote-counts', () => ({ + updatePcListingVoteCounts: vi.fn().mockResolvedValue(undefined), +})) + +const mockEmitNotificationEvent = vi.fn() +vi.mock('@/server/notifications/eventEmitter', () => ({ + notificationEventEmitter: { emitNotificationEvent: mockEmitNotificationEvent }, + NOTIFICATION_EVENTS: { + LISTING_VOTED: 'LISTING_VOTED', + COMMENT_VOTED: 'COMMENT_VOTED', + PC_LISTING_APPROVED: 'PC_LISTING_APPROVED', + PC_LISTING_REJECTED: 'PC_LISTING_REJECTED', + }, +})) + +const mockVerifyRecaptcha = vi.fn().mockResolvedValue({ success: true }) +vi.mock('@/lib/captcha/verify', () => ({ + verifyRecaptcha: (...args: unknown[]) => mockVerifyRecaptcha(...args), + getClientIP: vi.fn().mockReturnValue('127.0.0.1'), +})) + +vi.mock('@/lib/captcha/config', () => ({ + RECAPTCHA_CONFIG: { actions: { VOTE: 'VOTE' } }, +})) + +vi.mock('@/server/utils/query-builders', () => ({ + isUserBanned: vi.fn().mockResolvedValue(false), +})) + +vi.mock('@/server/utils/cache', () => ({ + listingStatsCache: { delete: vi.fn() }, +})) + +vi.mock('@/server/cache/invalidation', () => ({ + invalidateListPages: vi.fn().mockResolvedValue(undefined), + invalidateSitemap: vi.fn().mockResolvedValue(undefined), + revalidateByTag: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/lib/analytics', () => ({ + default: { + engagement: { vote: vi.fn(), commentVote: vi.fn() }, + listing: { created: vi.fn() }, + }, +})) + +vi.mock('@/server/services/audit.service', () => ({ + logAudit: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/server/services/author-risk.service', () => ({ + computeAuthorRiskProfiles: vi.fn().mockResolvedValue(new Map()), +})) + +vi.mock('@/server/api/utils/pinPermissions', () => ({ + canManageCommentPins: vi.fn().mockReturnValue(false), +})) + +vi.mock('@/server/utils/security-validation', () => ({ + validatePagination: vi.fn((page, limit, max) => ({ page: page ?? 1, limit: limit ?? max ?? 20 })), +})) + +const mockRepositoryCreate = vi.fn() +const mockRepositoryGetById = vi.fn() +const mockRepositoryApprove = vi.fn() +const mockRepositoryReject = vi.fn() +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), + })), +})) + +vi.mock('@/server/repositories/user-pc-presets.repository', () => ({ + UserPcPresetsRepository: vi.fn().mockImplementation(() => ({})), +})) + +const { pcListingsRouter } = await import('./pcListings') + +const USER_ID = '00000000-0000-4000-a000-000000000001' +const AUTHOR_ID = '00000000-0000-4000-a000-000000000002' +const ADMIN_ID = '00000000-0000-4000-a000-000000000003' +const LISTING_ID = '00000000-0000-4000-a000-000000000010' +const COMMENT_ID = '00000000-0000-4000-a000-000000000020' + +function createMockPrisma() { + const mockTx = { + pcListingVote: { + create: vi.fn().mockResolvedValue({ userId: USER_ID, pcListingId: LISTING_ID, value: true }), + delete: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue({ userId: USER_ID, pcListingId: LISTING_ID, value: false }), + findUnique: vi.fn().mockResolvedValue(null), + }, + pcListingCommentVote: { + create: vi.fn().mockResolvedValue({ userId: USER_ID, commentId: COMMENT_ID, value: true }), + delete: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue({ userId: USER_ID, commentId: COMMENT_ID, value: false }), + findUnique: vi.fn().mockResolvedValue(null), + }, + pcListingComment: { + findUnique: vi.fn(), + update: vi.fn().mockResolvedValue({ id: COMMENT_ID, score: 1 }), + }, + pcListing: { + findUnique: vi.fn(), + findMany: vi.fn().mockResolvedValue([]), + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + } + + return { + ...mockTx, + $transaction: vi.fn(async (cb: (tx: typeof mockTx) => Promise) => cb(mockTx)), + } +} + +type MockPrisma = ReturnType + +function createCaller(overrides: { userId?: string; role?: Role; prisma?: MockPrisma } = {}) { + const prisma = overrides.prisma ?? createMockPrisma() + return { + caller: pcListingsRouter.createCaller({ + session: { + user: { + id: overrides.userId ?? USER_ID, + email: 'test@test.com', + name: 'Test User', + role: overrides.role ?? Role.USER, + permissions: [], + showNsfw: false, + }, + }, + prisma: prisma as never, + headers: new Headers(), + }), + prisma, + } +} + +describe('pcListings trust integration', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRepositoryGetExistingVote.mockResolvedValue(null) + }) + + describe('vote', () => { + it('calls handleListingVoteTrustEffects with listingType pc on new vote', async () => { + const { caller, prisma } = createCaller() + prisma.pcListing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID }) + await caller.vote({ pcListingId: LISTING_ID, value: true }) + + expect(mockHandleListingVoteTrustEffects).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'created', + currentValue: true, + previousValue: null, + userId: USER_ID, + listingId: LISTING_ID, + listingType: 'pc', + authorId: AUTHOR_ID, + tx: expect.any(Object), + }), + ) + }) + + it('calls handleListingVoteTrustEffects with action deleted on vote toggle', async () => { + const { caller, prisma } = createCaller() + prisma.pcListing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID }) + // Simulate an existing upvote inside the transaction. + prisma.pcListingVote.findUnique.mockResolvedValue({ + userId: USER_ID, + pcListingId: LISTING_ID, + value: true, + }) + + await caller.vote({ pcListingId: LISTING_ID, value: true }) + + expect(mockHandleListingVoteTrustEffects).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'deleted', + previousValue: true, + listingType: 'pc', + }), + ) + }) + + it('fetches existingVote via the transaction client, not the repository', async () => { + const { caller, prisma } = createCaller() + prisma.pcListing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID }) + + await caller.vote({ pcListingId: LISTING_ID, value: true }) + + expect(prisma.pcListingVote.findUnique).toHaveBeenCalledWith({ + where: { userId_pcListingId: { userId: USER_ID, pcListingId: LISTING_ID } }, + }) + expect(mockRepositoryGetExistingVote).not.toHaveBeenCalled() + }) + + it('emits LISTING_VOTED on a new vote', async () => { + const { caller, prisma } = createCaller() + prisma.pcListing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID }) + + await caller.vote({ pcListingId: LISTING_ID, value: true }) + + expect(mockEmitNotificationEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'LISTING_VOTED', + entityType: 'pcListing', + entityId: LISTING_ID, + payload: expect.objectContaining({ voteValue: true }), + }), + ) + }) + + it('emits LISTING_VOTED on a vote direction change', async () => { + const { caller, prisma } = createCaller() + prisma.pcListing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID }) + prisma.pcListingVote.findUnique.mockResolvedValue({ + userId: USER_ID, + pcListingId: LISTING_ID, + value: true, + }) + + await caller.vote({ pcListingId: LISTING_ID, value: false }) + + expect(mockEmitNotificationEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'LISTING_VOTED', + payload: expect.objectContaining({ voteValue: false }), + }), + ) + }) + + it('does NOT emit LISTING_VOTED on toggle-off (delete)', async () => { + const { caller, prisma } = createCaller() + prisma.pcListing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID }) + prisma.pcListingVote.findUnique.mockResolvedValue({ + userId: USER_ID, + pcListingId: LISTING_ID, + value: true, + }) + + await caller.vote({ pcListingId: LISTING_ID, value: true }) + + expect(mockEmitNotificationEvent).not.toHaveBeenCalled() + }) + + it('verifies recaptcha when token is provided', async () => { + const { caller, prisma } = createCaller() + prisma.pcListing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID }) + + await caller.vote({ + pcListingId: LISTING_ID, + value: true, + recaptchaToken: 'valid-token', + }) + + expect(mockVerifyRecaptcha).toHaveBeenCalledWith({ + token: 'valid-token', + expectedAction: 'VOTE', + userIP: expect.any(String), + }) + }) + + it('throws CAPTCHA error when recaptcha verification fails', async () => { + const { caller, prisma } = createCaller() + prisma.pcListing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID }) + mockVerifyRecaptcha.mockResolvedValueOnce({ success: false, error: 'invalid-token' }) + + // AppError.captcha throws a TRPCError; assert the procedure rejects. + await expect( + caller.vote({ + pcListingId: LISTING_ID, + value: true, + recaptchaToken: 'bad-token', + }), + ).rejects.toThrow(/CAPTCHA/) + + // Vote write must NOT have happened + expect(prisma.pcListingVote.create).not.toHaveBeenCalled() + expect(prisma.pcListingVote.update).not.toHaveBeenCalled() + expect(prisma.pcListingVote.delete).not.toHaveBeenCalled() + }) + + it('proceeds without verifying recaptcha when no token provided (optional)', async () => { + const { caller, prisma } = createCaller() + prisma.pcListing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID }) + + await caller.vote({ pcListingId: LISTING_ID, value: true }) + + expect(mockVerifyRecaptcha).not.toHaveBeenCalled() + expect(prisma.pcListingVote.create).toHaveBeenCalled() + }) + }) + + describe('voteComment notifications', () => { + function setupComment(prisma: MockPrisma) { + prisma.pcListingComment.findUnique.mockResolvedValue({ + id: COMMENT_ID, + userId: AUTHOR_ID, + pcListingId: LISTING_ID, + }) + prisma.pcListingComment.update.mockResolvedValue({ id: COMMENT_ID, score: 1 }) + } + + it('emits COMMENT_VOTED on a new upvote', async () => { + const { caller, prisma } = createCaller() + setupComment(prisma) + + await caller.voteComment({ commentId: COMMENT_ID, value: true }) + + expect(mockEmitNotificationEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'COMMENT_VOTED', + entityType: 'comment', + entityId: COMMENT_ID, + payload: expect.objectContaining({ + commentId: COMMENT_ID, + pcListingId: LISTING_ID, + voteValue: true, + }), + }), + ) + }) + + it('emits COMMENT_VOTED on a vote change', async () => { + const { caller, prisma } = createCaller() + setupComment(prisma) + prisma.pcListingCommentVote.findUnique.mockResolvedValue({ + userId: USER_ID, + commentId: COMMENT_ID, + value: true, + }) + + await caller.voteComment({ commentId: COMMENT_ID, value: false }) + + expect(mockEmitNotificationEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'COMMENT_VOTED', + payload: expect.objectContaining({ voteValue: false }), + }), + ) + }) + + it('does NOT emit COMMENT_VOTED on toggle-off (remove)', async () => { + const { caller, prisma } = createCaller() + setupComment(prisma) + prisma.pcListingCommentVote.findUnique.mockResolvedValue({ + userId: USER_ID, + commentId: COMMENT_ID, + value: true, + }) + + await caller.voteComment({ commentId: COMMENT_ID, value: true }) + + expect(mockEmitNotificationEvent).not.toHaveBeenCalled() + }) + }) + + describe('create', () => { + it('calls applyTrustAction with LISTING_CREATED after creation', async () => { + const newListingId = '00000000-0000-4000-a000-000000000099' + mockRepositoryCreate.mockResolvedValue({ + id: newListingId, + status: ApprovalStatus.PENDING, + }) + + const { caller } = createCaller() + + await caller.create({ + gameId: '00000000-0000-4000-a000-000000000030', + cpuId: '00000000-0000-4000-a000-000000000031', + emulatorId: '00000000-0000-4000-a000-000000000032', + performanceId: 1, + memorySize: 16, + os: 'WINDOWS' as never, + osVersion: '11', + }) + + expect(mockApplyTrustAction).toHaveBeenCalledWith({ + userId: USER_ID, + action: TrustAction.LISTING_CREATED, + context: { pcListingId: newListingId }, + }) + }) + }) + + describe('approve', () => { + it('calls applyTrustAction with LISTING_APPROVED for author', async () => { + mockRepositoryGetById.mockResolvedValue({ + id: LISTING_ID, + authorId: AUTHOR_ID, + status: ApprovalStatus.PENDING, + emulatorId: '00000000-0000-4000-a000-000000000060', + }) + mockRepositoryApprove.mockResolvedValue({ + id: LISTING_ID, + status: ApprovalStatus.APPROVED, + }) + + const { caller } = createCaller({ userId: ADMIN_ID, role: Role.MODERATOR }) + + await caller.approve({ pcListingId: LISTING_ID }) + + expect(mockApplyTrustAction).toHaveBeenCalledWith({ + userId: AUTHOR_ID, + action: TrustAction.LISTING_APPROVED, + context: { + pcListingId: LISTING_ID, + adminUserId: ADMIN_ID, + reason: 'listing_approved', + }, + }) + }) + }) + + describe('reject', () => { + it('calls applyTrustAction with LISTING_REJECTED for author', async () => { + mockRepositoryGetById.mockResolvedValue({ + id: LISTING_ID, + authorId: AUTHOR_ID, + status: ApprovalStatus.PENDING, + emulatorId: '00000000-0000-4000-a000-000000000060', + }) + mockRepositoryReject.mockResolvedValue({ + id: LISTING_ID, + status: ApprovalStatus.REJECTED, + }) + + const { caller } = createCaller({ userId: ADMIN_ID, role: Role.MODERATOR }) + + await caller.reject({ pcListingId: LISTING_ID, notes: 'Incomplete report' }) + + expect(mockApplyTrustAction).toHaveBeenCalledWith({ + userId: AUTHOR_ID, + action: TrustAction.LISTING_REJECTED, + context: { + pcListingId: LISTING_ID, + adminUserId: ADMIN_ID, + reason: 'Incomplete report', + }, + }) + }) + }) + + describe('bulkApprove', () => { + it('calls applyTrustAction with LISTING_APPROVED for each listing author', async () => { + const listing1 = { + id: LISTING_ID, + gameId: '00000000-0000-4000-a000-000000000040', + authorId: AUTHOR_ID, + } + const listing2 = { + id: '00000000-0000-4000-a000-000000000011', + gameId: '00000000-0000-4000-a000-000000000041', + authorId: '00000000-0000-4000-a000-000000000050', + } + + const { caller, prisma } = createCaller({ userId: ADMIN_ID, role: Role.MODERATOR }) + prisma.pcListing.findMany.mockResolvedValue([listing1, listing2]) + prisma.pcListing.updateMany.mockResolvedValue({ count: 2 }) + + await caller.bulkApprove({ pcListingIds: [listing1.id, listing2.id] }) + + expect(mockApplyTrustAction).toHaveBeenCalledTimes(2) + expect(mockApplyTrustAction).toHaveBeenCalledWith({ + userId: AUTHOR_ID, + action: TrustAction.LISTING_APPROVED, + context: expect.objectContaining({ pcListingId: LISTING_ID }), + }) + expect(mockApplyTrustAction).toHaveBeenCalledWith({ + userId: '00000000-0000-4000-a000-000000000050', + action: TrustAction.LISTING_APPROVED, + context: expect.objectContaining({ pcListingId: '00000000-0000-4000-a000-000000000011' }), + }) + }) + }) + + describe('bulkReject', () => { + it('calls applyTrustAction with LISTING_REJECTED for each listing author', async () => { + const listing1 = { id: LISTING_ID, authorId: AUTHOR_ID } + const listing2 = { + id: '00000000-0000-4000-a000-000000000011', + authorId: '00000000-0000-4000-a000-000000000050', + } + + const { caller, prisma } = createCaller({ userId: ADMIN_ID, role: Role.MODERATOR }) + prisma.pcListing.findMany.mockResolvedValue([listing1, listing2]) + prisma.pcListing.updateMany.mockResolvedValue({ count: 2 }) + + await caller.bulkReject({ pcListingIds: [listing1.id, listing2.id], notes: 'Spam' }) + + expect(mockApplyTrustAction).toHaveBeenCalledTimes(2) + expect(mockApplyTrustAction).toHaveBeenCalledWith({ + userId: AUTHOR_ID, + action: TrustAction.LISTING_REJECTED, + context: expect.objectContaining({ + pcListingId: LISTING_ID, + reason: 'Spam', + }), + }) + expect(mockApplyTrustAction).toHaveBeenCalledWith({ + userId: '00000000-0000-4000-a000-000000000050', + action: TrustAction.LISTING_REJECTED, + context: expect.objectContaining({ + pcListingId: '00000000-0000-4000-a000-000000000011', + reason: 'Spam', + }), + }) + }) + }) + + describe('voteComment', () => { + function setupCommentMocks(prisma: MockPrisma) { + prisma.pcListingComment.findUnique.mockResolvedValue({ + id: COMMENT_ID, + userId: AUTHOR_ID, + pcListingId: LISTING_ID, + }) + prisma.pcListingCommentVote.findUnique.mockResolvedValue(null) + prisma.pcListingComment.update.mockResolvedValue({ id: COMMENT_ID, score: 1 }) + } + + it('calls handleCommentVoteTrustEffects with listingType pc on new upvote', async () => { + const { caller, prisma } = createCaller() + setupCommentMocks(prisma) + + await caller.voteComment({ commentId: COMMENT_ID, value: true }) + + expect(mockHandleCommentVoteTrustEffects).toHaveBeenCalledWith( + expect.objectContaining({ + trustAction: 'upvote', + newValue: true, + commentAuthorId: AUTHOR_ID, + voterId: USER_ID, + commentId: COMMENT_ID, + parentEntityId: LISTING_ID, + listingType: 'pc', + }), + ) + }) + + it('calls handleCommentVoteTrustEffects with change action on vote flip', async () => { + const { caller, prisma } = createCaller() + setupCommentMocks(prisma) + prisma.pcListingCommentVote.findUnique.mockResolvedValue({ + userId: USER_ID, + commentId: COMMENT_ID, + value: true, + }) + + await caller.voteComment({ commentId: COMMENT_ID, value: false }) + + expect(mockHandleCommentVoteTrustEffects).toHaveBeenCalledWith( + expect.objectContaining({ + trustAction: 'change', + newValue: false, + previousValue: true, + listingType: 'pc', + }), + ) + }) + + it('calls handleCommentVoteTrustEffects with remove action on vote toggle', async () => { + const { caller, prisma } = createCaller() + setupCommentMocks(prisma) + prisma.pcListingCommentVote.findUnique.mockResolvedValue({ + userId: USER_ID, + commentId: COMMENT_ID, + value: true, + }) + + await caller.voteComment({ commentId: COMMENT_ID, value: true }) + + expect(mockHandleCommentVoteTrustEffects).toHaveBeenCalledWith( + expect.objectContaining({ + trustAction: 'remove', + listingType: 'pc', + }), + ) + }) + + it('fetches existingVote inside the $transaction callback', async () => { + const { caller, prisma } = createCaller() + setupCommentMocks(prisma) + + await caller.voteComment({ commentId: COMMENT_ID, value: true }) + + const txCall = vi.mocked(prisma.$transaction).mock.invocationCallOrder[0] + const findUniqueCall = prisma.pcListingCommentVote.findUnique.mock.invocationCallOrder[0] + expect(txCall).toBeDefined() + expect(findUniqueCall).toBeDefined() + expect(findUniqueCall).toBeGreaterThan(txCall as number) + }) + }) +}) diff --git a/src/server/api/routers/pcListings.ts b/src/server/api/routers/pcListings.ts index b588f387..c6dd3d2b 100644 --- a/src/server/api/routers/pcListings.ts +++ b/src/server/api/routers/pcListings.ts @@ -1,6 +1,8 @@ import analytics from '@/lib/analytics' +import { RECAPTCHA_CONFIG } from '@/lib/captcha/config' +import { getClientIP, verifyRecaptcha } from '@/lib/captcha/verify' import { AppError, ResourceError } from '@/lib/errors' -import { TrustService } from '@/lib/trust/service' +import { applyTrustAction, TrustService } from '@/lib/trust/service' import { ApprovePcListingSchema, BulkApprovePcListingsSchema, @@ -68,6 +70,10 @@ import { paginate } from '@/server/utils/pagination' import { isUserBanned } from '@/server/utils/query-builders' import { validatePagination } from '@/server/utils/security-validation' import { updatePcListingVoteCounts } from '@/server/utils/vote-counts' +import { + handleCommentVoteTrustEffects, + handleListingVoteTrustEffects, +} from '@/server/utils/vote-trust-effects' import { PERMISSIONS, roleIncludesRole } from '@/utils/permission-system' import { canDeleteComment, @@ -251,6 +257,10 @@ export const pcListingsRouter = createTRPCRouter({ }), create: protectedProcedure.input(CreatePcListingSchema).mutation(async ({ ctx, input }) => { + // TODO: Add spam detection via `checkSpamContent` from + // `@/server/utils/spam-check` (currently only applied in mobile routes). + // Block: UX/product sign-off needed since existing web users would start + // seeing spam-block errors. Mirror mobile: `{ userId, content: notes, entityType: 'listing' }`. const authorId = ctx.session.user.id const repository = new PcListingsRepository(ctx.prisma) const newListing = await repository.create({ @@ -270,6 +280,12 @@ export const pcListingsRouter = createTRPCRouter({ : null) as { customFieldDefinitionId: string; value: unknown }[] | null, }) + await applyTrustAction({ + userId: authorId, + action: TrustAction.LISTING_CREATED, + context: { pcListingId: newListing.id }, + }) + // Invalidate stats cache when PC listing is created listingStatsCache.delete('pc-listing-stats') @@ -513,6 +529,18 @@ export const pcListingsRouter = createTRPCRouter({ const approvedListing = await repository.approve(input.pcListingId, ctx.session.user.id) + if (pcListing.authorId) { + await applyTrustAction({ + userId: pcListing.authorId, + action: TrustAction.LISTING_APPROVED, + context: { + pcListingId: input.pcListingId, + adminUserId: ctx.session.user.id, + reason: 'listing_approved', + }, + }) + } + // Invalidate stats cache when PC listing is approved listingStatsCache.delete('pc-listing-stats') @@ -567,6 +595,18 @@ export const pcListingsRouter = createTRPCRouter({ input.notes, ) + if (pcListing.authorId) { + await applyTrustAction({ + userId: pcListing.authorId, + action: TrustAction.LISTING_REJECTED, + context: { + pcListingId: input.pcListingId, + adminUserId: ctx.session.user.id, + reason: input.notes || 'listing_rejected', + }, + }) + } + // Invalidate stats cache when PC listing is rejected listingStatsCache.delete('pc-listing-stats') @@ -627,7 +667,7 @@ export const pcListingsRouter = createTRPCRouter({ const pendingListings = await ctx.prisma.pcListing.findMany({ where: { id: { in: input.pcListingIds }, status: ApprovalStatus.PENDING }, - select: { id: true, gameId: true }, + select: { id: true, gameId: true, authorId: true }, }) const result = await ctx.prisma.pcListing.updateMany({ @@ -639,6 +679,24 @@ export const pcListingsRouter = createTRPCRouter({ }, }) + // Apply trust actions in parallel — distinct user adjustments, independent. + const listingsWithAuthor = pendingListings.filter( + (l): l is typeof l & { authorId: string } => l.authorId !== null, + ) + await Promise.all( + listingsWithAuthor.map((listing) => + applyTrustAction({ + userId: listing.authorId, + action: TrustAction.LISTING_APPROVED, + context: { + pcListingId: listing.id, + adminUserId: ctx.session.user.id, + reason: 'bulk_listing_approved', + }, + }), + ), + ) + listingStatsCache.delete('pc-listing-stats') for (const listing of pendingListings) { @@ -671,7 +729,7 @@ export const pcListingsRouter = createTRPCRouter({ const pendingListings = await ctx.prisma.pcListing.findMany({ where: { id: { in: input.pcListingIds }, status: ApprovalStatus.PENDING }, - select: { id: true }, + select: { id: true, authorId: true }, }) const result = await ctx.prisma.pcListing.updateMany({ @@ -686,6 +744,24 @@ export const pcListingsRouter = createTRPCRouter({ }, }) + // Apply trust actions in parallel — distinct user adjustments, independent. + const listingsWithAuthor = pendingListings.filter( + (l): l is typeof l & { authorId: string } => l.authorId !== null, + ) + await Promise.all( + listingsWithAuthor.map((listing) => + applyTrustAction({ + userId: listing.authorId, + action: TrustAction.LISTING_REJECTED, + context: { + pcListingId: listing.id, + adminUserId: ctx.session.user.id, + reason: input.notes || 'bulk_listing_rejected', + }, + }), + ), + ) + // Invalidate stats cache when PC listings are bulk rejected listingStatsCache.delete('pc-listing-stats') @@ -886,74 +962,95 @@ export const pcListingsRouter = createTRPCRouter({ return AppError.shadowBanned() } + if (input.recaptchaToken) { + const clientIP = ctx.headers ? getClientIP(ctx.headers) : undefined + const captchaResult = await verifyRecaptcha({ + token: input.recaptchaToken, + expectedAction: RECAPTCHA_CONFIG.actions.VOTE, + userIP: clientIP, + }) + + if (!captchaResult.success) return AppError.captcha(captchaResult.error) + } + const pcListing = await ctx.prisma.pcListing.findUnique({ where: { id: pcListingId }, }) if (!pcListing) return ResourceError.pcListing.notFound() - // Check if user already voted on this PC listing - const repository = new PcListingsRepository(ctx.prisma) - const existingVote = await repository.getExistingVote(userId, pcListingId) - - let voteResult - - if (existingVote) { - // If vote is the same, remove the vote (toggle) - if (existingVote.value === value) { - // Delete vote and update counts in transaction - voteResult = await ctx.prisma.$transaction(async (tx) => { - await tx.pcListingVote.delete({ - where: { userId_pcListingId: { userId, pcListingId } }, - }) - - await updatePcListingVoteCounts(tx, pcListingId, 'delete', undefined, existingVote.value) - - return { message: 'Vote removed' } - }) - } else { - // Update vote and counts in transaction - voteResult = await ctx.prisma.$transaction(async (tx) => { - const vote = await tx.pcListingVote.update({ - where: { userId_pcListingId: { userId, pcListingId } }, - data: { value }, - }) - - await updatePcListingVoteCounts(tx, pcListingId, 'update', value, existingVote.value) + // Fetch existingVote INSIDE the transaction to avoid race conditions between + // concurrent votes on the same (user, pcListing) pair. + const voteResult = await ctx.prisma.$transaction(async (tx) => { + const existingVote = await tx.pcListingVote.findUnique({ + where: { userId_pcListingId: { userId, pcListingId } }, + }) - return vote - }) + let result: { + vote: { userId: string; pcListingId: string; value: boolean } | null + action: 'created' | 'updated' | 'deleted' + previousValue: boolean | null } - } else { - // Create new vote and update counts in transaction - voteResult = await ctx.prisma.$transaction(async (tx) => { + + if (!existingVote) { const vote = await tx.pcListingVote.create({ data: { userId, pcListingId, value }, }) - await updatePcListingVoteCounts(tx, pcListingId, 'create', value) + result = { vote, action: 'created', previousValue: null } + } else if (existingVote.value === value) { + await tx.pcListingVote.delete({ + where: { userId_pcListingId: { userId, pcListingId } }, + }) + await updatePcListingVoteCounts(tx, pcListingId, 'delete', undefined, existingVote.value) + result = { vote: null, action: 'deleted', previousValue: existingVote.value } + } else { + const vote = await tx.pcListingVote.update({ + where: { userId_pcListingId: { userId, pcListingId } }, + data: { value }, + }) + await updatePcListingVoteCounts(tx, pcListingId, 'update', value, existingVote.value) + result = { vote, action: 'updated', previousValue: existingVote.value } + } - return vote + await handleListingVoteTrustEffects({ + tx, + action: result.action, + currentValue: value, + previousValue: result.previousValue, + userId, + listingId: pcListingId, + listingType: 'pc', + authorId: pcListing.authorId, }) - } - // Emit notification event - notificationEventEmitter.emitNotificationEvent({ - eventType: value ? NOTIFICATION_EVENTS.LISTING_VOTED : NOTIFICATION_EVENTS.LISTING_VOTED, - entityType: 'pcListing', - entityId: pcListingId, - triggeredBy: userId, - payload: { pcListingId, voteValue: value }, + return result }) - const finalVoteValue = existingVote?.value === value ? null : value + // Only notify the author when a vote was created or updated — toggle-off should not fire. + if (voteResult.action === 'created' || voteResult.action === 'updated') { + if (voteResult.vote) { + notificationEventEmitter.emitNotificationEvent({ + eventType: NOTIFICATION_EVENTS.LISTING_VOTED, + entityType: 'pcListing', + entityId: pcListingId, + triggeredBy: userId, + payload: { + pcListingId, + voteValue: value, + }, + }) + } + } + + const finalVoteValue = voteResult.action === 'deleted' ? null : value analytics.engagement.vote({ listingId: pcListingId, voteValue: finalVoteValue, - previousVote: existingVote?.value, + previousVote: voteResult.previousValue, }) - return voteResult + return voteResult.vote }), getUserVote: protectedProcedure @@ -1072,6 +1169,10 @@ export const pcListingsRouter = createTRPCRouter({ createComment: protectedProcedure .input(CreatePcListingCommentSchema) .mutation(async ({ ctx, input }) => { + // TODO: Add spam detection via `checkSpamContent` from + // `@/server/utils/spam-check` (currently only applied in mobile routes). + // Block: UX/product sign-off needed since existing web users would start + // seeing spam-block errors. Mirror mobile: `{ userId, content, entityType: 'comment' }`. const { pcListingId, content, parentId } = input const userId = ctx.session.user.id @@ -1143,7 +1244,7 @@ export const pcListingsRouter = createTRPCRouter({ return ResourceError.comment.noPermission('edit') } - return ctx.prisma.pcListingComment.update({ + return await ctx.prisma.pcListingComment.update({ where: { id: input.commentId }, data: { content: input.content, @@ -1238,44 +1339,79 @@ export const pcListingsRouter = createTRPCRouter({ return ResourceError.comment.notFound() } - // Check if user already voted on this comment - const existingVote = await ctx.prisma.pcListingCommentVote.findUnique({ - where: { userId_commentId: { userId, commentId } }, - }) + // Fetch `existingVote` inside the transaction: two concurrent votes + // from the same user could both read null and both attempt to insert, + // producing a Prisma P2002 on the second. Keeping the read and write + // under the same isolation avoids the race. + return await ctx.prisma.$transaction(async (tx) => { + const existingVote = await tx.pcListingCommentVote.findUnique({ + where: { userId_commentId: { userId, commentId } }, + }) - // Start a transaction to handle both the vote and score update - return ctx.prisma.$transaction(async (tx) => { let voteResult let scoreChange: number + let trustAction: 'upvote' | 'downvote' | 'change' | 'remove' | null = null if (existingVote) { - // If vote is the same, remove the vote (toggle) if (existingVote.value === value) { await tx.pcListingCommentVote.delete({ where: { userId_commentId: { userId, commentId } }, }) scoreChange = existingVote.value ? -1 : 1 voteResult = { message: 'Vote removed' } + trustAction = 'remove' } else { voteResult = await tx.pcListingCommentVote.update({ where: { userId_commentId: { userId, commentId } }, data: { value }, }) scoreChange = value ? 2 : -2 + trustAction = 'change' } } else { voteResult = await tx.pcListingCommentVote.create({ data: { userId, commentId, value }, }) scoreChange = value ? 1 : -1 + trustAction = value ? 'upvote' : 'downvote' } - // Update the comment score - await tx.pcListingComment.update({ + const updatedComment = await tx.pcListingComment.update({ where: { id: commentId }, data: { score: { increment: scoreChange } }, }) + if (trustAction) { + await handleCommentVoteTrustEffects({ + tx, + trustAction, + newValue: value, + previousValue: existingVote?.value ?? null, + commentAuthorId: comment.userId, + voterId: userId, + commentId, + parentEntityId: comment.pcListingId, + listingType: 'pc', + updatedScore: updatedComment.score, + scoreChange, + }) + } + + // Notify comment author on new votes / direction changes; skip on toggle-off. + if (trustAction !== null && trustAction !== 'remove') { + notificationEventEmitter.emitNotificationEvent({ + eventType: NOTIFICATION_EVENTS.COMMENT_VOTED, + entityType: 'comment', + entityId: comment.id, + triggeredBy: userId, + payload: { + pcListingId: comment.pcListingId, + commentId: comment.id, + voteValue: value, + }, + }) + } + return voteResult }) }), @@ -1428,7 +1564,7 @@ export const pcListingsRouter = createTRPCRouter({ // Prevent users from reporting their own listings if (pcListing.authorId === userId) { - AppError.badRequest('You cannot report your own listing') + return AppError.badRequest('You cannot report your own listing') } // Check if user already reported this listing @@ -1442,10 +1578,10 @@ export const pcListingsRouter = createTRPCRouter({ }) if (existingReport) { - AppError.badRequest('You have already reported this listing') + return AppError.badRequest('You have already reported this listing') } - return ctx.prisma.pcListingReport.create({ + return await ctx.prisma.pcListingReport.create({ data: { pcListingId, reportedById: userId, @@ -1565,7 +1701,7 @@ export const pcListingsRouter = createTRPCRouter({ }) } - return ctx.prisma.pcListingReport.update({ + return await ctx.prisma.pcListingReport.update({ where: { id: reportId }, data: { status, @@ -1599,7 +1735,7 @@ export const pcListingsRouter = createTRPCRouter({ }) if (!pcListing) { - ResourceError.pcListing.notFound() + return ResourceError.pcListing.notFound() } // Check if user already verified this listing @@ -1613,10 +1749,10 @@ export const pcListingsRouter = createTRPCRouter({ }) if (existingVerification) { - AppError.badRequest('You have already verified this listing') + return AppError.badRequest('You have already verified this listing') } - return ctx.prisma.pcListingDeveloperVerification.create({ + return await ctx.prisma.pcListingDeveloperVerification.create({ data: { pcListingId, verifiedBy: verifierId, @@ -1644,7 +1780,7 @@ export const pcListingsRouter = createTRPCRouter({ return ResourceError.verification.canOnlyRemoveOwn() } - return ctx.prisma.pcListingDeveloperVerification.delete({ + return await ctx.prisma.pcListingDeveloperVerification.delete({ where: { id: input.verificationId }, }) }), @@ -1652,7 +1788,7 @@ export const pcListingsRouter = createTRPCRouter({ getVerifications: publicProcedure .input(GetPcListingVerificationsSchema) .query(async ({ ctx, input }) => { - return ctx.prisma.pcListingDeveloperVerification.findMany({ + return await ctx.prisma.pcListingDeveloperVerification.findMany({ where: { pcListingId: input.pcListingId }, include: { developer: { select: { id: true, name: true } }, diff --git a/src/server/api/routers/trust.test.ts b/src/server/api/routers/trust.test.ts new file mode 100644 index 00000000..5961ca72 --- /dev/null +++ b/src/server/api/routers/trust.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { Role } from '@orm' + +vi.unmock('@/server/api/trpc') +vi.unmock('@/server/api/root') + +const mockApplyMonthlyActiveBonus = vi.fn().mockResolvedValue({ processedUsers: 0, errors: [] }) +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), + })), +})) + +const mockPrismaTrustActionLogFindMany = vi.fn().mockResolvedValue([]) +const mockPrismaTrustActionLogCount = vi.fn().mockResolvedValue(0) +const mockPrismaUserCount = vi.fn().mockResolvedValue(0) +const mockPrismaUserGroupBy = vi.fn().mockResolvedValue([]) + +vi.mock('@/server/db', () => ({ + prisma: { + trustActionLog: { + findMany: (...args: unknown[]) => mockPrismaTrustActionLogFindMany(...args), + count: (...args: unknown[]) => mockPrismaTrustActionLogCount(...args), + }, + user: { + count: (...args: unknown[]) => mockPrismaUserCount(...args), + groupBy: (...args: unknown[]) => mockPrismaUserGroupBy(...args), + }, + }, +})) + +const { trustRouter } = await import('./trust') + +const USER_ID = '00000000-0000-4000-a000-000000000001' +const TARGET_USER_ID = '00000000-0000-4000-a000-000000000002' + +function createCaller(role: Role) { + return trustRouter.createCaller({ + session: { + user: { + id: USER_ID, + email: 'test@test.com', + name: 'Test User', + role, + permissions: [], + showNsfw: false, + }, + }, + prisma: {} as never, + headers: new Headers(), + }) +} + +const VALID_PAGINATION = { page: 1, limit: 20 } + +const NON_SUPER_ADMIN_ROLES: Role[] = [ + Role.USER, + Role.AUTHOR, + Role.DEVELOPER, + Role.MODERATOR, + Role.ADMIN, +] + +describe('trust router permission checks', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('getTrustLogs (SUPER_ADMIN only)', () => { + NON_SUPER_ADMIN_ROLES.forEach((role) => { + it(`rejects with FORBIDDEN for ${role} and does not query the DB`, async () => { + const caller = createCaller(role) + + await expect(caller.getTrustLogs(VALID_PAGINATION)).rejects.toThrow( + /Super Admin|insufficient|forbidden/i, + ) + + expect(mockPrismaTrustActionLogFindMany).not.toHaveBeenCalled() + expect(mockPrismaTrustActionLogCount).not.toHaveBeenCalled() + }) + }) + + it('proceeds for SUPER_ADMIN', async () => { + const caller = createCaller(Role.SUPER_ADMIN) + + const result = await caller.getTrustLogs(VALID_PAGINATION) + + expect(result).toHaveProperty('logs') + expect(result).toHaveProperty('pagination') + expect(mockPrismaTrustActionLogFindMany).toHaveBeenCalledTimes(1) + }) + }) + + describe('getTrustStats (SUPER_ADMIN only)', () => { + NON_SUPER_ADMIN_ROLES.forEach((role) => { + it(`rejects with FORBIDDEN for ${role} and does not query the DB`, async () => { + const caller = createCaller(role) + + await expect(caller.getTrustStats({})).rejects.toThrow( + /Super Admin|insufficient|forbidden/i, + ) + + expect(mockPrismaTrustActionLogCount).not.toHaveBeenCalled() + expect(mockPrismaUserCount).not.toHaveBeenCalled() + expect(mockPrismaUserGroupBy).not.toHaveBeenCalled() + }) + }) + + it('proceeds for SUPER_ADMIN', async () => { + const caller = createCaller(Role.SUPER_ADMIN) + + const result = await caller.getTrustStats({}) + + expect(result).toHaveProperty('totalActions') + expect(mockPrismaTrustActionLogCount).toHaveBeenCalledTimes(1) + }) + }) + + describe('runMonthlyActiveBonus (SUPER_ADMIN only)', () => { + NON_SUPER_ADMIN_ROLES.forEach((role) => { + it(`rejects with FORBIDDEN for ${role} and does not invoke the bonus job`, async () => { + const caller = createCaller(role) + + await expect(caller.runMonthlyActiveBonus({})).rejects.toThrow( + /Super Admin|insufficient|forbidden/i, + ) + + expect(mockApplyMonthlyActiveBonus).not.toHaveBeenCalled() + }) + }) + + it('invokes the bonus job for SUPER_ADMIN', async () => { + const caller = createCaller(Role.SUPER_ADMIN) + + await caller.runMonthlyActiveBonus({}) + + expect(mockApplyMonthlyActiveBonus).toHaveBeenCalledTimes(1) + }) + }) + + describe('adjustTrustScore (SUPER_ADMIN only)', () => { + NON_SUPER_ADMIN_ROLES.forEach((role) => { + it(`rejects with FORBIDDEN for ${role} and does not adjust trust`, async () => { + const caller = createCaller(role) + + await expect( + caller.adjustTrustScore({ + userId: TARGET_USER_ID, + adjustment: 50, + reason: 'attempted bypass', + }), + ).rejects.toThrow(/Super Admin|insufficient|forbidden/i) + + expect(mockApplyManualAdjustment).not.toHaveBeenCalled() + }) + }) + + it('invokes the adjustment for SUPER_ADMIN', async () => { + const caller = createCaller(Role.SUPER_ADMIN) + + await caller.adjustTrustScore({ + userId: TARGET_USER_ID, + adjustment: 50, + reason: 'legitimate adjustment', + }) + + expect(mockApplyManualAdjustment).toHaveBeenCalledTimes(1) + expect(mockApplyManualAdjustment).toHaveBeenCalledWith({ + userId: TARGET_USER_ID, + adjustment: 50, + reason: 'legitimate adjustment', + adminUserId: USER_ID, + }) + }) + }) +}) diff --git a/src/server/api/routers/trust.ts b/src/server/api/routers/trust.ts index 104651de..84f066f1 100644 --- a/src/server/api/routers/trust.ts +++ b/src/server/api/routers/trust.ts @@ -1,6 +1,6 @@ import { AppError } from '@/lib/errors' import { TRUST_LEVELS } from '@/lib/trust/config' -import { applyManualTrustAdjustment, applyMonthlyActiveBonus } from '@/lib/trust/service' +import { TrustService, applyMonthlyActiveBonus } from '@/lib/trust/service' import { GetTrustLogsSchema, GetTrustStatsSchema, @@ -17,7 +17,7 @@ export const trustRouter = createTRPCRouter({ // Get trust logs for admin dashboard (SUPER_ADMIN only) getTrustLogs: protectedProcedure.input(GetTrustLogsSchema).query(async ({ ctx, input }) => { if (!hasRolePermission(ctx.session.user.role, Role.SUPER_ADMIN)) { - AppError.insufficientRole(Role.SUPER_ADMIN) + return AppError.insufficientRole(Role.SUPER_ADMIN) } const { page, limit, sortField, sortDirection, search, action } = input @@ -79,7 +79,7 @@ export const trustRouter = createTRPCRouter({ // Get trust system statistics (SUPER_ADMIN only) getTrustStats: protectedProcedure.input(GetTrustStatsSchema).query(async ({ ctx }) => { if (!hasRolePermission(ctx.session.user.role, Role.SUPER_ADMIN)) { - AppError.insufficientRole(Role.SUPER_ADMIN) + return AppError.insufficientRole(Role.SUPER_ADMIN) } const [totalActions, totalUsers, levelDistribution] = await Promise.all([ @@ -127,7 +127,7 @@ export const trustRouter = createTRPCRouter({ .input(RunMonthlyActiveBonusSchema) .mutation(async ({ ctx }) => { if (!hasRolePermission(ctx.session.user.role, Role.SUPER_ADMIN)) { - AppError.insufficientRole(Role.SUPER_ADMIN) + return AppError.insufficientRole(Role.SUPER_ADMIN) } return await applyMonthlyActiveBonus() @@ -138,10 +138,10 @@ export const trustRouter = createTRPCRouter({ .input(ManualTrustAdjustmentSchema) .mutation(async ({ ctx, input }) => { if (!hasRolePermission(ctx.session.user.role, Role.SUPER_ADMIN)) { - AppError.insufficientRole(Role.SUPER_ADMIN) + return AppError.insufficientRole(Role.SUPER_ADMIN) } - await applyManualTrustAdjustment({ + await new TrustService(ctx.prisma).applyManualAdjustment({ userId: input.userId, adjustment: input.adjustment, reason: input.reason, diff --git a/src/server/api/routers/userBans.ts b/src/server/api/routers/userBans.ts index 81ddd1df..fd27221c 100644 --- a/src/server/api/routers/userBans.ts +++ b/src/server/api/routers/userBans.ts @@ -19,6 +19,7 @@ import { import { UserBansRepository } from '@/server/repositories/user-bans.repository' import { logAudit, buildDiff } from '@/server/services/audit.service' import { nullifyUserVotes, restoreUserVotes } from '@/server/services/vote-nullification.service' +import { withRetryTransaction } from '@/server/utils/transactions' import { PERMISSIONS } from '@/utils/permission-system' import { hasRolePermission } from '@/utils/permissions' import { AuditAction, AuditEntityType, Role } from '@orm' @@ -85,32 +86,38 @@ export const userBansRouter = createTRPCRouter({ return AppError.badRequest('Expiration date must be in the future') } - const created = await repository.create({ - userId, - bannedById, - reason, - notes, - expiresAt, - }) - - // Nullify votes if requested - let nullificationResult: Awaited> | null = null - if (shouldNullifyVotes) { - nullificationResult = await nullifyUserVotes(ctx.prisma, { - userId, - adminUserId: ctx.session.user.id, - reason: `Ban: ${reason}`, - includeCommentVotes: true, - headers: ctx.headers, - }) - - await ctx.prisma.userBan.update({ - where: { id: created.id }, - data: { votesNullified: true }, - }) - } + const { created, nullificationResult } = await withRetryTransaction( + ctx.prisma, + async (tx) => { + const ban = await new UserBansRepository(tx).create({ + userId, + bannedById, + reason, + notes, + expiresAt, + }) + + let nullResult: Awaited> | null = null + if (shouldNullifyVotes) { + nullResult = await nullifyUserVotes(tx, { + userId, + adminUserId: ctx.session.user.id, + reason: `Ban: ${reason}`, + includeCommentVotes: true, + headers: ctx.headers, + }) + + await tx.userBan.update({ + where: { id: ban.id }, + data: { votesNullified: true }, + }) + } + + return { created: ban, nullificationResult: nullResult } + }, + { timeout: 120_000 }, + ) - // Fire-and-forget audit log void logAudit(ctx.prisma, { actorId: ctx.session.user.id, action: AuditAction.BAN, @@ -217,7 +224,26 @@ export const userBansRouter = createTRPCRouter({ isActive: ban.isActive, } - const lifted = await repository.lift(id, unbannedById, notes) + const { lifted, restorationResult } = await withRetryTransaction( + ctx.prisma, + async (tx) => { + const liftedBan = await new UserBansRepository(tx).lift(id, unbannedById, notes) + + let restoreResult: Awaited> | null = null + if (ban.votesNullified) { + restoreResult = await restoreUserVotes(tx, { + userId: ban.userId, + adminUserId: ctx.session.user.id, + reason: `Ban lifted: ${ban.reason}`, + headers: ctx.headers, + }) + } + + return { lifted: liftedBan, restorationResult: restoreResult } + }, + { timeout: 120_000 }, + ) + const next = { reason: lifted.reason, notes: lifted.notes ?? null, @@ -225,17 +251,6 @@ export const userBansRouter = createTRPCRouter({ isActive: lifted.isActive, } - // Auto-restore votes if they were nullified during this ban - let restorationResult: Awaited> | null = null - if (ban.votesNullified) { - restorationResult = await restoreUserVotes(ctx.prisma, { - userId: ban.userId, - adminUserId: ctx.session.user.id, - reason: `Ban lifted: ${ban.reason}`, - headers: ctx.headers, - }) - } - void logAudit(ctx.prisma, { actorId: ctx.session.user.id, action: AuditAction.UNBAN, @@ -324,8 +339,8 @@ export const userBansRouter = createTRPCRouter({ ctx.prisma.listingReport.findMany({ where: { listing: { authorId: userId } }, include: { - reportedBy: { select: { id: true, name: true, email: true } }, - reviewedBy: { select: { id: true, name: true, email: true } }, + reportedBy: { select: { id: true, name: true } }, + reviewedBy: { select: { id: true, name: true } }, listing: { select: { id: true, @@ -339,8 +354,8 @@ export const userBansRouter = createTRPCRouter({ ctx.prisma.pcListingReport.findMany({ where: { pcListing: { authorId: userId } }, include: { - reportedBy: { select: { id: true, name: true, email: true } }, - reviewedBy: { select: { id: true, name: true, email: true } }, + reportedBy: { select: { id: true, name: true } }, + reviewedBy: { select: { id: true, name: true } }, pcListing: { select: { id: true, diff --git a/src/server/api/utils/listingHelpers.test.ts b/src/server/api/utils/listingHelpers.test.ts new file mode 100644 index 00000000..2f3abb64 --- /dev/null +++ b/src/server/api/utils/listingHelpers.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { buildProcessedOrderBy } from './listingHelpers' + +describe('buildProcessedOrderBy', () => { + it('defaults to processedAt desc when both args are null', () => { + expect(buildProcessedOrderBy(null, null)).toEqual({ processedAt: 'desc' }) + }) + + it('defaults to processedAt desc when both args are undefined', () => { + expect(buildProcessedOrderBy(undefined, undefined)).toEqual({ processedAt: 'desc' }) + }) + + it('honours explicit sortDirection with default sortField', () => { + expect(buildProcessedOrderBy(undefined, 'asc')).toEqual({ processedAt: 'asc' }) + expect(buildProcessedOrderBy(null, 'asc')).toEqual({ processedAt: 'asc' }) + }) + + it('maps createdAt directly', () => { + expect(buildProcessedOrderBy('createdAt', 'asc')).toEqual({ createdAt: 'asc' }) + expect(buildProcessedOrderBy('createdAt', 'desc')).toEqual({ createdAt: 'desc' }) + }) + + it('maps status directly', () => { + expect(buildProcessedOrderBy('status', 'asc')).toEqual({ status: 'asc' }) + }) + + it('maps game.title into a nested Prisma order clause', () => { + expect(buildProcessedOrderBy('game.title', 'asc')).toEqual({ game: { title: 'asc' } }) + }) + + it('maps author.name into a nested Prisma order clause', () => { + expect(buildProcessedOrderBy('author.name', 'desc')).toEqual({ author: { name: 'desc' } }) + }) + + it('maps game.system.name into a doubly-nested clause', () => { + expect(buildProcessedOrderBy('game.system.name', 'asc')).toEqual({ + game: { system: { name: 'asc' } }, + }) + }) + + it('maps emulator.name into a nested Prisma order clause', () => { + expect(buildProcessedOrderBy('emulator.name', 'desc')).toEqual({ + emulator: { name: 'desc' }, + }) + }) + + it('maps device into a composite brand+model sort array', () => { + expect(buildProcessedOrderBy('device', 'asc')).toEqual([ + { device: { brand: { name: 'asc' } } }, + { device: { modelName: 'asc' } }, + ]) + }) + + it('maps processedAt explicitly (no change from default)', () => { + expect(buildProcessedOrderBy('processedAt', 'asc')).toEqual({ processedAt: 'asc' }) + }) +}) diff --git a/src/server/api/utils/listingHelpers.ts b/src/server/api/utils/listingHelpers.ts new file mode 100644 index 00000000..2ce5a232 --- /dev/null +++ b/src/server/api/utils/listingHelpers.ts @@ -0,0 +1,41 @@ +import type { Prisma } from '@orm' + +export type ProcessedSortField = + | 'processedAt' + | 'createdAt' + | 'status' + | 'game.title' + | 'game.system.name' + | 'device' + | 'emulator.name' + | 'author.name' + +export type ProcessedSortDirection = 'asc' | 'desc' + +export function buildProcessedOrderBy( + sortField: ProcessedSortField | null | undefined, + sortDirection: ProcessedSortDirection | null | undefined, +): Prisma.ListingOrderByWithRelationInput | Prisma.ListingOrderByWithRelationInput[] { + const direction: Prisma.SortOrder = sortDirection ?? 'desc' + + switch (sortField) { + case 'createdAt': + return { createdAt: direction } + case 'status': + return { status: direction } + case 'game.title': + return { game: { title: direction } } + case 'game.system.name': + return { game: { system: { name: direction } } } + case 'device': + return [{ device: { brand: { name: direction } } }, { device: { modelName: direction } }] + case 'emulator.name': + return { emulator: { name: direction } } + case 'author.name': + return { author: { name: direction } } + case 'processedAt': + case null: + case undefined: + return { processedAt: direction } + } +} diff --git a/src/server/repositories/listings.repository.ts b/src/server/repositories/listings.repository.ts index 114bf378..073da543 100644 --- a/src/server/repositories/listings.repository.ts +++ b/src/server/repositories/listings.repository.ts @@ -2,6 +2,7 @@ import { PAGINATION } from '@/data/constants' import { AppError, ResourceError } from '@/lib/errors' import { canUserAutoApprove } from '@/lib/trust/service' import { validateCustomFields } from '@/server/api/routers/listings/validation' +import { computeVoteCounts } from '@/server/utils/moderator-info' import { paginate, calculateOffset } from '@/server/utils/pagination' import { buildNsfwFilter, @@ -737,4 +738,58 @@ export class ListingsRepository extends BaseRepository { orderBy: { createdAt: 'desc' }, }) } + + private static readonly moderatorInfoUserSelect = { + id: true, + name: true, + } as const + + private static readonly moderatorInfoVoteSelect = { + id: true, + value: true, + createdAt: true, + nullifiedAt: true, + user: { select: { id: true, name: true, trustScore: true } }, + } as const + + async getModeratorInfo(listingId: string) { + const listing = await this.handleDatabaseOperation( + () => + this.prisma.listing.findUnique({ + where: { id: listingId }, + select: { + status: true, + processedAt: true, + processedNotes: true, + processedByUser: { select: ListingsRepository.moderatorInfoUserSelect }, + }, + }), + 'Listing', + ) + + if (!listing) throw ResourceError.listing.notFound() + + const votes = await this.handleDatabaseOperation( + () => + this.prisma.vote.findMany({ + where: { listingId }, + orderBy: { createdAt: 'desc' }, + select: ListingsRepository.moderatorInfoVoteSelect, + }), + 'Vote', + ) + + const voteCounts = computeVoteCounts(votes) + + return { + approval: { + status: listing.status, + processedAt: listing.processedAt, + processedNotes: listing.processedNotes, + processedBy: listing.processedByUser, + }, + votes, + voteCounts, + } + } } diff --git a/src/server/repositories/pc-listings.repository.ts b/src/server/repositories/pc-listings.repository.ts index 9b5d8aa4..a6790136 100644 --- a/src/server/repositories/pc-listings.repository.ts +++ b/src/server/repositories/pc-listings.repository.ts @@ -6,6 +6,7 @@ import { PAGINATION } from '@/data/constants' import { AppError, ResourceError } from '@/lib/errors' import { canUserAutoApprove } from '@/lib/trust/service' +import { computeVoteCounts } from '@/server/utils/moderator-info' import { paginate, calculateOffset } from '@/server/utils/pagination' import { sanitizeInput } from '@/server/utils/security-validation' import { roleIncludesRole } from '@/utils/permission-system' @@ -945,4 +946,58 @@ export class PcListingsRepository extends BaseRepository { }) return !!verified } + + private static readonly moderatorInfoUserSelect = { + id: true, + name: true, + } as const + + private static readonly moderatorInfoVoteSelect = { + id: true, + value: true, + createdAt: true, + nullifiedAt: true, + user: { select: { id: true, name: true, trustScore: true } }, + } as const + + async getModeratorInfo(pcListingId: string) { + const pcListing = await this.handleDatabaseOperation( + () => + this.prisma.pcListing.findUnique({ + where: { id: pcListingId }, + select: { + status: true, + processedAt: true, + processedNotes: true, + processedByUser: { select: PcListingsRepository.moderatorInfoUserSelect }, + }, + }), + 'PcListing', + ) + + if (!pcListing) throw ResourceError.pcListing.notFound() + + const votes = await this.handleDatabaseOperation( + () => + this.prisma.pcListingVote.findMany({ + where: { pcListingId }, + orderBy: { createdAt: 'desc' }, + select: PcListingsRepository.moderatorInfoVoteSelect, + }), + 'PcListingVote', + ) + + const voteCounts = computeVoteCounts(votes) + + return { + approval: { + status: pcListing.status, + processedAt: pcListing.processedAt, + processedNotes: pcListing.processedNotes, + processedBy: pcListing.processedByUser, + }, + votes, + voteCounts, + } + } } diff --git a/src/server/services/audit.service.ts b/src/server/services/audit.service.ts index 364b4b4f..f8cd17c5 100644 --- a/src/server/services/audit.service.ts +++ b/src/server/services/audit.service.ts @@ -35,7 +35,9 @@ type Params = { headers?: Headers | Record } -export async function logAudit(prisma: PrismaClient, params: Params): Promise { +type PrismaLike = PrismaClient | Prisma.TransactionClient + +export async function logAudit(prisma: PrismaLike, params: Params): Promise { const { actorId, action, entityType, entityId, targetUserId, metadata, headers } = params const meta = extractRequestMeta(headers) diff --git a/src/server/services/vote-nullification.service.test.ts b/src/server/services/vote-nullification.service.test.ts index 9a469127..1934eb94 100644 --- a/src/server/services/vote-nullification.service.test.ts +++ b/src/server/services/vote-nullification.service.test.ts @@ -10,9 +10,13 @@ vi.mock('@/server/services/audit.service', () => ({ logAudit: vi.fn(), })) -const mockApplyManualTrustAdjustment = vi.fn() +const mockApplyManualAdjustment = vi.fn() +const mockApplyBulkManualAdjustments = vi.fn() vi.mock('@/lib/trust/service', () => ({ - applyManualTrustAdjustment: (...args: unknown[]) => mockApplyManualTrustAdjustment(...args), + TrustService: vi.fn().mockImplementation(() => ({ + applyManualAdjustment: (...args: unknown[]) => mockApplyManualAdjustment(...args), + applyBulkManualAdjustments: (...args: unknown[]) => mockApplyBulkManualAdjustments(...args), + })), })) vi.mock('@/utils/wilson-score', () => ({ @@ -28,22 +32,22 @@ function createMockPrisma() { vote: { findMany: vi.fn().mockResolvedValue([]), updateMany: vi.fn().mockResolvedValue({ count: 0 }), - count: vi.fn().mockResolvedValue(0), + groupBy: vi.fn().mockResolvedValue([]), }, pcListingVote: { findMany: vi.fn().mockResolvedValue([]), updateMany: vi.fn().mockResolvedValue({ count: 0 }), - count: vi.fn().mockResolvedValue(0), + groupBy: vi.fn().mockResolvedValue([]), }, commentVote: { findMany: vi.fn().mockResolvedValue([]), updateMany: vi.fn().mockResolvedValue({ count: 0 }), - count: vi.fn().mockResolvedValue(0), + groupBy: vi.fn().mockResolvedValue([]), }, pcListingCommentVote: { findMany: vi.fn().mockResolvedValue([]), updateMany: vi.fn().mockResolvedValue({ count: 0 }), - count: vi.fn().mockResolvedValue(0), + groupBy: vi.fn().mockResolvedValue([]), }, listing: { update: vi.fn().mockResolvedValue({}), @@ -146,7 +150,8 @@ describe('vote-nullification.service', () => { beforeEach(() => { prisma = createMockPrisma() - mockApplyManualTrustAdjustment.mockResolvedValue(undefined) + mockApplyManualAdjustment.mockResolvedValue(undefined) + mockApplyBulkManualAdjustments.mockResolvedValue(0) }) describe('nullifyUserVotes', () => { @@ -170,7 +175,7 @@ describe('vote-nullification.service', () => { expect(prisma.vote.updateMany).not.toHaveBeenCalled() expect(prisma.pcListingVote.updateMany).not.toHaveBeenCalled() - expect(mockApplyManualTrustAdjustment).not.toHaveBeenCalled() + expect(mockApplyBulkManualAdjustments).not.toHaveBeenCalled() }) it('nullifies handheld votes and sets nullifiedAt', async () => { @@ -179,7 +184,6 @@ describe('vote-nullification.service', () => { makeHandheldVote({ id: 'hv-2', value: false, authorId: AUTHOR_B }), ] prisma.vote.findMany.mockResolvedValue(votes) - prisma.vote.count.mockResolvedValue(0) const result = await nullifyUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -198,7 +202,6 @@ describe('vote-nullification.service', () => { it('nullifies PC votes', async () => { const votes = [makePcVote({ id: 'pv-1' }), makePcVote({ id: 'pv-2' })] prisma.pcListingVote.findMany.mockResolvedValue(votes) - prisma.pcListingVote.count.mockResolvedValue(0) const result = await nullifyUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -235,7 +238,6 @@ describe('vote-nullification.service', () => { it('skips comment votes when includeCommentVotes is false', async () => { prisma.vote.findMany.mockResolvedValue([makeHandheldVote()]) - prisma.vote.count.mockResolvedValue(0) const result = await nullifyUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -255,10 +257,6 @@ describe('vote-nullification.service', () => { makeHandheldVote({ id: 'hv-1', listingId: 'listing-1', value: true }), makeHandheldVote({ id: 'hv-2', listingId: 'listing-1', value: false }), ]) - // After nullification: 0 active votes remain - prisma.vote.count - .mockResolvedValueOnce(0) // upvotes for listing-1 - .mockResolvedValueOnce(0) // downvotes for listing-1 await nullifyUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -282,10 +280,6 @@ describe('vote-nullification.service', () => { prisma.pcListingVote.findMany.mockResolvedValue([ makePcVote({ id: 'pv-1', pcListingId: 'pc-listing-1', value: true }), ]) - prisma.pcListingVote.count - .mockResolvedValueOnce(0) // upvotes - .mockResolvedValueOnce(0) // downvotes - await nullifyUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, adminUserId: ADMIN_ID, @@ -308,9 +302,10 @@ describe('vote-nullification.service', () => { prisma.commentVote.findMany.mockResolvedValue([ makeCommentVote({ id: 'cv-1', commentId: 'comment-1' }), ]) - prisma.commentVote.count - .mockResolvedValueOnce(3) // remaining upvotes - .mockResolvedValueOnce(1) // remaining downvotes + prisma.commentVote.groupBy.mockResolvedValue([ + { commentId: 'comment-1', value: true, _count: { _all: 3 } }, + { commentId: 'comment-1', value: false, _count: { _all: 1 } }, + ]) await nullifyUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -329,9 +324,10 @@ describe('vote-nullification.service', () => { prisma.pcListingCommentVote.findMany.mockResolvedValue([ makePcCommentVote({ id: 'pcv-1', commentId: 'pc-comment-1' }), ]) - prisma.pcListingCommentVote.count - .mockResolvedValueOnce(5) // remaining upvotes - .mockResolvedValueOnce(2) // remaining downvotes + prisma.pcListingCommentVote.groupBy.mockResolvedValue([ + { commentId: 'pc-comment-1', value: true, _count: { _all: 5 } }, + { commentId: 'pc-comment-1', value: false, _count: { _all: 2 } }, + ]) await nullifyUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -351,7 +347,6 @@ describe('vote-nullification.service', () => { makeHandheldVote({ id: 'hv-1', value: true }), makeHandheldVote({ id: 'hv-2', value: false, authorId: AUTHOR_B }), ]) - prisma.vote.count.mockResolvedValue(0) await nullifyUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -361,11 +356,11 @@ describe('vote-nullification.service', () => { }) // Voter should get -1 per handheld vote (total -2) - const voterCall = mockApplyManualTrustAdjustment.mock.calls.find( - (call: unknown[]) => (call[0] as { userId: string }).userId === USER_ID, - ) - expect(voterCall).toBeDefined() - expect((voterCall![0] as { adjustment: number }).adjustment).toBe(-2) + expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1) + const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as { + adjustments: Map + } + expect(adjustments.get(USER_ID)).toBe(-2) }) it('reverses author trust: -2 for upvote, +1 for downvote', async () => { @@ -373,7 +368,6 @@ describe('vote-nullification.service', () => { makeHandheldVote({ id: 'hv-1', value: true, authorId: AUTHOR_A }), makeHandheldVote({ id: 'hv-2', value: false, authorId: AUTHOR_A }), ]) - prisma.vote.count.mockResolvedValue(0) await nullifyUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -383,18 +377,17 @@ describe('vote-nullification.service', () => { }) // Author A: upvote reversed (-2) + downvote reversed (+1) = -1 - const authorCall = mockApplyManualTrustAdjustment.mock.calls.find( - (call: unknown[]) => (call[0] as { userId: string }).userId === AUTHOR_A, - ) - expect(authorCall).toBeDefined() - expect((authorCall![0] as { adjustment: number }).adjustment).toBe(-1) + expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1) + const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as { + adjustments: Map + } + expect(adjustments.get(AUTHOR_A)).toBe(-1) }) it('skips author trust reversal for self-votes', async () => { prisma.vote.findMany.mockResolvedValue([ makeHandheldVote({ id: 'hv-1', value: true, authorId: USER_ID }), ]) - prisma.vote.count.mockResolvedValue(0) await nullifyUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -404,18 +397,19 @@ describe('vote-nullification.service', () => { }) // Only the voter trust should be adjusted (not author, since it's a self-vote) - expect(mockApplyManualTrustAdjustment).toHaveBeenCalledTimes(1) - expect(mockApplyManualTrustAdjustment).toHaveBeenCalledWith( - expect.objectContaining({ userId: USER_ID, adjustment: -1 }), - ) + expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1) + const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as { + adjustments: Map + } + expect(adjustments.get(USER_ID)).toBe(-1) + expect(adjustments.size).toBe(1) }) - it('does NOT reverse trust for PC votes', async () => { + it('reverses trust for PC votes (same as handheld)', async () => { prisma.pcListingVote.findMany.mockResolvedValue([ - makePcVote({ id: 'pv-1', value: true }), - makePcVote({ id: 'pv-2', value: false }), + makePcVote({ id: 'pv-1', value: true, authorId: AUTHOR_B }), + makePcVote({ id: 'pv-2', value: false, authorId: AUTHOR_B }), ]) - prisma.pcListingVote.count.mockResolvedValue(0) await nullifyUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -424,7 +418,14 @@ describe('vote-nullification.service', () => { includeCommentVotes: false, }) - expect(mockApplyManualTrustAdjustment).not.toHaveBeenCalled() + expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1) + const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as { + adjustments: Map + } + // Voter: -1 per vote = -2 total + expect(adjustments.get(USER_ID)).toBe(-2) + // Author B: upvote reversed (-2) + downvote reversed (+1) = -1 + expect(adjustments.get(AUTHOR_B)).toBe(-1) }) it('reverses comment author trust for handheld comment upvotes/downvotes', async () => { @@ -432,7 +433,6 @@ describe('vote-nullification.service', () => { makeCommentVote({ id: 'cv-1', value: true, commentUserId: AUTHOR_A }), makeCommentVote({ id: 'cv-2', value: false, commentUserId: AUTHOR_A }), ]) - prisma.commentVote.count.mockResolvedValue(0) await nullifyUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -442,18 +442,17 @@ describe('vote-nullification.service', () => { }) // Author A comment trust: upvote reversed (-2) + downvote reversed (+1) = -1 - const authorCall = mockApplyManualTrustAdjustment.mock.calls.find( - (call: unknown[]) => (call[0] as { userId: string }).userId === AUTHOR_A, - ) - expect(authorCall).toBeDefined() - expect((authorCall![0] as { adjustment: number }).adjustment).toBe(-1) + expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1) + const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as { + adjustments: Map + } + expect(adjustments.get(AUTHOR_A)).toBe(-1) }) it('skips comment author trust for self-votes on own comments', async () => { prisma.commentVote.findMany.mockResolvedValue([ makeCommentVote({ id: 'cv-1', value: true, commentUserId: USER_ID }), ]) - prisma.commentVote.count.mockResolvedValue(0) await nullifyUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -462,25 +461,26 @@ describe('vote-nullification.service', () => { includeCommentVotes: true, }) - // No trust adjustment for comment self-votes - expect(mockApplyManualTrustAdjustment).not.toHaveBeenCalled() + // No trust adjustment for comment self-votes — map is empty + expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1) + const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as { + adjustments: Map + } + expect(adjustments.size).toBe(0) }) - it('continues when trust adjustment fails and logs error', async () => { + it('propagates trust adjustment errors for transactional rollback', async () => { prisma.vote.findMany.mockResolvedValue([makeHandheldVote({ id: 'hv-1', value: true })]) - prisma.vote.count.mockResolvedValue(0) - mockApplyManualTrustAdjustment.mockRejectedValue(new Error('Trust service down')) - - const result = await nullifyUserVotes(prisma as unknown as PrismaClient, { - userId: USER_ID, - adminUserId: ADMIN_ID, - reason: 'Spam', - includeCommentVotes: false, - }) - - // Should still return counts despite trust failure - expect(result.handheldVotesNullified).toBe(1) - expect(result.trustAdjustments).toBe(0) + mockApplyBulkManualAdjustments.mockRejectedValue(new Error('Trust service down')) + + await expect( + nullifyUserVotes(prisma as unknown as PrismaClient, { + userId: USER_ID, + adminUserId: ADMIN_ID, + reason: 'Spam', + includeCommentVotes: false, + }), + ).rejects.toThrow('Trust service down') }) it('batches nullification for more than 100 votes', async () => { @@ -488,7 +488,6 @@ describe('vote-nullification.service', () => { makeHandheldVote({ id: `hv-${i}`, listingId: `listing-${i % 5}` }), ) prisma.vote.findMany.mockResolvedValue(votes) - prisma.vote.count.mockResolvedValue(0) await nullifyUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -511,7 +510,6 @@ describe('vote-nullification.service', () => { makeHandheldVote({ id: 'hv-2', listingId: 'listing-1' }), makeHandheldVote({ id: 'hv-3', listingId: 'listing-2' }), ]) - prisma.vote.count.mockResolvedValue(0) const result = await nullifyUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -528,8 +526,6 @@ describe('vote-nullification.service', () => { const { logAudit } = await import('@/server/services/audit.service') prisma.vote.findMany.mockResolvedValue([makeHandheldVote()]) prisma.pcListingVote.findMany.mockResolvedValue([makePcVote()]) - prisma.vote.count.mockResolvedValue(0) - prisma.pcListingVote.count.mockResolvedValue(0) await nullifyUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -559,10 +555,6 @@ describe('vote-nullification.service', () => { prisma.pcListingCommentVote.findMany.mockResolvedValue([ makePcCommentVote({ id: 'pcv-1', value: false }), ]) - prisma.vote.count.mockResolvedValue(0) - prisma.pcListingVote.count.mockResolvedValue(0) - prisma.commentVote.count.mockResolvedValue(0) - prisma.pcListingCommentVote.count.mockResolvedValue(0) const result = await nullifyUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -606,7 +598,6 @@ describe('vote-nullification.service', () => { { ...makeHandheldVote({ id: 'hv-2' }), nullifiedAt: new Date() }, ] prisma.vote.findMany.mockResolvedValue(nullifiedVotes) - prisma.vote.count.mockResolvedValue(0) const result = await restoreUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -624,7 +615,6 @@ describe('vote-nullification.service', () => { it('clears nullifiedAt on PC votes', async () => { const nullifiedVotes = [{ ...makePcVote({ id: 'pv-1' }), nullifiedAt: new Date() }] prisma.pcListingVote.findMany.mockResolvedValue(nullifiedVotes) - prisma.pcListingVote.count.mockResolvedValue(0) const result = await restoreUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -646,8 +636,6 @@ describe('vote-nullification.service', () => { prisma.pcListingCommentVote.findMany.mockResolvedValue([ { ...makePcCommentVote({ id: 'pcv-1' }), nullifiedAt: new Date() }, ]) - prisma.commentVote.count.mockResolvedValue(0) - prisma.pcListingCommentVote.count.mockResolvedValue(0) const result = await restoreUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -666,9 +654,10 @@ describe('vote-nullification.service', () => { nullifiedAt: new Date(), }, ]) - prisma.vote.count - .mockResolvedValueOnce(5) // upvotes after restore - .mockResolvedValueOnce(2) // downvotes after restore + prisma.vote.groupBy.mockResolvedValue([ + { listingId: 'listing-1', value: true, _count: { _all: 5 } }, + { listingId: 'listing-1', value: false, _count: { _all: 2 } }, + ]) await restoreUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -691,9 +680,10 @@ describe('vote-nullification.service', () => { prisma.commentVote.findMany.mockResolvedValue([ { ...makeCommentVote({ id: 'cv-1', commentId: 'comment-1' }), nullifiedAt: new Date() }, ]) - prisma.commentVote.count - .mockResolvedValueOnce(4) // upvotes - .mockResolvedValueOnce(1) // downvotes + prisma.commentVote.groupBy.mockResolvedValue([ + { commentId: 'comment-1', value: true, _count: { _all: 4 } }, + { commentId: 'comment-1', value: false, _count: { _all: 1 } }, + ]) await restoreUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -715,7 +705,6 @@ describe('vote-nullification.service', () => { nullifiedAt: new Date(), }, ]) - prisma.vote.count.mockResolvedValue(0) await restoreUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -724,11 +713,11 @@ describe('vote-nullification.service', () => { }) // Voter should get +1 per handheld vote (total +2) - const voterCall = mockApplyManualTrustAdjustment.mock.calls.find( - (call: unknown[]) => (call[0] as { userId: string }).userId === USER_ID, - ) - expect(voterCall).toBeDefined() - expect((voterCall![0] as { adjustment: number }).adjustment).toBe(2) + expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1) + const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as { + adjustments: Map + } + expect(adjustments.get(USER_ID)).toBe(2) }) it('re-applies author trust: +2 for upvotes, -1 for downvotes', async () => { @@ -742,7 +731,6 @@ describe('vote-nullification.service', () => { nullifiedAt: new Date(), }, ]) - prisma.vote.count.mockResolvedValue(0) await restoreUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -751,11 +739,11 @@ describe('vote-nullification.service', () => { }) // Author A: upvote re-applied (+2) + downvote re-applied (-1) = +1 - const authorCall = mockApplyManualTrustAdjustment.mock.calls.find( - (call: unknown[]) => (call[0] as { userId: string }).userId === AUTHOR_A, - ) - expect(authorCall).toBeDefined() - expect((authorCall![0] as { adjustment: number }).adjustment).toBe(1) + expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1) + const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as { + adjustments: Map + } + expect(adjustments.get(AUTHOR_A)).toBe(1) }) it('skips author trust re-application for self-votes', async () => { @@ -765,7 +753,6 @@ describe('vote-nullification.service', () => { nullifiedAt: new Date(), }, ]) - prisma.vote.count.mockResolvedValue(0) await restoreUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -773,17 +760,22 @@ describe('vote-nullification.service', () => { reason: 'Wrongful ban', }) - expect(mockApplyManualTrustAdjustment).toHaveBeenCalledTimes(1) - expect(mockApplyManualTrustAdjustment).toHaveBeenCalledWith( - expect.objectContaining({ userId: USER_ID, adjustment: 1 }), - ) + expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1) + const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as { + adjustments: Map + } + expect(adjustments.get(USER_ID)).toBe(1) + expect(adjustments.size).toBe(1) }) - it('does NOT re-apply trust for PC votes', async () => { + it('re-applies trust for PC votes (same as handheld)', async () => { prisma.pcListingVote.findMany.mockResolvedValue([ - { ...makePcVote({ id: 'pv-1' }), nullifiedAt: new Date() }, + { ...makePcVote({ id: 'pv-1', value: true, authorId: AUTHOR_B }), nullifiedAt: new Date() }, + { + ...makePcVote({ id: 'pv-2', value: false, authorId: AUTHOR_B }), + nullifiedAt: new Date(), + }, ]) - prisma.pcListingVote.count.mockResolvedValue(0) await restoreUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -791,7 +783,14 @@ describe('vote-nullification.service', () => { reason: 'Wrongful ban', }) - expect(mockApplyManualTrustAdjustment).not.toHaveBeenCalled() + expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1) + const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as { + adjustments: Map + } + // Voter: +1 per vote = +2 total + expect(adjustments.get(USER_ID)).toBe(2) + // Author B: upvote re-applied (+2) + downvote re-applied (-1) = +1 + expect(adjustments.get(AUTHOR_B)).toBe(1) }) it('batches restoration for more than 100 votes', async () => { @@ -800,7 +799,6 @@ describe('vote-nullification.service', () => { nullifiedAt: new Date(), })) prisma.vote.findMany.mockResolvedValue(votes) - prisma.vote.count.mockResolvedValue(0) await restoreUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -821,8 +819,6 @@ describe('vote-nullification.service', () => { prisma.pcListingVote.findMany.mockResolvedValue([ { ...makePcVote(), nullifiedAt: new Date() }, ]) - prisma.vote.count.mockResolvedValue(0) - prisma.pcListingVote.count.mockResolvedValue(0) await restoreUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -855,7 +851,6 @@ describe('vote-nullification.service', () => { nullifiedAt: new Date(), }, ]) - prisma.commentVote.count.mockResolvedValue(0) await restoreUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -863,19 +858,14 @@ describe('vote-nullification.service', () => { reason: 'Wrongful ban', }) + expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1) + const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as { + adjustments: Map + } // Author A: upvote re-applied (+2) - const authorACall = mockApplyManualTrustAdjustment.mock.calls.find( - (call: unknown[]) => (call[0] as { userId: string }).userId === AUTHOR_A, - ) - expect(authorACall).toBeDefined() - expect((authorACall![0] as { adjustment: number }).adjustment).toBe(2) - + expect(adjustments.get(AUTHOR_A)).toBe(2) // Author B: downvote re-applied (-1) - const authorBCall = mockApplyManualTrustAdjustment.mock.calls.find( - (call: unknown[]) => (call[0] as { userId: string }).userId === AUTHOR_B, - ) - expect(authorBCall).toBeDefined() - expect((authorBCall![0] as { adjustment: number }).adjustment).toBe(-1) + expect(adjustments.get(AUTHOR_B)).toBe(-1) }) }) @@ -890,8 +880,6 @@ describe('vote-nullification.service', () => { // Phase 1: Nullify prisma.vote.findMany.mockResolvedValue(votes) prisma.commentVote.findMany.mockResolvedValue(commentVotes) - prisma.vote.count.mockResolvedValue(0) - prisma.commentVote.count.mockResolvedValue(0) const nullifyResult = await nullifyUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -904,14 +892,15 @@ describe('vote-nullification.service', () => { expect(nullifyResult.commentVotesNullified).toBe(1) // Capture nullification trust adjustments - const nullifyAdjustments = new Map() - for (const call of mockApplyManualTrustAdjustment.mock.calls) { - const { userId, adjustment } = call[0] as { userId: string; adjustment: number } - nullifyAdjustments.set(userId, (nullifyAdjustments.get(userId) ?? 0) + adjustment) - } + expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1) + const nullifyAdjustments = mockApplyBulkManualAdjustments.mock.calls[0][0].adjustments as Map< + string, + number + > // Phase 2: Restore - mockApplyManualTrustAdjustment.mockClear() + mockApplyBulkManualAdjustments.mockClear() + mockApplyBulkManualAdjustments.mockResolvedValue(0) prisma = createMockPrisma() const nullifiedVotes = votes.map((v) => ({ ...v, nullifiedAt: new Date() })) @@ -919,8 +908,6 @@ describe('vote-nullification.service', () => { prisma.vote.findMany.mockResolvedValue(nullifiedVotes) prisma.commentVote.findMany.mockResolvedValue(nullifiedCommentVotes) - prisma.vote.count.mockResolvedValue(0) - prisma.commentVote.count.mockResolvedValue(0) const restoreResult = await restoreUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -932,11 +919,11 @@ describe('vote-nullification.service', () => { expect(restoreResult.commentVotesRestored).toBe(1) // Capture restoration trust adjustments - const restoreAdjustments = new Map() - for (const call of mockApplyManualTrustAdjustment.mock.calls) { - const { userId, adjustment } = call[0] as { userId: string; adjustment: number } - restoreAdjustments.set(userId, (restoreAdjustments.get(userId) ?? 0) + adjustment) - } + expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1) + const restoreAdjustments = mockApplyBulkManualAdjustments.mock.calls[0][0].adjustments as Map< + string, + number + > // Net trust adjustment for each user should be zero for (const [uid, nullifyAdj] of nullifyAdjustments) { @@ -950,7 +937,6 @@ describe('vote-nullification.service', () => { // Phase 1: Nullify prisma.vote.findMany.mockResolvedValue([vote]) - prisma.vote.count.mockResolvedValue(0) await nullifyUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, @@ -968,9 +954,9 @@ describe('vote-nullification.service', () => { // Phase 2: Restore prisma = createMockPrisma() prisma.vote.findMany.mockResolvedValue([{ ...vote, nullifiedAt: new Date() }]) - prisma.vote.count - .mockResolvedValueOnce(1) // upvotes after restore - .mockResolvedValueOnce(0) // downvotes after restore + prisma.vote.groupBy.mockResolvedValue([ + { listingId: 'listing-1', value: true, _count: { _all: 1 } }, + ]) await restoreUserVotes(prisma as unknown as PrismaClient, { userId: USER_ID, diff --git a/src/server/services/vote-nullification.service.ts b/src/server/services/vote-nullification.service.ts index 2f31713c..d91c7e72 100644 --- a/src/server/services/vote-nullification.service.ts +++ b/src/server/services/vote-nullification.service.ts @@ -1,10 +1,11 @@ -import { logger } from '@/lib/logger' import { TRUST_ACTIONS } from '@/lib/trust/config' -import { applyManualTrustAdjustment } from '@/lib/trust/service' +import { TrustService } from '@/lib/trust/service' import { type ListingType } from '@/schemas/common' import { logAudit } from '@/server/services/audit.service' import { calculateWilsonScore } from '@/utils/wilson-score' -import { AuditAction, AuditEntityType, TrustAction, type PrismaClient } from '@orm' +import { AuditAction, AuditEntityType, TrustAction, type Prisma, type PrismaClient } from '@orm' + +type PrismaClientOrTransaction = PrismaClient | Prisma.TransactionClient interface NullifyParams { userId: string @@ -43,86 +44,162 @@ interface RestoreResult { const BATCH_SIZE = 100 +interface VoteIdentifier { + id: string +} + +interface NullifiableDelegate { + updateMany: (args: { + where: { id: { in: string[] } } + data: { nullifiedAt: Date | null } + }) => Promise<{ count: number }> +} + +/** + * Updates `nullifiedAt` on a set of votes in serial batches of `BATCH_SIZE`. + * Batching is serial within one delegate (avoids exhausting the connection pool); + * the caller can run multiple `batchUpdateNullifiedAt` invocations in parallel + * across different delegates (different tables) via `Promise.all`. + */ +async function batchUpdateNullifiedAt( + delegate: NullifiableDelegate, + votes: readonly T[], + nullifiedAt: Date | null, +): Promise { + for (let i = 0; i < votes.length; i += BATCH_SIZE) { + const batch = votes.slice(i, i + BATCH_SIZE) + await delegate.updateMany({ + where: { id: { in: batch.map((v) => v.id) } }, + data: { nullifiedAt }, + }) + } +} + +interface VoteScoreEntry { + up: number + down: number +} + async function recalculateListingScores( - prisma: PrismaClient, + prisma: PrismaClientOrTransaction, listingIds: Set, type: ListingType, ): Promise { - let count = 0 - for (const listingId of listingIds) { - if (type === 'handheld') { - const [upvotes, downvotes] = await Promise.all([ - prisma.vote.count({ where: { listingId, value: true, nullifiedAt: null } }), - prisma.vote.count({ where: { listingId, value: false, nullifiedAt: null } }), - ]) + if (listingIds.size === 0) return 0 + + const ids = [...listingIds] + const scoreMap = new Map() + for (const id of ids) scoreMap.set(id, { up: 0, down: 0 }) + + if (type === 'handheld') { + const counts = await prisma.vote.groupBy({ + by: ['listingId', 'value'], + where: { listingId: { in: ids }, nullifiedAt: null }, + _count: { _all: true }, + }) + for (const row of counts) { + const entry = scoreMap.get(row.listingId) + if (entry) { + if (row.value) entry.up = row._count._all + else entry.down = row._count._all + } + } + for (const [id, { up, down }] of scoreMap) { await prisma.listing.update({ - where: { id: listingId }, + where: { id }, data: { - upvoteCount: upvotes, - downvoteCount: downvotes, - voteCount: upvotes + downvotes, - successRate: calculateWilsonScore(upvotes, downvotes), + upvoteCount: up, + downvoteCount: down, + voteCount: up + down, + successRate: calculateWilsonScore(up, down), }, }) - } else { - const [upvotes, downvotes] = await Promise.all([ - prisma.pcListingVote.count({ - where: { pcListingId: listingId, value: true, nullifiedAt: null }, - }), - prisma.pcListingVote.count({ - where: { pcListingId: listingId, value: false, nullifiedAt: null }, - }), - ]) + } + } else { + const counts = await prisma.pcListingVote.groupBy({ + by: ['pcListingId', 'value'], + where: { pcListingId: { in: ids }, nullifiedAt: null }, + _count: { _all: true }, + }) + for (const row of counts) { + const entry = scoreMap.get(row.pcListingId) + if (entry) { + if (row.value) entry.up = row._count._all + else entry.down = row._count._all + } + } + for (const [id, { up, down }] of scoreMap) { await prisma.pcListing.update({ - where: { id: listingId }, + where: { id }, data: { - upvoteCount: upvotes, - downvoteCount: downvotes, - voteCount: upvotes + downvotes, - successRate: calculateWilsonScore(upvotes, downvotes), + upvoteCount: up, + downvoteCount: down, + voteCount: up + down, + successRate: calculateWilsonScore(up, down), }, }) } - count++ } - return count + + return listingIds.size } async function recalculateCommentScores( - prisma: PrismaClient, + prisma: PrismaClientOrTransaction, commentIds: Set, type: ListingType, ): Promise { - let count = 0 - for (const commentId of commentIds) { - if (type === 'handheld') { - const [upvotes, downvotes] = await Promise.all([ - prisma.commentVote.count({ where: { commentId, value: true, nullifiedAt: null } }), - prisma.commentVote.count({ where: { commentId, value: false, nullifiedAt: null } }), - ]) + if (commentIds.size === 0) return 0 + + const ids = [...commentIds] + const scoreMap = new Map() + for (const id of ids) scoreMap.set(id, { up: 0, down: 0 }) + + if (type === 'handheld') { + const counts = await prisma.commentVote.groupBy({ + by: ['commentId', 'value'], + where: { commentId: { in: ids }, nullifiedAt: null }, + _count: { _all: true }, + }) + for (const row of counts) { + const entry = scoreMap.get(row.commentId) + if (entry) { + if (row.value) entry.up = row._count._all + else entry.down = row._count._all + } + } + for (const [id, { up, down }] of scoreMap) { await prisma.comment.update({ - where: { id: commentId }, - data: { score: upvotes - downvotes }, + where: { id }, + data: { score: up - down }, }) - } else { - const [upvotes, downvotes] = await Promise.all([ - prisma.pcListingCommentVote.count({ where: { commentId, value: true, nullifiedAt: null } }), - prisma.pcListingCommentVote.count({ - where: { commentId, value: false, nullifiedAt: null }, - }), - ]) + } + } else { + const counts = await prisma.pcListingCommentVote.groupBy({ + by: ['commentId', 'value'], + where: { commentId: { in: ids }, nullifiedAt: null }, + _count: { _all: true }, + }) + for (const row of counts) { + const entry = scoreMap.get(row.commentId) + if (entry) { + if (row.value) entry.up = row._count._all + else entry.down = row._count._all + } + } + for (const [id, { up, down }] of scoreMap) { await prisma.pcListingComment.update({ - where: { id: commentId }, - data: { score: upvotes - downvotes }, + where: { id }, + data: { score: up - down }, }) } - count++ } - return count + + return commentIds.size } export async function nullifyUserVotes( - prisma: PrismaClient, + prisma: PrismaClientOrTransaction, params: NullifyParams, ): Promise { const { userId, adminUserId, reason, includeCommentVotes, headers } = params @@ -175,40 +252,16 @@ export async function nullifyUserVotes( const now = new Date() - // 2. Batch nullify votes - for (let i = 0; i < handheldVotes.length; i += BATCH_SIZE) { - const batch = handheldVotes.slice(i, i + BATCH_SIZE) - await prisma.vote.updateMany({ - where: { id: { in: batch.map((v) => v.id) } }, - data: { nullifiedAt: now }, - }) - } - - for (let i = 0; i < pcVotes.length; i += BATCH_SIZE) { - const batch = pcVotes.slice(i, i + BATCH_SIZE) - await prisma.pcListingVote.updateMany({ - where: { id: { in: batch.map((v) => v.id) } }, - data: { nullifiedAt: now }, - }) - } - - for (let i = 0; i < commentVotes.length; i += BATCH_SIZE) { - const batch = commentVotes.slice(i, i + BATCH_SIZE) - await prisma.commentVote.updateMany({ - where: { id: { in: batch.map((v) => v.id) } }, - data: { nullifiedAt: now }, - }) - } - - for (let i = 0; i < pcCommentVotes.length; i += BATCH_SIZE) { - const batch = pcCommentVotes.slice(i, i + BATCH_SIZE) - await prisma.pcListingCommentVote.updateMany({ - where: { id: { in: batch.map((v) => v.id) } }, - data: { nullifiedAt: now }, - }) - } + // 2. Batch nullify votes — across different vote types in parallel, + // serially batched within each type to keep connection-pool pressure bounded. + await Promise.all([ + batchUpdateNullifiedAt(prisma.vote, handheldVotes, now), + batchUpdateNullifiedAt(prisma.pcListingVote, pcVotes, now), + batchUpdateNullifiedAt(prisma.commentVote, commentVotes, now), + batchUpdateNullifiedAt(prisma.pcListingCommentVote, pcCommentVotes, now), + ]) - // 3. Recalculate listing scores + // 3. Recalculate listing scores — parallel across distinct tables. const handheldListingIds = new Set(handheldVotes.map((v) => v.listingId)) const pcListingIds = new Set(pcVotes.map((v) => v.pcListingId)) const handheldCommentIds = new Set(commentVotes.map((v) => v.commentId)) @@ -229,69 +282,56 @@ export async function nullifyUserVotes( trustAdjustments.set(targetUserId, (trustAdjustments.get(targetUserId) ?? 0) + amount) } - // Handheld listing votes: reverse trust for voter and authors + // Reverse listing vote trust for both handheld and PC for (const vote of handheldVotes) { - // Reverse voter trust: voter got +1 for either UPVOTE or DOWNVOTE - addAdjustment(userId, -TRUST_ACTIONS[TrustAction.UPVOTE].weight) - - // Reverse author trust (skip self-votes) + const voterAction = vote.value ? TrustAction.UPVOTE : TrustAction.DOWNVOTE + addAdjustment(userId, -TRUST_ACTIONS[voterAction].weight) if (vote.listing.authorId && vote.listing.authorId !== userId) { - if (vote.value) { - // Was upvote: author got +2 (LISTING_RECEIVED_UPVOTE), reverse it - addAdjustment( - vote.listing.authorId, - -TRUST_ACTIONS[TrustAction.LISTING_RECEIVED_UPVOTE].weight, - ) - } else { - // Was downvote: author got -1 (LISTING_RECEIVED_DOWNVOTE), reverse it - addAdjustment( - vote.listing.authorId, - -TRUST_ACTIONS[TrustAction.LISTING_RECEIVED_DOWNVOTE].weight, - ) - } + const action = vote.value + ? TrustAction.LISTING_RECEIVED_UPVOTE + : TrustAction.LISTING_RECEIVED_DOWNVOTE + addAdjustment(vote.listing.authorId, -TRUST_ACTIONS[action].weight) + } + } + + for (const vote of pcVotes) { + const voterAction = vote.value ? TrustAction.UPVOTE : TrustAction.DOWNVOTE + addAdjustment(userId, -TRUST_ACTIONS[voterAction].weight) + if (vote.pcListing.authorId && vote.pcListing.authorId !== userId) { + const action = vote.value + ? TrustAction.LISTING_RECEIVED_UPVOTE + : TrustAction.LISTING_RECEIVED_DOWNVOTE + addAdjustment(vote.pcListing.authorId, -TRUST_ACTIONS[action].weight) } } - // Handheld comment votes: reverse author trust (skip self-votes) + // Reverse comment vote trust for both handheld and PC for (const vote of commentVotes) { if (vote.comment.userId !== userId) { - if (vote.value) { - addAdjustment( - vote.comment.userId, - -TRUST_ACTIONS[TrustAction.COMMENT_RECEIVED_UPVOTE].weight, - ) - } else { - addAdjustment( - vote.comment.userId, - -TRUST_ACTIONS[TrustAction.COMMENT_RECEIVED_DOWNVOTE].weight, - ) - } + const action = vote.value + ? TrustAction.COMMENT_RECEIVED_UPVOTE + : TrustAction.COMMENT_RECEIVED_DOWNVOTE + addAdjustment(vote.comment.userId, -TRUST_ACTIONS[action].weight) } } - // PC votes: no trust reversal (trust was never applied for PC votes) - // PC comment votes: no trust reversal (trust was never applied) - - // Apply trust adjustments - let trustAdjustmentCount = 0 - for (const [targetUserId, adjustment] of trustAdjustments) { - if (adjustment === 0) continue - try { - await applyManualTrustAdjustment({ - userId: targetUserId, - adjustment, - reason: `Vote nullification: ${reason}`, - adminUserId, - }) - trustAdjustmentCount++ - } catch (err) { - logger.error( - `[vote-nullification] Failed to apply trust adjustment for user ${targetUserId}:`, - err, - ) + for (const vote of pcCommentVotes) { + if (vote.comment.userId !== userId) { + const action = vote.value + ? TrustAction.COMMENT_RECEIVED_UPVOTE + : TrustAction.COMMENT_RECEIVED_DOWNVOTE + addAdjustment(vote.comment.userId, -TRUST_ACTIONS[action].weight) } } + // Apply trust adjustments using TrustService for transaction participation + const trustService = new TrustService(prisma) + const trustAdjustmentCount = await trustService.applyBulkManualAdjustments({ + adjustments: trustAdjustments, + reason: `Vote nullification: ${reason}`, + adminUserId, + }) + // 5. Audit log void logAudit(prisma, { actorId: adminUserId, @@ -323,7 +363,7 @@ export async function nullifyUserVotes( } export async function restoreUserVotes( - prisma: PrismaClient, + prisma: PrismaClientOrTransaction, params: RestoreParams, ): Promise { const { userId, adminUserId, reason, headers } = params @@ -370,40 +410,15 @@ export async function restoreUserVotes( } } - // 2. Clear nullifiedAt - for (let i = 0; i < handheldVotes.length; i += BATCH_SIZE) { - const batch = handheldVotes.slice(i, i + BATCH_SIZE) - await prisma.vote.updateMany({ - where: { id: { in: batch.map((v) => v.id) } }, - data: { nullifiedAt: null }, - }) - } - - for (let i = 0; i < pcVotes.length; i += BATCH_SIZE) { - const batch = pcVotes.slice(i, i + BATCH_SIZE) - await prisma.pcListingVote.updateMany({ - where: { id: { in: batch.map((v) => v.id) } }, - data: { nullifiedAt: null }, - }) - } - - for (let i = 0; i < commentVotes.length; i += BATCH_SIZE) { - const batch = commentVotes.slice(i, i + BATCH_SIZE) - await prisma.commentVote.updateMany({ - where: { id: { in: batch.map((v) => v.id) } }, - data: { nullifiedAt: null }, - }) - } - - for (let i = 0; i < pcCommentVotes.length; i += BATCH_SIZE) { - const batch = pcCommentVotes.slice(i, i + BATCH_SIZE) - await prisma.pcListingCommentVote.updateMany({ - where: { id: { in: batch.map((v) => v.id) } }, - data: { nullifiedAt: null }, - }) - } + // 2. Clear nullifiedAt — parallel across vote types, serial batches within each type. + await Promise.all([ + batchUpdateNullifiedAt(prisma.vote, handheldVotes, null), + batchUpdateNullifiedAt(prisma.pcListingVote, pcVotes, null), + batchUpdateNullifiedAt(prisma.commentVote, commentVotes, null), + batchUpdateNullifiedAt(prisma.pcListingCommentVote, pcCommentVotes, null), + ]) - // 3. Recalculate scores + // 3. Recalculate scores — parallel across distinct tables. const handheldListingIds = new Set(handheldVotes.map((v) => v.listingId)) const pcListingIds = new Set(pcVotes.map((v) => v.pcListingId)) const handheldCommentIds = new Set(commentVotes.map((v) => v.commentId)) @@ -423,61 +438,55 @@ export async function restoreUserVotes( trustAdjustments.set(targetUserId, (trustAdjustments.get(targetUserId) ?? 0) + amount) } + // Re-apply listing vote trust for both handheld and PC for (const vote of handheldVotes) { - // Re-apply voter trust - addAdjustment(userId, TRUST_ACTIONS[TrustAction.UPVOTE].weight) - - // Re-apply author trust (skip self-votes) + const voterAction = vote.value ? TrustAction.UPVOTE : TrustAction.DOWNVOTE + addAdjustment(userId, TRUST_ACTIONS[voterAction].weight) if (vote.listing.authorId && vote.listing.authorId !== userId) { - if (vote.value) { - addAdjustment( - vote.listing.authorId, - TRUST_ACTIONS[TrustAction.LISTING_RECEIVED_UPVOTE].weight, - ) - } else { - addAdjustment( - vote.listing.authorId, - TRUST_ACTIONS[TrustAction.LISTING_RECEIVED_DOWNVOTE].weight, - ) - } + const action = vote.value + ? TrustAction.LISTING_RECEIVED_UPVOTE + : TrustAction.LISTING_RECEIVED_DOWNVOTE + addAdjustment(vote.listing.authorId, TRUST_ACTIONS[action].weight) + } + } + + for (const vote of pcVotes) { + const voterAction = vote.value ? TrustAction.UPVOTE : TrustAction.DOWNVOTE + addAdjustment(userId, TRUST_ACTIONS[voterAction].weight) + if (vote.pcListing.authorId && vote.pcListing.authorId !== userId) { + const action = vote.value + ? TrustAction.LISTING_RECEIVED_UPVOTE + : TrustAction.LISTING_RECEIVED_DOWNVOTE + addAdjustment(vote.pcListing.authorId, TRUST_ACTIONS[action].weight) } } + // Re-apply comment vote trust for both handheld and PC for (const vote of commentVotes) { if (vote.comment.userId !== userId) { - if (vote.value) { - addAdjustment( - vote.comment.userId, - TRUST_ACTIONS[TrustAction.COMMENT_RECEIVED_UPVOTE].weight, - ) - } else { - addAdjustment( - vote.comment.userId, - TRUST_ACTIONS[TrustAction.COMMENT_RECEIVED_DOWNVOTE].weight, - ) - } + const action = vote.value + ? TrustAction.COMMENT_RECEIVED_UPVOTE + : TrustAction.COMMENT_RECEIVED_DOWNVOTE + addAdjustment(vote.comment.userId, TRUST_ACTIONS[action].weight) } } - let trustAdjustmentCount = 0 - for (const [targetUserId, adjustment] of trustAdjustments) { - if (adjustment === 0) continue - try { - await applyManualTrustAdjustment({ - userId: targetUserId, - adjustment, - reason: `Vote restoration: ${reason}`, - adminUserId, - }) - trustAdjustmentCount++ - } catch (err) { - logger.error( - `[vote-restoration] Failed to apply trust adjustment for user ${targetUserId}:`, - err, - ) + for (const vote of pcCommentVotes) { + if (vote.comment.userId !== userId) { + const action = vote.value + ? TrustAction.COMMENT_RECEIVED_UPVOTE + : TrustAction.COMMENT_RECEIVED_DOWNVOTE + addAdjustment(vote.comment.userId, TRUST_ACTIONS[action].weight) } } + const trustService = new TrustService(prisma) + const trustAdjustmentCount = await trustService.applyBulkManualAdjustments({ + adjustments: trustAdjustments, + reason: `Vote restoration: ${reason}`, + adminUserId, + }) + // 5. Audit log void logAudit(prisma, { actorId: adminUserId, diff --git a/src/server/utils/moderator-info.test.ts b/src/server/utils/moderator-info.test.ts new file mode 100644 index 00000000..fc32b36e --- /dev/null +++ b/src/server/utils/moderator-info.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { computeVoteCounts, type VoteForCounting } from './moderator-info' + +function vote(value: boolean, nullifiedAt: Date | null = null): VoteForCounting { + return { value, nullifiedAt } +} + +describe('computeVoteCounts', () => { + it('returns zeroes for an empty array', () => { + expect(computeVoteCounts([])).toEqual({ up: 0, down: 0, nullified: 0 }) + }) + + it('counts only active upvotes in `up`', () => { + const result = computeVoteCounts([vote(true), vote(true), vote(true)]) + expect(result).toEqual({ up: 3, down: 0, nullified: 0 }) + }) + + it('counts only active downvotes in `down`', () => { + const result = computeVoteCounts([vote(false), vote(false)]) + expect(result).toEqual({ up: 0, down: 2, nullified: 0 }) + }) + + it('counts nullified votes separately, regardless of value', () => { + const result = computeVoteCounts([ + vote(true, new Date('2026-01-01')), + vote(false, new Date('2026-01-02')), + vote(true, new Date('2026-01-03')), + ]) + expect(result).toEqual({ up: 0, down: 0, nullified: 3 }) + }) + + it('returns the correct mix when active and nullified votes are interleaved', () => { + const result = computeVoteCounts([ + vote(true), // up + vote(true, new Date()), // nullified + vote(false), // down + vote(false, new Date()), // nullified + vote(true), // up + vote(true), // up + vote(false), // down + ]) + expect(result).toEqual({ up: 3, down: 2, nullified: 2 }) + }) + + it('does NOT double-count: a nullified upvote contributes only to `nullified`', () => { + const result = computeVoteCounts([vote(true, new Date('2026-01-01'))]) + expect(result.up).toBe(0) + expect(result.down).toBe(0) + expect(result.nullified).toBe(1) + }) +}) diff --git a/src/server/utils/moderator-info.ts b/src/server/utils/moderator-info.ts new file mode 100644 index 00000000..c1ec794d --- /dev/null +++ b/src/server/utils/moderator-info.ts @@ -0,0 +1,27 @@ +export interface VoteCounts { + up: number + down: number + nullified: number +} + +// A nullified vote counts only in `nullified`, not in up/down. +export interface VoteForCounting { + value: boolean + nullifiedAt: Date | null +} + +export function computeVoteCounts(votes: readonly VoteForCounting[]): VoteCounts { + let up = 0 + let down = 0 + let nullified = 0 + for (const vote of votes) { + if (vote.nullifiedAt !== null) { + nullified += 1 + } else if (vote.value) { + up += 1 + } else { + down += 1 + } + } + return { up, down, nullified } +} diff --git a/src/server/utils/spam-check.test.ts b/src/server/utils/spam-check.test.ts new file mode 100644 index 00000000..850a8cce --- /dev/null +++ b/src/server/utils/spam-check.test.ts @@ -0,0 +1,107 @@ +import { TRPCError } from '@trpc/server' +import { afterEach, describe, expect, it, vi } from 'vitest' +import analytics from '@/lib/analytics' +import { type PrismaClient } from '@orm' +import { checkSpamContent } from './spam-check' +import { SpamDetectionService } from './spamDetection' + +vi.mock('@/lib/analytics', () => ({ + default: { + contentQuality: { + spamDetected: vi.fn(), + }, + }, +})) + +const mockPrisma = {} as unknown as PrismaClient + +afterEach(() => vi.restoreAllMocks()) + +describe('checkSpamContent', () => { + const USER_ID = 'user-123' + + it('returns without side effects when content is not spam', async () => { + const detectSpy = vi.spyOn(SpamDetectionService.prototype, 'detectSpam').mockResolvedValue({ + isSpam: false, + confidence: 0.1, + method: 'content_analysis', + }) + + await expect( + checkSpamContent({ + prisma: mockPrisma, + userId: USER_ID, + content: 'Clean content', + entityType: 'listing', + }), + ).resolves.toBeUndefined() + + expect(detectSpy).toHaveBeenCalledOnce() + expect(analytics.contentQuality.spamDetected).not.toHaveBeenCalled() + }) + + it('emits analytics and throws AppError.badRequest with reason when spam detected', async () => { + vi.spyOn(SpamDetectionService.prototype, 'detectSpam').mockResolvedValue({ + isSpam: true, + confidence: 0.95, + method: 'rate_limiting', + reason: 'Exceeded rate limit: 4 listings in 5 minutes', + }) + + await expect( + checkSpamContent({ + prisma: mockPrisma, + userId: USER_ID, + content: 'Flagged content', + entityType: 'listing', + }), + ).rejects.toThrow(/Spam detected: Exceeded rate limit/) + + expect(analytics.contentQuality.spamDetected).toHaveBeenCalledTimes(1) + expect(analytics.contentQuality.spamDetected).toHaveBeenCalledWith({ + entityType: 'listing', + entityId: USER_ID, + confidence: 0.95, + method: 'rate_limiting', + }) + }) + + it('falls back to the community-guidelines message when reason is missing', async () => { + vi.spyOn(SpamDetectionService.prototype, 'detectSpam').mockResolvedValue({ + isSpam: true, + confidence: 0.8, + method: 'pattern_matching', + }) + + await expect( + checkSpamContent({ + prisma: mockPrisma, + userId: USER_ID, + content: 'Looks spammy but no explicit reason', + entityType: 'comment', + }), + ).rejects.toThrow(/community guidelines/i) + }) + + it('throws a BAD_REQUEST TRPCError', async () => { + vi.spyOn(SpamDetectionService.prototype, 'detectSpam').mockResolvedValue({ + isSpam: true, + confidence: 0.9, + method: 'duplicate_detection', + reason: 'Duplicate of recent submission', + }) + + try { + await checkSpamContent({ + prisma: mockPrisma, + userId: USER_ID, + content: 'duplicate', + entityType: 'comment', + }) + throw new Error('Expected checkSpamContent to throw') + } catch (error) { + expect(error).toBeInstanceOf(TRPCError) + expect((error as TRPCError).code).toBe('BAD_REQUEST') + } + }) +}) diff --git a/src/server/utils/spam-check.ts b/src/server/utils/spam-check.ts new file mode 100644 index 00000000..7890aefb --- /dev/null +++ b/src/server/utils/spam-check.ts @@ -0,0 +1,35 @@ +import analytics from '@/lib/analytics' +import { AppError } from '@/lib/errors' +import { SpamDetectionService } from '@/server/utils/spamDetection' +import { type PrismaClient } from '@orm' + +type SpamEntityType = 'listing' | 'comment' + +interface CheckSpamContentParams { + prisma: PrismaClient + userId: string + content: string + entityType: SpamEntityType +} + +export async function checkSpamContent(params: CheckSpamContentParams): Promise { + const detector = new SpamDetectionService(params.prisma) + const result = await detector.detectSpam({ + userId: params.userId, + content: params.content, + entityType: params.entityType, + }) + + if (!result.isSpam) return + + analytics.contentQuality.spamDetected({ + entityType: params.entityType, + entityId: params.userId, + confidence: result.confidence, + method: result.method, + }) + + throw AppError.badRequest( + `Spam detected: ${result.reason || 'Your content appears to be spam. Please review our community guidelines.'}`, + ) +} diff --git a/src/server/utils/spamDetection.ts b/src/server/utils/spamDetection.ts index 6c776ac5..7d7b3a81 100644 --- a/src/server/utils/spamDetection.ts +++ b/src/server/utils/spamDetection.ts @@ -43,7 +43,7 @@ const DEFAULT_CONFIG: Required = { } /** - * Comprehensive spam detection service + * Spam detection service * Checks content for spam using multiple detection methods */ export class SpamDetectionService { diff --git a/src/server/utils/vote-trust-effects.test.ts b/src/server/utils/vote-trust-effects.test.ts index dd572207..825c55fb 100644 --- a/src/server/utils/vote-trust-effects.test.ts +++ b/src/server/utils/vote-trust-effects.test.ts @@ -1,283 +1,646 @@ import { describe, expect, it, beforeEach, vi } from 'vitest' import { TrustAction } from '@orm' -import { handleVoteTrustEffects } from './vote-trust-effects' +import { handleCommentVoteTrustEffects, handleListingVoteTrustEffects } from './vote-trust-effects' -const mockApplyTrustAction = vi.fn() -const mockReverseTrustAction = vi.fn() +const mockLogAction = vi.fn() +const mockReverseLogAction = vi.fn() vi.mock('@/lib/trust/service', () => ({ - applyTrustAction: (...args: unknown[]) => mockApplyTrustAction(...args), - reverseTrustAction: (...args: unknown[]) => mockReverseTrustAction(...args), + TrustService: vi.fn().mockImplementation(() => ({ + logAction: mockLogAction, + reverseLogAction: mockReverseLogAction, + })), })) const USER_ID = 'voter-1' const AUTHOR_ID = 'author-1' const LISTING_ID = 'listing-1' +const mockTx = {} as Parameters[0]['tx'] -describe('handleVoteTrustEffects', () => { - beforeEach(() => { - mockApplyTrustAction.mockResolvedValue(undefined) - mockReverseTrustAction.mockResolvedValue(undefined) - }) +beforeEach(() => { + vi.clearAllMocks() + mockLogAction.mockResolvedValue(undefined) + mockReverseLogAction.mockResolvedValue(undefined) +}) - describe('vote created', () => { - it('applies UPVOTE trust for voter on upvote', async () => { - await handleVoteTrustEffects({ +describe('handleListingVoteTrustEffects', () => { + describe('created action', () => { + it('applies UPVOTE for voter and LISTING_RECEIVED_UPVOTE for author on upvote', async () => { + await handleListingVoteTrustEffects({ + tx: mockTx, action: 'created', currentValue: true, previousValue: null, userId: USER_ID, listingId: LISTING_ID, + listingType: 'handheld', authorId: AUTHOR_ID, }) - expect(mockApplyTrustAction).toHaveBeenCalledWith({ + expect(mockLogAction).toHaveBeenCalledTimes(2) + expect(mockLogAction).toHaveBeenNthCalledWith(1, { userId: USER_ID, action: TrustAction.UPVOTE, - context: { listingId: LISTING_ID }, + metadata: { listingId: LISTING_ID }, }) - }) - - it('applies DOWNVOTE trust for voter on downvote', async () => { - await handleVoteTrustEffects({ - action: 'created', - currentValue: false, - previousValue: null, - userId: USER_ID, - listingId: LISTING_ID, - authorId: AUTHOR_ID, - }) - - expect(mockApplyTrustAction).toHaveBeenCalledWith({ - userId: USER_ID, - action: TrustAction.DOWNVOTE, - context: { listingId: LISTING_ID }, - }) - }) - - it('applies LISTING_RECEIVED_UPVOTE trust for author on upvote', async () => { - await handleVoteTrustEffects({ - action: 'created', - currentValue: true, - previousValue: null, - userId: USER_ID, - listingId: LISTING_ID, - authorId: AUTHOR_ID, - }) - - expect(mockApplyTrustAction).toHaveBeenCalledWith({ + expect(mockLogAction).toHaveBeenNthCalledWith(2, { userId: AUTHOR_ID, action: TrustAction.LISTING_RECEIVED_UPVOTE, - context: { listingId: LISTING_ID, voterId: USER_ID }, + metadata: { listingId: LISTING_ID, voterId: USER_ID }, }) + expect(mockReverseLogAction).not.toHaveBeenCalled() }) - it('applies LISTING_RECEIVED_DOWNVOTE trust for author on downvote', async () => { - await handleVoteTrustEffects({ + it('applies DOWNVOTE for voter and LISTING_RECEIVED_DOWNVOTE for author on downvote', async () => { + await handleListingVoteTrustEffects({ + tx: mockTx, action: 'created', currentValue: false, previousValue: null, userId: USER_ID, listingId: LISTING_ID, + listingType: 'handheld', authorId: AUTHOR_ID, }) - expect(mockApplyTrustAction).toHaveBeenCalledWith({ + expect(mockLogAction).toHaveBeenCalledWith({ + userId: USER_ID, + action: TrustAction.DOWNVOTE, + metadata: { listingId: LISTING_ID }, + }) + expect(mockLogAction).toHaveBeenCalledWith({ userId: AUTHOR_ID, action: TrustAction.LISTING_RECEIVED_DOWNVOTE, - context: { listingId: LISTING_ID, voterId: USER_ID }, + metadata: { listingId: LISTING_ID, voterId: USER_ID }, }) }) it('skips author trust on self-vote', async () => { - await handleVoteTrustEffects({ + await handleListingVoteTrustEffects({ + tx: mockTx, action: 'created', currentValue: true, previousValue: null, userId: USER_ID, listingId: LISTING_ID, + listingType: 'handheld', authorId: USER_ID, }) - expect(mockApplyTrustAction).toHaveBeenCalledTimes(1) - expect(mockApplyTrustAction).toHaveBeenCalledWith( + expect(mockLogAction).toHaveBeenCalledTimes(1) + expect(mockLogAction).toHaveBeenCalledWith( expect.objectContaining({ userId: USER_ID, action: TrustAction.UPVOTE }), ) }) it('skips author trust when authorId is null', async () => { - await handleVoteTrustEffects({ + await handleListingVoteTrustEffects({ + tx: mockTx, action: 'created', currentValue: true, previousValue: null, userId: USER_ID, listingId: LISTING_ID, + listingType: 'handheld', authorId: null, }) - expect(mockApplyTrustAction).toHaveBeenCalledTimes(1) + expect(mockLogAction).toHaveBeenCalledTimes(1) }) - it('does not call reverseTrustAction', async () => { - await handleVoteTrustEffects({ + it('uses pcListingId context key for PC listings', async () => { + await handleListingVoteTrustEffects({ + tx: mockTx, action: 'created', currentValue: true, previousValue: null, userId: USER_ID, listingId: LISTING_ID, + listingType: 'pc', authorId: AUTHOR_ID, }) - expect(mockReverseTrustAction).not.toHaveBeenCalled() + expect(mockLogAction).toHaveBeenCalledWith({ + userId: USER_ID, + action: TrustAction.UPVOTE, + metadata: { pcListingId: LISTING_ID }, + }) + expect(mockLogAction).toHaveBeenCalledWith({ + userId: AUTHOR_ID, + action: TrustAction.LISTING_RECEIVED_UPVOTE, + metadata: { pcListingId: LISTING_ID, voterId: USER_ID }, + }) }) }) - describe('vote updated', () => { - it('applies trust for the new vote value', async () => { - await handleVoteTrustEffects({ + describe('updated action (vote change — reverse then apply)', () => { + it('reverses previous upvote and applies downvote when changing from up to down', async () => { + await handleListingVoteTrustEffects({ + tx: mockTx, action: 'updated', currentValue: false, previousValue: true, userId: USER_ID, listingId: LISTING_ID, + listingType: 'handheld', authorId: AUTHOR_ID, }) - expect(mockApplyTrustAction).toHaveBeenCalledWith({ + // Expect 2 reversals (voter + author) then 2 applications (voter + author) + expect(mockReverseLogAction).toHaveBeenCalledTimes(2) + expect(mockReverseLogAction).toHaveBeenCalledWith({ + userId: USER_ID, + originalAction: TrustAction.UPVOTE, + metadata: { listingId: LISTING_ID }, + }) + expect(mockReverseLogAction).toHaveBeenCalledWith({ + userId: AUTHOR_ID, + originalAction: TrustAction.LISTING_RECEIVED_UPVOTE, + metadata: { listingId: LISTING_ID, voterId: USER_ID }, + }) + + expect(mockLogAction).toHaveBeenCalledTimes(2) + expect(mockLogAction).toHaveBeenCalledWith({ userId: USER_ID, action: TrustAction.DOWNVOTE, - context: { listingId: LISTING_ID }, + metadata: { listingId: LISTING_ID }, }) - expect(mockApplyTrustAction).toHaveBeenCalledWith({ + expect(mockLogAction).toHaveBeenCalledWith({ userId: AUTHOR_ID, action: TrustAction.LISTING_RECEIVED_DOWNVOTE, - context: { listingId: LISTING_ID, voterId: USER_ID }, + metadata: { listingId: LISTING_ID, voterId: USER_ID }, }) }) - }) - describe('vote deleted (toggled off)', () => { - it('reverses UPVOTE trust for voter when upvote was toggled off', async () => { - await handleVoteTrustEffects({ - action: 'deleted', + it('reverses previous downvote and applies upvote when changing from down to up', async () => { + await handleListingVoteTrustEffects({ + tx: mockTx, + action: 'updated', currentValue: true, - previousValue: true, + previousValue: false, userId: USER_ID, listingId: LISTING_ID, + listingType: 'handheld', authorId: AUTHOR_ID, }) - expect(mockReverseTrustAction).toHaveBeenCalledWith({ + expect(mockReverseLogAction).toHaveBeenCalledWith({ userId: USER_ID, - originalAction: TrustAction.UPVOTE, - context: { listingId: LISTING_ID }, + originalAction: TrustAction.DOWNVOTE, + metadata: { listingId: LISTING_ID }, + }) + expect(mockReverseLogAction).toHaveBeenCalledWith({ + userId: AUTHOR_ID, + originalAction: TrustAction.LISTING_RECEIVED_DOWNVOTE, + metadata: { listingId: LISTING_ID, voterId: USER_ID }, + }) + expect(mockLogAction).toHaveBeenCalledWith({ + userId: USER_ID, + action: TrustAction.UPVOTE, + metadata: { listingId: LISTING_ID }, + }) + expect(mockLogAction).toHaveBeenCalledWith({ + userId: AUTHOR_ID, + action: TrustAction.LISTING_RECEIVED_UPVOTE, + metadata: { listingId: LISTING_ID, voterId: USER_ID }, }) }) - it('reverses DOWNVOTE trust for voter when downvote was toggled off', async () => { - await handleVoteTrustEffects({ - action: 'deleted', + it('does nothing when previousValue is null (safety)', async () => { + await handleListingVoteTrustEffects({ + tx: mockTx, + action: 'updated', currentValue: false, - previousValue: false, + previousValue: null, userId: USER_ID, listingId: LISTING_ID, + listingType: 'handheld', authorId: AUTHOR_ID, }) - expect(mockReverseTrustAction).toHaveBeenCalledWith({ + expect(mockReverseLogAction).not.toHaveBeenCalled() + expect(mockLogAction).not.toHaveBeenCalled() + }) + + it('skips author reversals/applications on self-vote', async () => { + await handleListingVoteTrustEffects({ + tx: mockTx, + action: 'updated', + currentValue: false, + previousValue: true, userId: USER_ID, - originalAction: TrustAction.DOWNVOTE, - context: { listingId: LISTING_ID }, + listingId: LISTING_ID, + listingType: 'handheld', + authorId: USER_ID, + }) + + // Only voter-side trust calls, one reverse and one apply + expect(mockReverseLogAction).toHaveBeenCalledTimes(1) + expect(mockLogAction).toHaveBeenCalledTimes(1) + }) + + it('uses pcListingId for PC listings on update', async () => { + await handleListingVoteTrustEffects({ + tx: mockTx, + action: 'updated', + currentValue: false, + previousValue: true, + userId: USER_ID, + listingId: LISTING_ID, + listingType: 'pc', + authorId: AUTHOR_ID, }) + + expect(mockReverseLogAction).toHaveBeenCalledWith( + expect.objectContaining({ metadata: { pcListingId: LISTING_ID } }), + ) + expect(mockLogAction).toHaveBeenCalledWith( + expect.objectContaining({ metadata: { pcListingId: LISTING_ID } }), + ) }) + }) - it('reverses LISTING_RECEIVED_UPVOTE trust for author when upvote was toggled off', async () => { - await handleVoteTrustEffects({ + describe('deleted action (vote toggled off)', () => { + it('reverses UPVOTE and LISTING_RECEIVED_UPVOTE when an upvote is toggled off', async () => { + await handleListingVoteTrustEffects({ + tx: mockTx, action: 'deleted', currentValue: true, previousValue: true, userId: USER_ID, listingId: LISTING_ID, + listingType: 'handheld', authorId: AUTHOR_ID, }) - expect(mockReverseTrustAction).toHaveBeenCalledWith({ + expect(mockReverseLogAction).toHaveBeenCalledTimes(2) + expect(mockReverseLogAction).toHaveBeenCalledWith({ + userId: USER_ID, + originalAction: TrustAction.UPVOTE, + metadata: { listingId: LISTING_ID }, + }) + expect(mockReverseLogAction).toHaveBeenCalledWith({ userId: AUTHOR_ID, originalAction: TrustAction.LISTING_RECEIVED_UPVOTE, - context: { listingId: LISTING_ID, voterId: USER_ID }, + metadata: { listingId: LISTING_ID, voterId: USER_ID }, }) + expect(mockLogAction).not.toHaveBeenCalled() }) - it('reverses LISTING_RECEIVED_DOWNVOTE trust for author when downvote was toggled off', async () => { - await handleVoteTrustEffects({ + it('reverses DOWNVOTE and LISTING_RECEIVED_DOWNVOTE when a downvote is toggled off', async () => { + await handleListingVoteTrustEffects({ + tx: mockTx, action: 'deleted', currentValue: false, previousValue: false, userId: USER_ID, listingId: LISTING_ID, + listingType: 'handheld', authorId: AUTHOR_ID, }) - expect(mockReverseTrustAction).toHaveBeenCalledWith({ + expect(mockReverseLogAction).toHaveBeenCalledWith({ + userId: USER_ID, + originalAction: TrustAction.DOWNVOTE, + metadata: { listingId: LISTING_ID }, + }) + expect(mockReverseLogAction).toHaveBeenCalledWith({ userId: AUTHOR_ID, originalAction: TrustAction.LISTING_RECEIVED_DOWNVOTE, - context: { listingId: LISTING_ID, voterId: USER_ID }, + metadata: { listingId: LISTING_ID, voterId: USER_ID }, }) }) - it('skips author trust reversal on self-vote toggle-off', async () => { - await handleVoteTrustEffects({ + it('does nothing when previousValue is null (safety)', async () => { + await handleListingVoteTrustEffects({ + tx: mockTx, action: 'deleted', currentValue: true, - previousValue: true, + previousValue: null, userId: USER_ID, listingId: LISTING_ID, - authorId: USER_ID, + listingType: 'handheld', + authorId: AUTHOR_ID, }) - expect(mockReverseTrustAction).toHaveBeenCalledTimes(1) - expect(mockReverseTrustAction).toHaveBeenCalledWith( - expect.objectContaining({ userId: USER_ID, originalAction: TrustAction.UPVOTE }), - ) + expect(mockReverseLogAction).not.toHaveBeenCalled() + expect(mockLogAction).not.toHaveBeenCalled() }) - it('skips author trust reversal when authorId is null', async () => { - await handleVoteTrustEffects({ + it('skips author reversal on self-vote toggle-off', async () => { + await handleListingVoteTrustEffects({ + tx: mockTx, action: 'deleted', currentValue: true, previousValue: true, userId: USER_ID, listingId: LISTING_ID, - authorId: null, + listingType: 'handheld', + authorId: USER_ID, }) - expect(mockReverseTrustAction).toHaveBeenCalledTimes(1) + expect(mockReverseLogAction).toHaveBeenCalledTimes(1) + expect(mockReverseLogAction).toHaveBeenCalledWith( + expect.objectContaining({ userId: USER_ID, originalAction: TrustAction.UPVOTE }), + ) }) - it('does not reverse trust when previousValue is null', async () => { - await handleVoteTrustEffects({ + it('uses pcListingId for PC listings on delete', async () => { + await handleListingVoteTrustEffects({ + tx: mockTx, action: 'deleted', currentValue: true, - previousValue: null, + previousValue: true, userId: USER_ID, listingId: LISTING_ID, + listingType: 'pc', authorId: AUTHOR_ID, }) - expect(mockReverseTrustAction).not.toHaveBeenCalled() + expect(mockReverseLogAction).toHaveBeenCalledWith( + expect.objectContaining({ + originalAction: TrustAction.UPVOTE, + metadata: { pcListingId: LISTING_ID }, + }), + ) + expect(mockReverseLogAction).toHaveBeenCalledWith( + expect.objectContaining({ + originalAction: TrustAction.LISTING_RECEIVED_UPVOTE, + metadata: { pcListingId: LISTING_ID, voterId: USER_ID }, + }), + ) }) + }) +}) - it('does not call applyTrustAction', async () => { - await handleVoteTrustEffects({ - action: 'deleted', - currentValue: true, +describe('handleCommentVoteTrustEffects', () => { + const COMMENT_AUTHOR_ID = 'comment-author-1' + const VOTER_ID = 'voter-1' + const COMMENT_ID = 'comment-1' + const PARENT_ENTITY_ID = 'parent-entity-1' + const commentMockTx = {} as Parameters[0]['tx'] + + function callWithDefaults( + overrides: Partial[0]>, + ) { + return handleCommentVoteTrustEffects({ + tx: commentMockTx, + trustAction: 'upvote', + newValue: true, + previousValue: null, + commentAuthorId: COMMENT_AUTHOR_ID, + voterId: VOTER_ID, + commentId: COMMENT_ID, + parentEntityId: PARENT_ENTITY_ID, + listingType: 'handheld', + updatedScore: 1, + scoreChange: 1, + ...overrides, + }) + } + + describe('self-vote skipping', () => { + it('does nothing when commentAuthorId equals voterId', async () => { + await callWithDefaults({ + commentAuthorId: 'same-user', + voterId: 'same-user', + }) + + expect(mockLogAction).not.toHaveBeenCalled() + expect(mockReverseLogAction).not.toHaveBeenCalled() + }) + }) + + describe('upvote action', () => { + it('logs COMMENT_RECEIVED_UPVOTE for handheld with listingId', async () => { + await callWithDefaults({ trustAction: 'upvote', listingType: 'handheld' }) + + expect(mockLogAction).toHaveBeenCalledWith({ + userId: COMMENT_AUTHOR_ID, + action: TrustAction.COMMENT_RECEIVED_UPVOTE, + targetUserId: VOTER_ID, + metadata: { commentId: COMMENT_ID, voterId: VOTER_ID, listingId: PARENT_ENTITY_ID }, + }) + }) + + it('logs COMMENT_RECEIVED_UPVOTE for PC with pcListingId', async () => { + await callWithDefaults({ trustAction: 'upvote', listingType: 'pc' }) + + expect(mockLogAction).toHaveBeenCalledWith({ + userId: COMMENT_AUTHOR_ID, + action: TrustAction.COMMENT_RECEIVED_UPVOTE, + targetUserId: VOTER_ID, + metadata: { commentId: COMMENT_ID, voterId: VOTER_ID, pcListingId: PARENT_ENTITY_ID }, + }) + }) + }) + + describe('downvote action', () => { + it('logs COMMENT_RECEIVED_DOWNVOTE for handheld', async () => { + await callWithDefaults({ + trustAction: 'downvote', + newValue: false, + listingType: 'handheld', + }) + + expect(mockLogAction).toHaveBeenCalledWith({ + userId: COMMENT_AUTHOR_ID, + action: TrustAction.COMMENT_RECEIVED_DOWNVOTE, + targetUserId: VOTER_ID, + metadata: { commentId: COMMENT_ID, voterId: VOTER_ID, listingId: PARENT_ENTITY_ID }, + }) + }) + }) + + describe('change action (uses reverseLogAction for the reversal)', () => { + it('properly reverses previous downvote then applies upvote when changing to upvote', async () => { + await callWithDefaults({ + trustAction: 'change', + newValue: true, + previousValue: false, + }) + + // Must use reverseLogAction (not logAction) to actually reverse weight + expect(mockReverseLogAction).toHaveBeenCalledTimes(1) + expect(mockReverseLogAction).toHaveBeenCalledWith({ + userId: COMMENT_AUTHOR_ID, + originalAction: TrustAction.COMMENT_RECEIVED_DOWNVOTE, + targetUserId: VOTER_ID, + metadata: { commentId: COMMENT_ID, voterId: VOTER_ID, listingId: PARENT_ENTITY_ID }, + }) + + // Then apply new upvote + expect(mockLogAction).toHaveBeenCalledTimes(1) + expect(mockLogAction).toHaveBeenCalledWith({ + userId: COMMENT_AUTHOR_ID, + action: TrustAction.COMMENT_RECEIVED_UPVOTE, + targetUserId: VOTER_ID, + metadata: { commentId: COMMENT_ID, voterId: VOTER_ID, listingId: PARENT_ENTITY_ID }, + }) + }) + + it('properly reverses previous upvote then applies downvote when changing to downvote', async () => { + await callWithDefaults({ + trustAction: 'change', + newValue: false, previousValue: true, - userId: USER_ID, - listingId: LISTING_ID, - authorId: AUTHOR_ID, }) - expect(mockApplyTrustAction).not.toHaveBeenCalled() + expect(mockReverseLogAction).toHaveBeenCalledTimes(1) + expect(mockReverseLogAction).toHaveBeenCalledWith({ + userId: COMMENT_AUTHOR_ID, + originalAction: TrustAction.COMMENT_RECEIVED_UPVOTE, + targetUserId: VOTER_ID, + metadata: expect.any(Object), + }) + + expect(mockLogAction).toHaveBeenCalledTimes(1) + expect(mockLogAction).toHaveBeenCalledWith({ + userId: COMMENT_AUTHOR_ID, + action: TrustAction.COMMENT_RECEIVED_DOWNVOTE, + targetUserId: VOTER_ID, + metadata: expect.any(Object), + }) + }) + + it('uses pcListingId for PC listing vote changes', async () => { + await callWithDefaults({ + trustAction: 'change', + newValue: true, + previousValue: false, + listingType: 'pc', + }) + + expect(mockReverseLogAction).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ pcListingId: PARENT_ENTITY_ID }), + }), + ) + expect(mockLogAction).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ pcListingId: PARENT_ENTITY_ID }), + }), + ) + }) + }) + + describe('remove action (uses reverseLogAction)', () => { + it('reverses COMMENT_RECEIVED_UPVOTE when previousValue was upvote', async () => { + await callWithDefaults({ + trustAction: 'remove', + previousValue: true, + }) + + expect(mockReverseLogAction).toHaveBeenCalledWith({ + userId: COMMENT_AUTHOR_ID, + originalAction: TrustAction.COMMENT_RECEIVED_UPVOTE, + targetUserId: VOTER_ID, + metadata: { commentId: COMMENT_ID, voterId: VOTER_ID, listingId: PARENT_ENTITY_ID }, + }) + // remove doesn't need to log a new action + expect(mockLogAction).not.toHaveBeenCalled() + }) + + it('reverses COMMENT_RECEIVED_DOWNVOTE when previousValue was downvote', async () => { + await callWithDefaults({ + trustAction: 'remove', + previousValue: false, + }) + + expect(mockReverseLogAction).toHaveBeenCalledWith({ + userId: COMMENT_AUTHOR_ID, + originalAction: TrustAction.COMMENT_RECEIVED_DOWNVOTE, + targetUserId: VOTER_ID, + metadata: expect.any(Object), + }) + }) + + it('does nothing when previousValue is null (safety)', async () => { + await callWithDefaults({ + trustAction: 'remove', + previousValue: null, + }) + + expect(mockReverseLogAction).not.toHaveBeenCalled() + expect(mockLogAction).not.toHaveBeenCalled() + }) + }) + + describe('helpful comment threshold', () => { + it('logs HELPFUL_COMMENT when score crosses 5 from below', async () => { + await callWithDefaults({ + trustAction: 'upvote', + updatedScore: 5, + scoreChange: 1, + }) + + expect(mockLogAction).toHaveBeenCalledWith({ + userId: COMMENT_AUTHOR_ID, + action: TrustAction.HELPFUL_COMMENT, + metadata: { + commentId: COMMENT_ID, + listingId: PARENT_ENTITY_ID, + score: 5, + threshold: 5, + }, + }) + }) + + it('does NOT log HELPFUL_COMMENT when score was already at threshold', async () => { + await callWithDefaults({ + trustAction: 'upvote', + updatedScore: 6, + scoreChange: 1, + }) + + expect(mockLogAction).not.toHaveBeenCalledWith( + expect.objectContaining({ action: TrustAction.HELPFUL_COMMENT }), + ) + }) + + it('does NOT log HELPFUL_COMMENT when score is still below threshold', async () => { + await callWithDefaults({ + trustAction: 'upvote', + updatedScore: 3, + scoreChange: 1, + }) + + expect(mockLogAction).not.toHaveBeenCalledWith( + expect.objectContaining({ action: TrustAction.HELPFUL_COMMENT }), + ) + }) + + it('uses pcListingId in HELPFUL_COMMENT metadata for PC listings', async () => { + await callWithDefaults({ + trustAction: 'upvote', + listingType: 'pc', + updatedScore: 5, + scoreChange: 1, + }) + + expect(mockLogAction).toHaveBeenCalledWith({ + userId: COMMENT_AUTHOR_ID, + action: TrustAction.HELPFUL_COMMENT, + metadata: { + commentId: COMMENT_ID, + pcListingId: PARENT_ENTITY_ID, + score: 5, + threshold: 5, + }, + }) + }) + + it('does NOT log HELPFUL_COMMENT when score drops below threshold', async () => { + await callWithDefaults({ + trustAction: 'downvote', + newValue: false, + updatedScore: 4, + scoreChange: -1, + }) + + expect(mockLogAction).not.toHaveBeenCalledWith( + expect.objectContaining({ action: TrustAction.HELPFUL_COMMENT }), + ) }) }) }) diff --git a/src/server/utils/vote-trust-effects.ts b/src/server/utils/vote-trust-effects.ts index 44180ac8..55bd1f54 100644 --- a/src/server/utils/vote-trust-effects.ts +++ b/src/server/utils/vote-trust-effects.ts @@ -1,53 +1,202 @@ -import { applyTrustAction, reverseTrustAction } from '@/lib/trust/service' -import { TrustAction } from '@orm' +import { TrustService } from '@/lib/trust/service' +import { type ListingType } from '@/schemas/common' +import { TrustAction, type PrismaClient, type Prisma } from '@orm' type VoteAction = 'created' | 'updated' | 'deleted' -interface VoteTrustEffectsParams { +type PrismaTransactionClient = Omit< + PrismaClient, + '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends' +> + +interface ListingVoteTrustParams { + tx: PrismaTransactionClient | Prisma.TransactionClient action: VoteAction currentValue: boolean previousValue: boolean | null userId: string listingId: string + listingType: ListingType authorId: string | null } -export async function handleVoteTrustEffects(params: VoteTrustEffectsParams): Promise { - const { action, currentValue, previousValue, userId, listingId, authorId } = params +/** + * Applies or reverses trust effects when a listing vote is created, updated, or deleted. + * Works for both handheld and PC listings. Must be called within a transaction — pass `tx`. + */ +export async function handleListingVoteTrustEffects(params: ListingVoteTrustParams): Promise { + const { tx, action, currentValue, previousValue, userId, listingId, listingType, authorId } = + params + + const contextKey = listingType === 'handheld' ? 'listingId' : 'pcListingId' + const context = { [contextKey]: listingId } + const trustService = new TrustService(tx) - if (action === 'created' || action === 'updated') { - await applyTrustAction({ + const applyVoterTrust = (value: boolean) => + trustService.logAction({ userId, - action: currentValue ? TrustAction.UPVOTE : TrustAction.DOWNVOTE, - context: { listingId }, + action: value ? TrustAction.UPVOTE : TrustAction.DOWNVOTE, + metadata: context, }) - if (authorId && authorId !== userId) { - await applyTrustAction({ - userId: authorId, - action: currentValue - ? TrustAction.LISTING_RECEIVED_UPVOTE - : TrustAction.LISTING_RECEIVED_DOWNVOTE, - context: { listingId, voterId: userId }, - }) - } + const reverseVoterTrust = (value: boolean) => + trustService.reverseLogAction({ + userId, + originalAction: value ? TrustAction.UPVOTE : TrustAction.DOWNVOTE, + metadata: context, + }) + + const applyAuthorTrust = (value: boolean) => { + if (!authorId || authorId === userId) return Promise.resolve() + return trustService.logAction({ + userId: authorId, + action: value ? TrustAction.LISTING_RECEIVED_UPVOTE : TrustAction.LISTING_RECEIVED_DOWNVOTE, + metadata: { ...context, voterId: userId }, + }) } - if (action === 'deleted' && previousValue !== null) { - await reverseTrustAction({ - userId, - originalAction: previousValue ? TrustAction.UPVOTE : TrustAction.DOWNVOTE, - context: { listingId }, + const reverseAuthorTrust = (value: boolean) => { + if (!authorId || authorId === userId) return Promise.resolve() + return trustService.reverseLogAction({ + userId: authorId, + originalAction: value + ? TrustAction.LISTING_RECEIVED_UPVOTE + : TrustAction.LISTING_RECEIVED_DOWNVOTE, + metadata: { ...context, voterId: userId }, }) + } + + if (action === 'created') { + await applyVoterTrust(currentValue) + await applyAuthorTrust(currentValue) + return + } + + if (action === 'updated' && previousValue !== null) { + await reverseVoterTrust(previousValue) + await reverseAuthorTrust(previousValue) + await applyVoterTrust(currentValue) + await applyAuthorTrust(currentValue) + return + } + + if (action === 'deleted' && previousValue !== null) { + await reverseVoterTrust(previousValue) + await reverseAuthorTrust(previousValue) + } +} + +type CommentVoteTrustAction = 'upvote' | 'downvote' | 'change' | 'remove' + +interface CommentVoteTrustParams { + tx: PrismaTransactionClient | Prisma.TransactionClient + trustAction: CommentVoteTrustAction + newValue: boolean + previousValue: boolean | null + commentAuthorId: string + voterId: string + commentId: string + parentEntityId: string + listingType: ListingType + updatedScore: number + scoreChange: number +} + +const HELPFUL_THRESHOLD = 5 + +/** + * Applies trust effects when a comment vote is created, changed, or removed. + * Works for both handheld and PC listing comments. + */ +export async function handleCommentVoteTrustEffects(params: CommentVoteTrustParams): Promise { + const { + tx, + trustAction, + newValue, + previousValue, + commentAuthorId, + voterId, + commentId, + parentEntityId, + listingType, + updatedScore, + scoreChange, + } = params + + if (commentAuthorId === voterId) return + + const trustService = new TrustService(tx) + const parentKey = listingType === 'handheld' ? 'listingId' : 'pcListingId' + const baseMeta = { commentId, voterId, [parentKey]: parentEntityId } - if (authorId && authorId !== userId) { - await reverseTrustAction({ - userId: authorId, - originalAction: previousValue - ? TrustAction.LISTING_RECEIVED_UPVOTE - : TrustAction.LISTING_RECEIVED_DOWNVOTE, - context: { listingId, voterId: userId }, + if (trustAction === 'upvote') { + await trustService.logAction({ + userId: commentAuthorId, + action: TrustAction.COMMENT_RECEIVED_UPVOTE, + targetUserId: voterId, + metadata: baseMeta, + }) + } else if (trustAction === 'downvote') { + await trustService.logAction({ + userId: commentAuthorId, + action: TrustAction.COMMENT_RECEIVED_DOWNVOTE, + targetUserId: voterId, + metadata: baseMeta, + }) + } else if (trustAction === 'change') { + if (newValue) { + // Changed from downvote to upvote: reverse downvote, apply upvote + await trustService.reverseLogAction({ + userId: commentAuthorId, + originalAction: TrustAction.COMMENT_RECEIVED_DOWNVOTE, + targetUserId: voterId, + metadata: baseMeta, + }) + await trustService.logAction({ + userId: commentAuthorId, + action: TrustAction.COMMENT_RECEIVED_UPVOTE, + targetUserId: voterId, + metadata: baseMeta, + }) + } else { + // Changed from upvote to downvote: reverse upvote, apply downvote + await trustService.reverseLogAction({ + userId: commentAuthorId, + originalAction: TrustAction.COMMENT_RECEIVED_UPVOTE, + targetUserId: voterId, + metadata: baseMeta, + }) + await trustService.logAction({ + userId: commentAuthorId, + action: TrustAction.COMMENT_RECEIVED_DOWNVOTE, + targetUserId: voterId, + metadata: baseMeta, }) } + } else if (trustAction === 'remove' && previousValue !== null) { + const originalAction = previousValue + ? TrustAction.COMMENT_RECEIVED_UPVOTE + : TrustAction.COMMENT_RECEIVED_DOWNVOTE + await trustService.reverseLogAction({ + userId: commentAuthorId, + originalAction, + targetUserId: voterId, + metadata: baseMeta, + }) + } + + // Helpful comment bonus when crossing the threshold + const previousScore = updatedScore - scoreChange + if (previousScore < HELPFUL_THRESHOLD && updatedScore >= HELPFUL_THRESHOLD) { + await trustService.logAction({ + userId: commentAuthorId, + action: TrustAction.HELPFUL_COMMENT, + metadata: { + commentId, + [parentKey]: parentEntityId, + score: updatedScore, + threshold: HELPFUL_THRESHOLD, + }, + }) } } diff --git a/src/test/setup.ts b/src/test/setup.ts index 2cf42d0f..63409e14 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -17,13 +17,23 @@ vi.mock('@orm/sql', () => ({ // Mock the entire Prisma client module to prevent database initialization vi.mock('@orm', () => ({ - // Prisma namespace for raw SQL tagged templates + // Prisma namespace — includes raw SQL tagged templates and the + // runtime-enum-like objects that router modules reference at module top + // (e.g. `const mode = Prisma.QueryMode.insensitive`). Prisma: { sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ strings: Array.from(strings), values, text: strings.join('?'), }), + QueryMode: { + default: 'default', + insensitive: 'insensitive', + }, + SortOrder: { + asc: 'asc', + desc: 'desc', + }, }, PrismaClient: vi.fn().mockImplementation(() => ({ $transaction: vi.fn(), diff --git a/src/utils/user-bans.test.ts b/src/utils/user-bans.test.ts new file mode 100644 index 00000000..3f4e2da5 --- /dev/null +++ b/src/utils/user-bans.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { hasActiveBans } from './user-bans' + +describe('hasActiveBans', () => { + it('returns false for null', () => { + expect(hasActiveBans(null)).toBe(false) + }) + + it('returns false for undefined', () => { + expect(hasActiveBans(undefined)).toBe(false) + }) + + it('returns false for a primitive', () => { + expect(hasActiveBans('user-id')).toBe(false) + expect(hasActiveBans(42)).toBe(false) + }) + + it('returns false when `userBans` is not a property', () => { + expect(hasActiveBans({})).toBe(false) + expect(hasActiveBans({ id: 'x', name: 'y' })).toBe(false) + }) + + it('returns false when `userBans` is null', () => { + expect(hasActiveBans({ userBans: null })).toBe(false) + }) + + it('returns false when `userBans` is not an array', () => { + expect(hasActiveBans({ userBans: 'not-an-array' })).toBe(false) + expect(hasActiveBans({ userBans: { id: 'x' } })).toBe(false) + }) + + it('returns false when `userBans` is an empty array', () => { + expect(hasActiveBans({ userBans: [] })).toBe(false) + }) + + it('returns true when `userBans` contains at least one entry', () => { + expect(hasActiveBans({ userBans: [{ id: 'ban-1' }] })).toBe(true) + expect(hasActiveBans({ userBans: [{ id: 'a' }, { id: 'b' }] })).toBe(true) + }) +}) diff --git a/src/utils/user-bans.ts b/src/utils/user-bans.ts new file mode 100644 index 00000000..0788f05d --- /dev/null +++ b/src/utils/user-bans.ts @@ -0,0 +1,10 @@ +interface AuthorWithPossibleBans { + userBans?: { id: string }[] | null +} + +export function hasActiveBans(author: unknown): author is AuthorWithPossibleBans { + if (!author || typeof author !== 'object') return false + if (!('userBans' in author)) return false + const bans = (author as AuthorWithPossibleBans).userBans + return Array.isArray(bans) && bans.length > 0 +} diff --git a/tests/pc-voting.spec.ts b/tests/pc-voting.spec.ts new file mode 100644 index 00000000..10d35519 --- /dev/null +++ b/tests/pc-voting.spec.ts @@ -0,0 +1,131 @@ +import { type Page, test, expect } from '@playwright/test' + +// Mirrors voting.spec.ts for handheld listings — PcVoteButtons wraps the +// shared VoteButtons component, so both should behave identically. + +async function navigateToFirstPcListing(page: Page) { + await page.goto('/pc-listings') + await page.waitForLoadState('domcontentloaded') + + const rows = page.locator('tbody tr') + await expect(rows.first()).toBeVisible() + + await rows.first().locator('a').first().click() + await page.waitForLoadState('domcontentloaded') +} + +test.describe('PC Listing Voting Functionality Tests', () => { + test('should display vote section on PC listing detail page', async ({ page }) => { + await navigateToFirstPcListing(page) + + const verificationHeading = page.getByRole('heading', { + name: /community verification/i, + }) + await expect(verificationHeading).toBeVisible() + }) + + test('should display confirm and inaccurate vote buttons', async ({ page }) => { + await navigateToFirstPcListing(page) + + const confirmButton = page.getByRole('button', { name: /confirm/i }) + const inaccurateButton = page.getByRole('button', { name: /inaccurate/i }) + + await expect(confirmButton).toBeVisible() + await expect(inaccurateButton).toBeVisible() + }) + + test('should display success rate percentage and voter count', async ({ page }) => { + await navigateToFirstPcListing(page) + + const successRate = page.getByText(/\d+%/) + await expect(successRate.first()).toBeVisible() + + const verifiedByText = page.getByText(/verified by \d+ users/i) + await expect(verifiedByText).toBeVisible() + }) + + test('should show help button for voting explanation', async ({ page }) => { + await navigateToFirstPcListing(page) + + const helpButton = page.getByTitle('How does verification work?') + await expect(helpButton).toBeVisible() + }) + + test('should show sign-in prompt for unauthenticated users', async ({ page }) => { + await navigateToFirstPcListing(page) + + const signInToVerify = page + .getByText(/sign in/i) + .locator('..') + .filter({ hasText: /to verify/i }) + await expect(signInToVerify).toBeVisible() + }) + + test('should have vote buttons disabled for unauthenticated users', async ({ page }) => { + await navigateToFirstPcListing(page) + + const confirmButton = page.getByRole('button', { name: /confirm/i }) + await expect(confirmButton).toBeVisible() + await expect(confirmButton).toBeDisabled() + + const inaccurateButton = page.getByRole('button', { name: /inaccurate/i }) + await expect(inaccurateButton).toBeDisabled() + }) + + test('should display progress bar for success rate', async ({ page }) => { + await navigateToFirstPcListing(page) + + const verificationHeading = page.getByRole('heading', { + name: /community verification/i, + }) + await expect(verificationHeading).toBeVisible() + + await expect(page.getByText(/\d+%/)).toBeVisible() + await expect(page.getByText(/verified by \d+ users/i)).toBeVisible() + }) +}) + +test.describe('PC Listing Vote — Toggle and Change Flows (Authenticated)', () => { + test.use({ storageState: 'tests/.auth/user.json' }) + + test('toggling the same vote twice returns the button to its unpressed state', async ({ + page, + }) => { + await navigateToFirstPcListing(page) + + const confirmButton = page.getByRole('button', { name: /confirm/i }) + await expect(confirmButton).toBeVisible() + + const initial = await confirmButton.getAttribute('aria-pressed') + + // Click once → flip + await confirmButton.click() + await expect(confirmButton).toHaveAttribute( + 'aria-pressed', + initial === 'true' ? 'false' : 'true', + ) + + // Click again → flip back + await confirmButton.click() + await expect(confirmButton).toHaveAttribute('aria-pressed', initial ?? 'false') + }) + + test('changing from upvote to downvote flips aria-pressed on both buttons', async ({ page }) => { + await navigateToFirstPcListing(page) + + const confirmButton = page.getByRole('button', { name: /confirm/i }) + const inaccurateButton = page.getByRole('button', { name: /inaccurate/i }) + + // Ensure upvote is active + const confirmPressed = await confirmButton.getAttribute('aria-pressed') + if (confirmPressed !== 'true') { + await confirmButton.click() + await expect(confirmButton).toHaveAttribute('aria-pressed', 'true') + } + + // Change to downvote + await inaccurateButton.click() + await expect(inaccurateButton).toHaveAttribute('aria-pressed', 'true') + await expect(confirmButton).toHaveAttribute('aria-pressed', 'false') + }) +}) diff --git a/tests/trust-integration.spec.ts b/tests/trust-integration.spec.ts new file mode 100644 index 00000000..ffd6f880 --- /dev/null +++ b/tests/trust-integration.spec.ts @@ -0,0 +1,215 @@ +import { test, expect } from '@playwright/test' +import { + approveFirstPendingListing, + createPcListing, + rejectFirstPendingListing, + resetUserTrustScore, + withContext, +} from './helpers/data-factory' +import type { Page } from '@playwright/test' + +async function verifyTrustLogContains(page: Page, actionText: string) { + await page.goto('/admin/trust-logs', { waitUntil: 'domcontentloaded' }) + await expect(page.getByText(/loading/i)).toBeHidden() + + const table = page.locator('table') + await expect(table).toBeVisible() + + const rows = page.locator('table tbody tr').filter({ hasText: new RegExp(actionText, 'i') }) + expect(await rows.count()).toBeGreaterThan(0) +} + +test.describe('Trust Effects E2E — Self-Contained', () => { + test.setTimeout(60000) + + test('listing creation records LISTING_CREATED trust action', async ({ browser }) => { + await withContext(browser, 'tests/.auth/user.json', async (page) => { + await createPcListing(page) + }) + + await withContext(browser, 'tests/.auth/super_admin.json', async (page) => { + await verifyTrustLogContains(page, 'created a listing') + }) + }) + + test('approving a listing records LISTING_APPROVED trust action', async ({ browser }) => { + await withContext(browser, 'tests/.auth/super_admin.json', async (page) => { + await approveFirstPendingListing(page, '/admin/approvals') + }) + + await withContext(browser, 'tests/.auth/super_admin.json', async (page) => { + await verifyTrustLogContains(page, 'approved') + }) + }) + + test('admin trust adjustment resets score, user listing stays pending, admin approval triggers LISTING_APPROVED', async ({ + browser, + }) => { + await withContext(browser, 'tests/.auth/super_admin.json', async (page) => { + await resetUserTrustScore(page, 'user@emuready') + }) + + await withContext(browser, 'tests/.auth/user.json', async (page) => { + await createPcListing(page) + }) + + await withContext(browser, 'tests/.auth/super_admin.json', async (page) => { + await approveFirstPendingListing(page, '/admin/approvals') + }) + + await withContext(browser, 'tests/.auth/super_admin.json', async (page) => { + await verifyTrustLogContains(page, 'approved') + }) + }) + + test('rejecting a listing records LISTING_REJECTED trust action', async ({ browser }) => { + await withContext(browser, 'tests/.auth/super_admin.json', async (page) => { + await rejectFirstPendingListing(page, '/admin/approvals') + }) + + await withContext(browser, 'tests/.auth/super_admin.json', async (page) => { + await verifyTrustLogContains(page, 'rejected') + }) + }) + + test('voting on a PC listing records UPVOTE trust action', async ({ browser }) => { + await withContext(browser, 'tests/.auth/user.json', async (page) => { + await page.goto('/pc-listings') + + const rows = page.locator('tbody tr') + await expect(rows.first()).toBeVisible() + + await rows.first().locator('a').first().click() + await page.waitForLoadState('domcontentloaded') + + const confirmButton = page.getByRole('button', { name: /confirm/i }) + await expect(confirmButton).toBeVisible() + + const wasPressed = await confirmButton.getAttribute('aria-pressed') + await confirmButton.click() + + await expect(confirmButton).toHaveAttribute( + 'aria-pressed', + wasPressed === 'true' ? 'false' : 'true', + ) + }) + + await withContext(browser, 'tests/.auth/super_admin.json', async (page) => { + await verifyTrustLogContains(page, 'upvoted a listing') + }) + }) + + test('voting on a handheld listing records UPVOTE trust action', async ({ browser }) => { + await withContext(browser, 'tests/.auth/user.json', async (page) => { + await page.goto('/listings') + + const rows = page.locator('table tbody tr') + await expect(rows.first()).toBeVisible() + + const link = rows.first().locator('a[href*="/listings/"]').first() + await link.click() + await page.waitForLoadState('domcontentloaded') + + const confirmButton = page.getByRole('button', { name: /confirm/i }) + await expect(confirmButton).toBeVisible() + + const wasPressed = await confirmButton.getAttribute('aria-pressed') + await confirmButton.click() + + await expect(confirmButton).toHaveAttribute( + 'aria-pressed', + wasPressed === 'true' ? 'false' : 'true', + ) + }) + + await withContext(browser, 'tests/.auth/super_admin.json', async (page) => { + await verifyTrustLogContains(page, 'upvoted a listing') + }) + }) + + test('changing a vote (upvote → downvote) reverses previous then applies new trust', async ({ + browser, + }) => { + // User upvotes a listing, then changes to downvote + await withContext(browser, 'tests/.auth/user.json', async (page) => { + await page.goto('/listings') + const rows = page.locator('table tbody tr') + await expect(rows.first()).toBeVisible() + + const link = rows.first().locator('a[href*="/listings/"]').first() + await link.click() + await page.waitForLoadState('domcontentloaded') + + const confirmButton = page.getByRole('button', { name: /confirm/i }) + const inaccurateButton = page.getByRole('button', { name: /inaccurate/i }) + await expect(confirmButton).toBeVisible() + await expect(inaccurateButton).toBeVisible() + + // Ensure upvote is active + const wasConfirmed = await confirmButton.getAttribute('aria-pressed') + if (wasConfirmed !== 'true') { + await confirmButton.click() + await expect(confirmButton).toHaveAttribute('aria-pressed', 'true') + } + + // Change to downvote — click the "Inaccurate" button + await inaccurateButton.click() + await expect(inaccurateButton).toHaveAttribute('aria-pressed', 'true') + await expect(confirmButton).toHaveAttribute('aria-pressed', 'false') + }) + + // Admin verifies trust log has the reversal description + await withContext(browser, 'tests/.auth/super_admin.json', async (page) => { + await verifyTrustLogContains(page, 'Trust reversal due to vote change or removal') + }) + }) + + test('toggling off an upvote reverses the trust award', async ({ browser }) => { + await withContext(browser, 'tests/.auth/user.json', async (page) => { + await page.goto('/listings') + const rows = page.locator('table tbody tr') + await expect(rows.first()).toBeVisible() + + const link = rows.first().locator('a[href*="/listings/"]').first() + await link.click() + await page.waitForLoadState('domcontentloaded') + + const confirmButton = page.getByRole('button', { name: /confirm/i }) + await expect(confirmButton).toBeVisible() + + // Ensure upvote is active + const wasPressed = await confirmButton.getAttribute('aria-pressed') + if (wasPressed !== 'true') { + await confirmButton.click() + await expect(confirmButton).toHaveAttribute('aria-pressed', 'true') + } + + // Toggle off + await confirmButton.click() + await expect(confirmButton).toHaveAttribute('aria-pressed', 'false') + }) + + await withContext(browser, 'tests/.auth/super_admin.json', async (page) => { + await verifyTrustLogContains(page, 'Trust reversal due to vote change or removal') + }) + }) + + test('trust logs table shows action data with weights', async ({ browser }) => { + await withContext(browser, 'tests/.auth/super_admin.json', async (page) => { + await page.goto('/admin/trust-logs', { waitUntil: 'domcontentloaded' }) + await expect(page.getByText(/loading/i)).toBeHidden() + + const table = page.locator('table') + await expect(table).toBeVisible() + + const headerText = await page.locator('thead').textContent() + expect(headerText?.toLowerCase()).toContain('action') + expect(headerText?.toLowerCase()).toContain('weight') + + const rows = page.locator('table tbody tr') + expect(await rows.count()).toBeGreaterThan(0) + + await expect(rows.first()).toContainText(/[+-]?\d+/) + }) + }) +}) From 13c2c681cfb8ade5ba22723432ac8db1999a00ec Mon Sep 17 00:00:00 2001 From: ObfuscatedVoid Date: Sat, 18 Apr 2026 12:38:13 +0200 Subject: [PATCH 4/6] feat: update Playwright configuration for improved test stability and setup dependencies --- playwright.config.ts | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 02db5bd4..020df9ea 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -18,28 +18,23 @@ export default defineConfig({ forbidOnly: !!process.env.CI, /* 1 retry catches timing-sensitive tests without hiding consistent issues */ retries: 1, - /* Conservative approach for CI stability - 1 worker ensures each test gets full resources */ - workers: isCI ? 1 : undefined, + workers: 1, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'list', - /* Test timeout */ - timeout: isCI ? 60 * 1000 : 30 * 1000, // 60 seconds in CI, 30 locally + timeout: 60 * 1000, + + expect: { + timeout: 10 * 1000, + }, - /* Global setup - runs once before all tests */ globalSetup: require.resolve('./tests/global.setup.ts'), - /* Shared settings for all the projects below. */ use: { - /* Base URL to use in actions like `await page.goto('/')`. */ baseURL: 'http://localhost:3000', - - /* Collect trace when retrying the failed test. */ + actionTimeout: 10 * 1000, + navigationTimeout: 30 * 1000, trace: 'on-first-retry', - - /* Screenshot on failure */ screenshot: 'only-on-failure', - - /* Video on failure */ video: 'retain-on-failure', }, @@ -50,11 +45,17 @@ export default defineConfig({ name: 'setup', testMatch: /auth\.setup\.ts/, }, + // Data setup - creates listings, reports, etc. that other tests depend on + { + name: 'data-setup', + testMatch: /data-setup\.spec\.ts/, + dependencies: ['setup'], + }, { name: 'chromium', use: { ...devices['Desktop Chrome'] }, - testIgnore: ['**/auth.setup.ts', '**/global.setup.ts'], - dependencies: ['setup'], // Ensure auth is set up before running tests + testIgnore: ['**/auth.setup.ts', '**/global.setup.ts', '**/data-setup.spec.ts'], + dependencies: ['data-setup'], }, ], From f69e9e28cfbba9782c6c18d7269aa59264fc248f Mon Sep 17 00:00:00 2001 From: ObfuscatedVoid Date: Sat, 18 Apr 2026 16:46:06 +0200 Subject: [PATCH 5/6] feat: improve custom field deletion logic with safety checks --- .../seeders/gamenativeCustomFieldsSeeder.ts | 28 +++++++++++++++++-- .../emulator-config/gamenative/mapping.ts | 2 +- tests/listings-success-rate-sorting.spec.ts | 5 +++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/prisma/seeders/gamenativeCustomFieldsSeeder.ts b/prisma/seeders/gamenativeCustomFieldsSeeder.ts index c233f992..4e67ce7b 100644 --- a/prisma/seeders/gamenativeCustomFieldsSeeder.ts +++ b/prisma/seeders/gamenativeCustomFieldsSeeder.ts @@ -405,15 +405,39 @@ export default async function gamenativeCustomFieldsSeeder(prisma: PrismaClient) }) } - const removed = await prisma.customFieldDefinition.deleteMany({ + const staleDefinitions = await prisma.customFieldDefinition.findMany({ where: { emulatorId: gamenative.id, name: { notIn: fieldNames }, }, + select: { id: true, name: true }, }) + if (staleDefinitions.length > 0) { + const valueCount = await prisma.listingCustomFieldValue.count({ + where: { customFieldDefinitionId: { in: staleDefinitions.map((d) => d.id) } }, + }) + const pcValueCount = await prisma.pcListingCustomFieldValue.count({ + where: { customFieldDefinitionId: { in: staleDefinitions.map((d) => d.id) } }, + }) + + if (valueCount > 0 || pcValueCount > 0) { + console.warn( + `⚠️ Skipping removal of ${staleDefinitions.length} stale field(s) ` + + `(${staleDefinitions.map((d) => d.name).join(', ')}) — ` + + `${valueCount + pcValueCount} user-submitted values would be cascade-deleted. ` + + `Remove manually after migrating data.`, + ) + } else { + await prisma.customFieldDefinition.deleteMany({ + where: { id: { in: staleDefinitions.map((d) => d.id) } }, + }) + console.info(`✅ Removed ${staleDefinitions.length} unused field definition(s).`) + } + } + console.info( - `✅ GameNative custom fields synced. Updated ${GAMENATIVE_CUSTOM_FIELDS.length} definitions, removed ${removed.count}.`, + `✅ GameNative custom fields synced. Updated ${GAMENATIVE_CUSTOM_FIELDS.length} definitions.`, ) } diff --git a/src/shared/emulator-config/gamenative/mapping.ts b/src/shared/emulator-config/gamenative/mapping.ts index 2e9532b1..336a6cff 100644 --- a/src/shared/emulator-config/gamenative/mapping.ts +++ b/src/shared/emulator-config/gamenative/mapping.ts @@ -76,7 +76,7 @@ export const GAMENATIVE_IMPORT_MAPPINGS: Record if (fullConfig && typeof fullConfig['dxvkVersion'] === 'string') { return fullConfig['dxvkVersion'] } - return configStr || undefined + return configStr.includes('=') ? undefined : configStr || undefined }, }, diff --git a/tests/listings-success-rate-sorting.spec.ts b/tests/listings-success-rate-sorting.spec.ts index 56b24d7f..7a35ee35 100644 --- a/tests/listings-success-rate-sorting.spec.ts +++ b/tests/listings-success-rate-sorting.spec.ts @@ -11,8 +11,11 @@ test.describe('Success Rate Sorting', () => { // The community support banner overlays the table header and intercepts // sort clicks if it's still present. const dismissBanner = page.getByRole('button', { name: /dismiss community support/i }) - if (await dismissBanner.isVisible({ timeout: 1000 })) { + try { + await dismissBanner.waitFor({ state: 'visible', timeout: 1000 }) await dismissBanner.click() + } catch { + // Banner not present — nothing to dismiss } }) From 2907802650d97e40e82a4510686db7f909dae77d Mon Sep 17 00:00:00 2001 From: ObfuscatedVoid Date: Sat, 18 Apr 2026 23:54:54 +0200 Subject: [PATCH 6/6] fix: Add fallback for undefined startup selection mapping --- src/shared/emulator-config/gamenative/mapping.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/emulator-config/gamenative/mapping.ts b/src/shared/emulator-config/gamenative/mapping.ts index 336a6cff..9a9dfbf0 100644 --- a/src/shared/emulator-config/gamenative/mapping.ts +++ b/src/shared/emulator-config/gamenative/mapping.ts @@ -96,7 +96,7 @@ export const GAMENATIVE_IMPORT_MAPPINGS: Record startup_selection: { jsonPath: 'startupSelection', - fromConfig: (value) => STARTUP_SELECTION_REVERSE[String(value)], + fromConfig: (value) => STARTUP_SELECTION_REVERSE[String(value)] ?? String(value), }, box64_version: {