diff --git a/apps/server/src/db.ts b/apps/server/src/db.ts index f4aacbb..4c85eee 100644 --- a/apps/server/src/db.ts +++ b/apps/server/src/db.ts @@ -84,6 +84,24 @@ function ensureDefaults(): void { const settings = getSettings(); if (!settings) { setSettings(DEFAULT_SETTINGS); + } else { + // Migrate existing settings to include new fields with defaults + let needsUpdate = false; + const updated = { ...settings }; + + if (!updated.platformAcronyms) { + updated.platformAcronyms = {}; + needsUpdate = true; + } + + if (!updated.platformIcons) { + updated.platformIcons = {}; + needsUpdate = true; + } + + if (needsUpdate) { + setSettings(updated); + } } } diff --git a/apps/server/src/routes/__tests__/settings.spec.ts b/apps/server/src/routes/__tests__/settings.spec.ts new file mode 100644 index 0000000..d92668f --- /dev/null +++ b/apps/server/src/routes/__tests__/settings.spec.ts @@ -0,0 +1,320 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import express from 'express'; +import settingsRouter from '../settings'; +import * as db from '../../db'; +import { DEFAULT_SETTINGS } from '@crocdesk/shared'; + +// Mock the database module +vi.mock('../../db', () => ({ + getSettings: vi.fn(), + setSettings: vi.fn() +})); + +describe('Settings API Routes', () => { + let app: express.Application; + + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use('/settings', settingsRouter); + vi.clearAllMocks(); + }); + + describe('GET /settings', () => { + it('should return default settings when no settings exist', async () => { + vi.mocked(db.getSettings).mockReturnValue(null); + + const response = await request(app).get('/settings'); + + expect(response.status).toBe(200); + expect(response.body).toEqual(DEFAULT_SETTINGS); + }); + + it('should return stored settings when they exist', async () => { + const mockSettings = { + downloadDir: '/custom/downloads', + libraryDir: '/custom/library', + platformAcronyms: { snes: 'sfc' }, + platformIcons: { snes: 'nintendo' } + }; + vi.mocked(db.getSettings).mockReturnValue(mockSettings); + + const response = await request(app).get('/settings'); + + expect(response.status).toBe(200); + expect(response.body).toEqual(mockSettings); + }); + }); + + describe('PUT /settings', () => { + it('should accept valid settings', async () => { + const validSettings = { + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { + snes: 'sfc', + playstation: 'psx' + }, + platformIcons: { + snes: 'nintendo', + playstation: 'sony' + } + }; + + const response = await request(app) + .put('/settings') + .send(validSettings); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + expect(db.setSettings).toHaveBeenCalledWith(validSettings); + }); + + it('should handle empty body', async () => { + // Express parses empty/null bodies as empty object {} + // This is acceptable behavior as empty settings won't break anything + const response = await request(app) + .put('/settings') + .send({}); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + }); + + it('should handle non-JSON content type', async () => { + // When sending non-JSON, Express will reject it at middleware level + const response = await request(app) + .put('/settings') + .set('Content-Type', 'text/plain') + .send('invalid'); + + // Express json middleware will produce empty body, which is {} after parsing + // This will pass validation as an empty settings object + expect(response.status).toBe(200); + }); + + describe('platformAcronyms validation', () => { + it('should reject non-object platformAcronyms', async () => { + const response = await request(app) + .put('/settings') + .send({ + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: 'not-an-object' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('platformAcronyms must be an object'); + expect(db.setSettings).not.toHaveBeenCalled(); + }); + + it('should reject acronyms that are too short', async () => { + const response = await request(app) + .put('/settings') + .send({ + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { + snes: 'x' + } + }); + + expect(response.status).toBe(400); + expect(response.body.error).toContain('Invalid acronym "x"'); + expect(response.body.error).toContain('Must be 2-12 characters'); + expect(db.setSettings).not.toHaveBeenCalled(); + }); + + it('should reject acronyms that are too long', async () => { + const response = await request(app) + .put('/settings') + .send({ + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { + snes: 'verylongacronym' + } + }); + + expect(response.status).toBe(400); + expect(response.body.error).toContain('Invalid acronym'); + expect(db.setSettings).not.toHaveBeenCalled(); + }); + + it('should reject acronyms with invalid characters', async () => { + const response = await request(app) + .put('/settings') + .send({ + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { + snes: 'snes!' + } + }); + + expect(response.status).toBe(400); + expect(response.body.error).toContain('Invalid acronym "snes!"'); + expect(db.setSettings).not.toHaveBeenCalled(); + }); + + it('should accept valid acronyms', async () => { + const response = await request(app) + .put('/settings') + .send({ + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { + snes: 'sfc', + playstation: 'psx', + 'n64': 'n64', + 'xbox-360': 'x360' + } + }); + + expect(response.status).toBe(200); + expect(db.setSettings).toHaveBeenCalled(); + }); + }); + + describe('platformIcons validation', () => { + it('should reject non-object platformIcons', async () => { + const response = await request(app) + .put('/settings') + .send({ + downloadDir: './downloads', + libraryDir: './library', + platformIcons: 'not-an-object' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('platformIcons must be an object'); + expect(db.setSettings).not.toHaveBeenCalled(); + }); + + it('should reject invalid icon brands', async () => { + const response = await request(app) + .put('/settings') + .send({ + downloadDir: './downloads', + libraryDir: './library', + platformIcons: { + snes: 'playstation' + } + }); + + expect(response.status).toBe(400); + expect(response.body.error).toContain('Invalid icon brand "playstation"'); + expect(response.body.error).toContain('Must be one of: nintendo, sony, xbox, sega, pc, atari, commodore, nec, generic'); + expect(db.setSettings).not.toHaveBeenCalled(); + }); + + it('should reject case-sensitive icon brands', async () => { + const response = await request(app) + .put('/settings') + .send({ + downloadDir: './downloads', + libraryDir: './library', + platformIcons: { + snes: 'Nintendo' + } + }); + + expect(response.status).toBe(400); + expect(response.body.error).toContain('Invalid icon brand "Nintendo"'); + expect(db.setSettings).not.toHaveBeenCalled(); + }); + + it('should accept all valid icon brands', async () => { + const response = await request(app) + .put('/settings') + .send({ + downloadDir: './downloads', + libraryDir: './library', + platformIcons: { + platform1: 'nintendo', + platform2: 'sony', + platform3: 'xbox', + platform4: 'sega', + platform5: 'pc', + platform6: 'atari', + platform7: 'commodore', + platform8: 'nec', + platform9: 'generic' + } + }); + + expect(response.status).toBe(200); + expect(db.setSettings).toHaveBeenCalled(); + }); + }); + + describe('Combined validation', () => { + it('should validate both acronyms and icons together', async () => { + const response = await request(app) + .put('/settings') + .send({ + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { + snes: 'sfc', + playstation: 'psx' + }, + platformIcons: { + snes: 'nintendo', + playstation: 'sony' + } + }); + + expect(response.status).toBe(200); + expect(db.setSettings).toHaveBeenCalled(); + }); + + it('should stop at first validation error', async () => { + const response = await request(app) + .put('/settings') + .send({ + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { + snes: 'x' // invalid + }, + platformIcons: { + playstation: 'invalid' // would also be invalid + } + }); + + expect(response.status).toBe(400); + // Should fail on acronym validation first + expect(response.body.error).toContain('Invalid acronym "x"'); + expect(db.setSettings).not.toHaveBeenCalled(); + }); + + it('should allow empty override objects', async () => { + const response = await request(app) + .put('/settings') + .send({ + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: {}, + platformIcons: {} + }); + + expect(response.status).toBe(200); + expect(db.setSettings).toHaveBeenCalled(); + }); + + it('should allow missing override fields', async () => { + const response = await request(app) + .put('/settings') + .send({ + downloadDir: './downloads', + libraryDir: './library' + }); + + expect(response.status).toBe(200); + expect(db.setSettings).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/server/src/routes/settings.ts b/apps/server/src/routes/settings.ts index 46bc758..fc51bc2 100644 --- a/apps/server/src/routes/settings.ts +++ b/apps/server/src/routes/settings.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import { DEFAULT_SETTINGS } from "@crocdesk/shared"; +import { DEFAULT_SETTINGS, isValidAcronym, isValidIconBrand } from "@crocdesk/shared"; import { getSettings, setSettings } from "../db"; const router = Router(); @@ -15,6 +15,39 @@ router.put("/", (req, res) => { res.status(400).json({ error: "Invalid settings payload" }); return; } + + // Validate platformAcronyms if present + if (nextSettings.platformAcronyms) { + if (typeof nextSettings.platformAcronyms !== "object") { + res.status(400).json({ error: "platformAcronyms must be an object" }); + return; + } + for (const [platform, acronym] of Object.entries(nextSettings.platformAcronyms)) { + if (typeof acronym !== "string" || !isValidAcronym(acronym)) { + res.status(400).json({ + error: `Invalid acronym "${acronym}" for platform "${platform}". Must be 2-12 characters, alphanumeric with dashes/underscores.` + }); + return; + } + } + } + + // Validate platformIcons if present + if (nextSettings.platformIcons) { + if (typeof nextSettings.platformIcons !== "object") { + res.status(400).json({ error: "platformIcons must be an object" }); + return; + } + for (const [platform, icon] of Object.entries(nextSettings.platformIcons)) { + if (typeof icon !== "string" || !isValidIconBrand(icon)) { + res.status(400).json({ + error: `Invalid icon brand "${icon}" for platform "${platform}". Must be one of: nintendo, sony, xbox, sega, pc, atari, commodore, nec, generic.` + }); + return; + } + } + } + setSettings(nextSettings); res.json({ ok: true }); }); diff --git a/apps/server/src/services/pipeline.ts b/apps/server/src/services/pipeline.ts index 9aca188..83866f1 100644 --- a/apps/server/src/services/pipeline.ts +++ b/apps/server/src/services/pipeline.ts @@ -3,6 +3,7 @@ import { promises as fs } from "fs"; import { Readable } from "stream"; import type { ReadableStream as NodeReadableStream } from "stream/web"; import type { CrocdbEntry, Manifest, Settings } from "@crocdesk/shared"; +import { resolvePlatformAcronym } from "@crocdesk/shared"; import { ENABLE_DOWNLOADS } from "../config"; import { getEntry } from "./crocdb"; import { writeManifest } from "./manifest"; @@ -79,11 +80,12 @@ export async function runDownloadAndInstall( try { if (abortSignal?.aborted) throw new Error("Cancelled by user"); progressReporter(0.6, "Extracting archive"); - // Extract to libraryDir/{console}/{game} + // Extract to libraryDir/{acronym}/{game} const libraryDir = path.resolve(settings.libraryDir || "./library"); const platform = entry.platform || "unknown"; + const platformAcronym = resolvePlatformAcronym(platform, settings); const gameName = formatName(entry); - const extractDir = path.join(libraryDir, platform, gameName); + const extractDir = path.join(libraryDir, platformAcronym, gameName); await ensureDir(extractDir); await extractZip(downloadPath, extractDir); @@ -109,6 +111,7 @@ export async function runDownloadAndInstall( slug: entry.slug, title: entry.title, platform: entry.platform, + platformAcronym, regions: entry.regions }, artifacts, @@ -132,7 +135,7 @@ export async function runDownloadAndInstall( await saveBoxart(entry, path.dirname(outputPath)).catch(() => {}); const stats = await fs.stat(outputPath); - const manifest = buildManifest(entry, outputPath, stats.size); + const manifest = buildManifest(entry, outputPath, stats.size, settings); await writeManifest(path.dirname(outputPath), manifest); if (abortSignal?.aborted) throw new Error("Cancelled by user"); @@ -468,8 +471,10 @@ async function finalizeLayout( sourcePath: string, settings: Settings ): Promise { - // Use settings.libraryDir as source of truth; place files under console subfolder - const targetRoot = path.join(path.resolve(settings.libraryDir || "./library"), entry.platform); + // Use settings.libraryDir as source of truth; place files under acronym subfolder + const platform = entry.platform || "unknown"; + const platformAcronym = resolvePlatformAcronym(platform, settings); + const targetRoot = path.join(path.resolve(settings.libraryDir || "./library"), platformAcronym); await ensureDir(targetRoot); const ext = path.extname(sourcePath); @@ -490,7 +495,8 @@ async function finalizeLayoutMany( ): Promise<{ outputDir: string; outputPaths: string[] }> { const libraryRoot = path.resolve(settings.libraryDir || "./library"); const platform = entry.platform || "unknown"; - const baseRoot = path.join(libraryRoot, platform); + const platformAcronym = resolvePlatformAcronym(platform, settings); + const baseRoot = path.join(libraryRoot, platformAcronym); // Create a dedicated folder for the game under the platform root const folderName = formatName(entry); // Avoid double-nesting if baseRoot already is the game folder @@ -527,15 +533,19 @@ function sanitize(value: string): string { function buildManifest( entry: CrocdbEntry, outputPath: string, - size: number + size: number, + settings: Settings ): Manifest { const artifactPath = path.basename(outputPath); + const platform = entry.platform || "unknown"; + const platformAcronym = resolvePlatformAcronym(platform, settings); return { schema: 1, crocdb: { slug: entry.slug, title: entry.title, platform: entry.platform, + platformAcronym, regions: entry.regions }, artifacts: [ diff --git a/apps/web/src/components/PlatformSettings.tsx b/apps/web/src/components/PlatformSettings.tsx new file mode 100644 index 0000000..b03fe1f --- /dev/null +++ b/apps/web/src/components/PlatformSettings.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import type { Settings } from "@crocdesk/shared"; +import { + DEFAULT_PLATFORM_ACRONYMS, + DEFAULT_PLATFORM_ICONS +} from "@crocdesk/shared"; +import PlatformIcon from "./PlatformIcon"; +import { Card, Input } from "./ui"; +import { spacing } from "../lib/design-tokens"; + +type PlatformSettingsProps = { + settings: Settings; + onUpdate: (updated: Settings) => void; +}; + +export default function PlatformSettings({ settings, onUpdate }: PlatformSettingsProps) { + // Show a few example platforms for demonstration + const examplePlatforms = ["snes", "playstation", "n64", "genesis"]; + + // Get effective acronym for a platform + const getEffectiveAcronym = (platform: string): string => { + return settings.platformAcronyms?.[platform] || DEFAULT_PLATFORM_ACRONYMS[platform] || platform; + }; + + // Get effective icon for a platform + const getEffectiveIcon = (platform: string): string => { + return settings.platformIcons?.[platform] || DEFAULT_PLATFORM_ICONS[platform] || "generic"; + }; + + // Update acronym for a platform + const updateAcronym = (platform: string, acronym: string) => { + const trimmed = acronym.trim(); + const updated = { + ...settings, + platformAcronyms: { + ...settings.platformAcronyms, + [platform]: trimmed + } + }; + onUpdate(updated); + }; + + return ( + +

Platform Configuration

+

+ Customize how platforms appear in your library. Acronyms are used for folder names during unpacking (e.g., snes/Game Name). +

+ +
+ {examplePlatforms.map((platform) => { + const acronym = getEffectiveAcronym(platform); + const icon = getEffectiveIcon(platform); + const acronymValue = settings.platformAcronyms?.[platform] || ""; + + return ( +
+ +
+
+ {platform} +
+
+ Folder: {acronym}/Game Name +
+
+
+ updateAcronym(platform, e.target.value)} + placeholder={DEFAULT_PLATFORM_ACRONYMS[platform] || platform} + style={{ fontSize: "13px", padding: "6px 8px" }} + /> +
+
+ ); + })} +

+ Showing {examplePlatforms.length} example platforms. All {Object.keys(DEFAULT_PLATFORM_ACRONYMS).length} platforms can be configured via settings. +

+
+
+ ); +} diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 216e134..31755a3 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -5,6 +5,7 @@ import type { Settings } from "@crocdesk/shared"; import { useUIStore } from "../store"; import { useTheme } from "../components/ThemeProvider"; import { Card, Input, Button } from "../components/ui"; +import PlatformSettings from "../components/PlatformSettings"; import { spacing } from "../lib/design-tokens"; export default function SettingsPage() { @@ -134,6 +135,17 @@ export default function SettingsPage() { + {settingsQuery.data && ( + { + setDraft(updated); + // Auto-save platform settings + saveMutation.mutate(updated); + }} + /> + )} +

About

diff --git a/package-lock.json b/package-lock.json index 9b88f0a..274e946 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@playwright/test": "^1.48.2", "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^14.0.0", + "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^8.50.1", "@typescript-eslint/parser": "^8.50.1", "concurrently": "^8.2.2", @@ -22,6 +23,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "jsdom": "^24.0.0", + "supertest": "^7.1.4", "typescript": "^5.4.5", "vitest": "^4.0.16" } @@ -132,7 +134,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -477,7 +478,6 @@ "url": "https://opencollective.com/csstools" } ], - "peer": true, "engines": { "node": ">=18" }, @@ -500,7 +500,6 @@ "url": "https://opencollective.com/csstools" } ], - "peer": true, "engines": { "node": ">=18" } @@ -1486,6 +1485,19 @@ "node": ">= 10.0.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1524,6 +1536,16 @@ "node": ">= 8" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2114,6 +2136,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -2199,6 +2228,13 @@ "@types/node": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -2254,7 +2290,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2299,6 +2334,30 @@ "@types/send": "<1" } }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", @@ -2351,7 +2410,6 @@ "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -2732,7 +2790,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2765,7 +2822,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2931,6 +2987,7 @@ "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, + "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -2949,6 +3006,7 @@ "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, + "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -2969,13 +3027,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true + "dev": true, + "peer": true }, "node_modules/archiver-utils/node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -2990,7 +3050,8 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "dev": true, + "peer": true }, "node_modules/argparse": { "version": "2.0.1", @@ -3159,6 +3220,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -3456,7 +3524,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3924,11 +3991,22 @@ "node": ">=0.10.0" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/compress-commons": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, + "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -4072,6 +4150,13 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -4104,6 +4189,7 @@ "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "dev": true, + "peer": true, "bin": { "crc32": "bin/crc32.njs" }, @@ -4116,6 +4202,7 @@ "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, + "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -4456,6 +4543,17 @@ "dev": true, "optional": true }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dir-compare": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz", @@ -4483,7 +4581,6 @@ "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.13.3.tgz", "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -4723,6 +4820,7 @@ "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-24.13.3.tgz", "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "dev": true, + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -4735,6 +4833,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -4749,6 +4848,7 @@ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -4761,6 +4861,7 @@ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -5203,7 +5304,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5638,6 +5738,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -5868,6 +5975,24 @@ "node": ">= 6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -7237,7 +7362,6 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", "dev": true, - "peer": true, "dependencies": { "cssstyle": "^4.0.1", "data-urls": "^5.0.0", @@ -7389,6 +7513,7 @@ "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "dev": true, + "peer": true, "dependencies": { "readable-stream": "^2.0.5" }, @@ -7400,13 +7525,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true + "dev": true, + "peer": true }, "node_modules/lazystream/node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -7421,7 +7548,8 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "dev": true, + "peer": true }, "node_modules/levn": { "version": "0.4.1", @@ -7468,25 +7596,29 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "dev": true + "dev": true, + "peer": true }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", - "dev": true + "dev": true, + "peer": true }, "node_modules/lodash.flatten": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", - "dev": true + "dev": true, + "peer": true }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true + "dev": true, + "peer": true }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -7499,7 +7631,8 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", - "dev": true + "dev": true, + "peer": true }, "node_modules/loose-envify": { "version": "1.4.0", @@ -7899,6 +8032,7 @@ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8328,7 +8462,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8945,7 +9078,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8957,7 +9089,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9040,6 +9171,7 @@ "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", "dev": true, + "peer": true, "dependencies": { "minimatch": "^5.1.0" } @@ -9049,6 +9181,7 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "peer": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -9058,6 +9191,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dev": true, + "peer": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -10128,6 +10262,54 @@ "node": ">= 8.0" } }, + "node_modules/superagent": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz", + "integrity": "sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.4", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.4.tgz", + "integrity": "sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^10.2.3" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -10607,7 +10789,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10804,7 +10985,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -11376,7 +11556,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -11783,6 +11962,7 @@ "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, + "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -11797,6 +11977,7 @@ "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, + "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", @@ -11819,7 +12000,6 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 71b9a1f..abd387b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@playwright/test": "^1.48.2", "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^14.0.0", + "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^8.50.1", "@typescript-eslint/parser": "^8.50.1", "concurrently": "^8.2.2", @@ -38,6 +39,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "jsdom": "^24.0.0", + "supertest": "^7.1.4", "typescript": "^5.4.5", "vitest": "^4.0.16" } diff --git a/packages/shared/package.json b/packages/shared/package.json index 742ec92..e394b9b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -2,6 +2,7 @@ "name": "@crocdesk/shared", "version": "0.1.0", "private": true, + "type": "module", "main": "dist/index.js", "types": "src/index.ts", "files": [ diff --git a/packages/shared/src/__tests__/utils.spec.ts b/packages/shared/src/__tests__/utils.spec.ts new file mode 100644 index 0000000..f8c0309 --- /dev/null +++ b/packages/shared/src/__tests__/utils.spec.ts @@ -0,0 +1,302 @@ +import { describe, it, expect } from 'vitest'; +import { + isValidAcronym, + isValidIconBrand, + resolvePlatformAcronym, + resolvePlatformIcon +} from '../utils'; +import type { Settings } from '../types'; + +describe('Platform Acronym and Icon Utilities', () => { + describe('isValidAcronym', () => { + it('should accept valid acronyms', () => { + expect(isValidAcronym('snes')).toBe(true); + expect(isValidAcronym('ps1')).toBe(true); + expect(isValidAcronym('n64')).toBe(true); + expect(isValidAcronym('a2600')).toBe(true); + expect(isValidAcronym('x360')).toBe(true); + expect(isValidAcronym('sfc')).toBe(true); + expect(isValidAcronym('psx')).toBe(true); + expect(isValidAcronym('md')).toBe(true); + expect(isValidAcronym('neo-geo')).toBe(true); + expect(isValidAcronym('pc_engine')).toBe(true); + expect(isValidAcronym('AB')).toBe(true); // uppercase + expect(isValidAcronym('Ab12')).toBe(true); // mixed case + }); + + it('should reject acronyms that are too short', () => { + expect(isValidAcronym('a')).toBe(false); + expect(isValidAcronym('x')).toBe(false); + expect(isValidAcronym('')).toBe(false); + }); + + it('should reject acronyms that are too long', () => { + expect(isValidAcronym('verylongacronym')).toBe(false); + expect(isValidAcronym('morethan12chr')).toBe(false); + }); + + it('should reject acronyms with invalid characters', () => { + expect(isValidAcronym('snes!')).toBe(false); + expect(isValidAcronym('ps@1')).toBe(false); + expect(isValidAcronym('n 64')).toBe(false); // space + expect(isValidAcronym('game.boy')).toBe(false); // dot + expect(isValidAcronym('snes/pal')).toBe(false); // slash + }); + + it('should handle edge cases', () => { + expect(isValidAcronym('--')).toBe(true); // only dashes (valid but weird) + expect(isValidAcronym('__')).toBe(true); // only underscores (valid but weird) + expect(isValidAcronym('12')).toBe(true); // only numbers + expect(isValidAcronym('-_-_-_-_-_-_')).toBe(true); // alternating (12 chars) + }); + }); + + describe('isValidIconBrand', () => { + it('should accept valid icon brands', () => { + expect(isValidIconBrand('nintendo')).toBe(true); + expect(isValidIconBrand('sony')).toBe(true); + expect(isValidIconBrand('xbox')).toBe(true); + expect(isValidIconBrand('sega')).toBe(true); + expect(isValidIconBrand('pc')).toBe(true); + expect(isValidIconBrand('atari')).toBe(true); + expect(isValidIconBrand('commodore')).toBe(true); + expect(isValidIconBrand('nec')).toBe(true); + expect(isValidIconBrand('generic')).toBe(true); + }); + + it('should reject invalid icon brands', () => { + expect(isValidIconBrand('playstation')).toBe(false); + expect(isValidIconBrand('microsoft')).toBe(false); + expect(isValidIconBrand('Nintendo')).toBe(false); // case sensitive + expect(isValidIconBrand('SONY')).toBe(false); // case sensitive + expect(isValidIconBrand('')).toBe(false); + expect(isValidIconBrand('unknown')).toBe(false); + }); + }); + + describe('resolvePlatformAcronym', () => { + it('should return custom acronym when provided in settings', () => { + const settings: Settings = { + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { + 'snes': 'sfc', + 'playstation': 'psx' + } + }; + + expect(resolvePlatformAcronym('snes', settings)).toBe('sfc'); + expect(resolvePlatformAcronym('playstation', settings)).toBe('psx'); + }); + + it('should normalize platform name before lookup', () => { + const settings: Settings = { + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { + 'snes': 'sfc' + } + }; + + expect(resolvePlatformAcronym('SNES', settings)).toBe('sfc'); + expect(resolvePlatformAcronym('Snes', settings)).toBe('sfc'); + expect(resolvePlatformAcronym(' snes ', settings)).toBe('sfc'); + }); + + it('should fall back to default acronym when custom not provided', () => { + const settings: Settings = { + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: {} + }; + + expect(resolvePlatformAcronym('snes', settings)).toBe('snes'); + expect(resolvePlatformAcronym('playstation', settings)).toBe('ps1'); + expect(resolvePlatformAcronym('n64', settings)).toBe('n64'); + expect(resolvePlatformAcronym('game boy', settings)).toBe('gb'); + }); + + it('should fall back to default when custom acronym is invalid', () => { + const settings: Settings = { + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { + 'snes': 'x', // too short + 'playstation': 'verylongacronym!' // too long and invalid chars + } + }; + + expect(resolvePlatformAcronym('snes', settings)).toBe('snes'); + expect(resolvePlatformAcronym('playstation', settings)).toBe('ps1'); + }); + + it('should sanitize platform name when no default exists', () => { + const settings: Settings = { + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: {} + }; + + expect(resolvePlatformAcronym('unknown platform', settings)).toBe('unknown-plat'); + expect(resolvePlatformAcronym('test@platform!', settings)).toBe('test-platfor'); + expect(resolvePlatformAcronym('very-long-platform-name', settings)).toBe('very-long-pl'); + }); + + it('should work without settings parameter', () => { + expect(resolvePlatformAcronym('snes')).toBe('snes'); + expect(resolvePlatformAcronym('playstation')).toBe('ps1'); + expect(resolvePlatformAcronym('unknown platform')).toBe('unknown-plat'); + }); + + it('should handle empty platformAcronyms object', () => { + const settings: Settings = { + downloadDir: './downloads', + libraryDir: './library' + }; + + expect(resolvePlatformAcronym('snes', settings)).toBe('snes'); + }); + }); + + describe('resolvePlatformIcon', () => { + it('should return custom icon when provided in settings', () => { + const settings: Settings = { + downloadDir: './downloads', + libraryDir: './library', + platformIcons: { + 'snes': 'atari', + 'playstation': 'pc' + } + }; + + expect(resolvePlatformIcon('snes', settings)).toBe('atari'); + expect(resolvePlatformIcon('playstation', settings)).toBe('pc'); + }); + + it('should normalize platform name before lookup', () => { + const settings: Settings = { + downloadDir: './downloads', + libraryDir: './library', + platformIcons: { + 'snes': 'pc' + } + }; + + expect(resolvePlatformIcon('SNES', settings)).toBe('pc'); + expect(resolvePlatformIcon('Snes', settings)).toBe('pc'); + expect(resolvePlatformIcon(' snes ', settings)).toBe('pc'); + }); + + it('should fall back to default icon when custom not provided', () => { + const settings: Settings = { + downloadDir: './downloads', + libraryDir: './library', + platformIcons: {} + }; + + expect(resolvePlatformIcon('snes', settings)).toBe('nintendo'); + expect(resolvePlatformIcon('playstation', settings)).toBe('sony'); + expect(resolvePlatformIcon('xbox', settings)).toBe('xbox'); + expect(resolvePlatformIcon('genesis', settings)).toBe('sega'); + }); + + it('should fall back to default when custom icon is invalid', () => { + const settings: Settings = { + downloadDir: './downloads', + libraryDir: './library', + platformIcons: { + 'snes': 'playstation', // invalid brand + 'playstation': 'Microsoft' // invalid brand + } + }; + + expect(resolvePlatformIcon('snes', settings)).toBe('nintendo'); + expect(resolvePlatformIcon('playstation', settings)).toBe('sony'); + }); + + it('should return generic for unknown platforms', () => { + const settings: Settings = { + downloadDir: './downloads', + libraryDir: './library', + platformIcons: {} + }; + + expect(resolvePlatformIcon('unknown platform', settings)).toBe('generic'); + expect(resolvePlatformIcon('future console', settings)).toBe('generic'); + }); + + it('should work without settings parameter', () => { + expect(resolvePlatformIcon('snes')).toBe('nintendo'); + expect(resolvePlatformIcon('playstation')).toBe('sony'); + expect(resolvePlatformIcon('unknown')).toBe('generic'); + }); + + it('should handle empty platformIcons object', () => { + const settings: Settings = { + downloadDir: './downloads', + libraryDir: './library' + }; + + expect(resolvePlatformIcon('snes', settings)).toBe('nintendo'); + }); + }); + + describe('Resolution precedence', () => { + it('should prioritize custom over default for acronyms', () => { + const settings: Settings = { + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { + 'snes': 'custom' + } + }; + + // Custom value should override default + expect(resolvePlatformAcronym('snes', settings)).toBe('custom'); + // Non-customized should still use default + expect(resolvePlatformAcronym('n64', settings)).toBe('n64'); + }); + + it('should prioritize custom over default for icons', () => { + const settings: Settings = { + downloadDir: './downloads', + libraryDir: './library', + platformIcons: { + 'snes': 'sega' // odd but valid choice + } + }; + + // Custom value should override default + expect(resolvePlatformIcon('snes', settings)).toBe('sega'); + // Non-customized should still use default + expect(resolvePlatformIcon('n64', settings)).toBe('nintendo'); + }); + + it('should handle mixed valid and invalid custom values', () => { + const settings: Settings = { + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { + 'snes': 'sfc', // valid + 'n64': 'x', // invalid - too short + 'playstation': 'psx' // valid + }, + platformIcons: { + 'snes': 'pc', // valid + 'n64': 'invalid', // invalid brand + 'genesis': 'atari' // valid + } + }; + + // Valid customs should be used + expect(resolvePlatformAcronym('snes', settings)).toBe('sfc'); + expect(resolvePlatformAcronym('playstation', settings)).toBe('psx'); + expect(resolvePlatformIcon('snes', settings)).toBe('pc'); + expect(resolvePlatformIcon('genesis', settings)).toBe('atari'); + + // Invalid customs should fall back to defaults + expect(resolvePlatformAcronym('n64', settings)).toBe('n64'); + expect(resolvePlatformIcon('n64', settings)).toBe('nintendo'); + }); + }); +}); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 305d252..955ac3c 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,9 +1,149 @@ import type { Settings } from "./types"; +/** + * Default platform acronyms used for folder naming during unpacking. + * Keys are platform identifiers from Crocdb API (lowercase, normalized). + * Values are short acronyms (2-12 chars, alphanumeric with dashes/underscores). + */ +export const DEFAULT_PLATFORM_ACRONYMS: Record = { + // Nintendo + "nes": "nes", + "snes": "snes", + "n64": "n64", + "gamecube": "gcn", + "wii": "wii", + "wiiu": "wiiu", + "switch": "switch", + "game boy": "gb", + "game boy color": "gbc", + "game boy advance": "gba", + "nintendo ds": "nds", + "nintendo 3ds": "3ds", + // Sony + "playstation": "ps1", + "playstation 2": "ps2", + "playstation 3": "ps3", + "playstation 4": "ps4", + "playstation 5": "ps5", + "psp": "psp", + "ps vita": "vita", + // Xbox + "xbox": "xbox", + "xbox 360": "x360", + "xbox one": "xone", + "xbox series x": "xsx", + // Sega + "master system": "sms", + "genesis": "gen", + "mega drive": "md", + "sega cd": "scd", + "saturn": "saturn", + "dreamcast": "dc", + "game gear": "gg", + // Atari + "atari 2600": "a2600", + "atari 5200": "a5200", + "atari 7800": "a7800", + "atari lynx": "lynx", + "atari jaguar": "jag", + // Other + "pc": "pc", + "dos": "dos", + "arcade": "arcade", + "neo geo": "neogeo", + "turbografx-16": "tg16", + "pc engine": "pce", + "wonderswan": "ws", + "3do": "3do", + "commodore 64": "c64", + "amiga": "amiga", + "unknown": "unknown" +}; + +/** + * Default icon slugs for platforms. These correspond to brand keys + * in the PlatformIcon component (nintendo, sony, xbox, sega, pc, atari, commodore, nec). + * Keys are platform identifiers from Crocdb API. + */ +export const DEFAULT_PLATFORM_ICONS: Record = { + // Nintendo platforms use "nintendo" brand + "nes": "nintendo", + "snes": "nintendo", + "n64": "nintendo", + "gamecube": "nintendo", + "wii": "nintendo", + "wiiu": "nintendo", + "switch": "nintendo", + "game boy": "nintendo", + "game boy color": "nintendo", + "game boy advance": "nintendo", + "nintendo ds": "nintendo", + "nintendo 3ds": "nintendo", + // Sony platforms use "sony" brand + "playstation": "sony", + "playstation 2": "sony", + "playstation 3": "sony", + "playstation 4": "sony", + "playstation 5": "sony", + "psp": "sony", + "ps vita": "sony", + // Xbox platforms use "xbox" brand + "xbox": "xbox", + "xbox 360": "xbox", + "xbox one": "xbox", + "xbox series x": "xbox", + // Sega platforms use "sega" brand + "master system": "sega", + "genesis": "sega", + "mega drive": "sega", + "sega cd": "sega", + "saturn": "sega", + "dreamcast": "sega", + "game gear": "sega", + // Atari platforms use "atari" brand + "atari 2600": "atari", + "atari 5200": "atari", + "atari 7800": "atari", + "atari lynx": "atari", + "atari jaguar": "atari", + // PC platforms use "pc" brand + "pc": "pc", + "dos": "pc", + // NEC platforms use "nec" brand + "turbografx-16": "nec", + "pc engine": "nec", + // Commodore platforms use "commodore" brand + "commodore 64": "commodore", + "amiga": "commodore", + // Generic for others + "arcade": "generic", + "neo geo": "generic", + "wonderswan": "generic", + "3do": "generic", + "unknown": "generic" +}; + +/** + * Valid icon brand keys that can be used in platformIcons overrides. + */ +export const VALID_ICON_BRANDS = [ + "nintendo", + "sony", + "xbox", + "sega", + "pc", + "atari", + "commodore", + "nec", + "generic" +] as const; + export const DEFAULT_SETTINGS: Settings = { downloadDir: "./downloads", libraryDir: "./library", queue: { concurrency: 2 - } + }, + platformAcronyms: {}, + platformIcons: {} }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b316008..af9ede3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,2 +1,3 @@ export * from "./types"; export * from "./constants"; +export * from "./utils"; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 7438515..b82bc7d 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -73,6 +73,20 @@ export type Settings = { queue?: { concurrency?: number; }; + /** + * Custom platform acronyms for folder naming. + * Keys are platform identifiers (lowercase, normalized). + * Values are acronyms (2-12 chars, [a-z0-9-_]). + * Empty object means use defaults from DEFAULT_PLATFORM_ACRONYMS. + */ + platformAcronyms?: Record; + /** + * Custom platform icon brand slugs. + * Keys are platform identifiers (lowercase, normalized). + * Values are brand slugs (nintendo, sony, xbox, sega, pc, atari, commodore, nec, generic). + * Empty object means use defaults from DEFAULT_PLATFORM_ICONS. + */ + platformIcons?: Record; }; export type ManifestArtifact = { @@ -87,6 +101,7 @@ export type Manifest = { slug: string; title: string; platform: string; + platformAcronym?: string; regions: string[]; }; artifacts: ManifestArtifact[]; diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts new file mode 100644 index 0000000..712b65f --- /dev/null +++ b/packages/shared/src/utils.ts @@ -0,0 +1,85 @@ +import { DEFAULT_PLATFORM_ACRONYMS, DEFAULT_PLATFORM_ICONS, VALID_ICON_BRANDS } from "./constants"; +import type { Settings } from "./types"; + +/** + * Validates a platform acronym format. + * Must be 2-12 characters, alphanumeric with dashes and underscores allowed. + */ +export function isValidAcronym(acronym: string): boolean { + return /^[a-z0-9_-]{2,12}$/i.test(acronym); +} + +/** + * Validates an icon brand slug. + * Must be one of the predefined valid icon brands. + */ +export function isValidIconBrand(brand: string): boolean { + return VALID_ICON_BRANDS.includes(brand as typeof VALID_ICON_BRANDS[number]); +} + +/** + * Resolves the effective platform acronym for a given platform. + * Checks user overrides first, then falls back to defaults. + * Normalizes the platform key to lowercase for consistent lookups. + */ +export function resolvePlatformAcronym( + platform: string, + settings?: Settings +): string { + const normalizedPlatform = platform.toLowerCase().trim(); + + // Check user overrides + const customAcronym = settings?.platformAcronyms?.[normalizedPlatform]; + if (customAcronym && isValidAcronym(customAcronym)) { + return customAcronym; + } + + // Fallback to defaults + const defaultAcronym = DEFAULT_PLATFORM_ACRONYMS[normalizedPlatform]; + if (defaultAcronym) { + return defaultAcronym; + } + + // Last resort: use the platform name itself (sanitized) + return sanitizePlatformForFolder(normalizedPlatform); +} + +/** + * Resolves the effective icon brand for a given platform. + * Checks user overrides first, then falls back to defaults. + * Normalizes the platform key to lowercase for consistent lookups. + */ +export function resolvePlatformIcon( + platform: string, + settings?: Settings +): string { + const normalizedPlatform = platform.toLowerCase().trim(); + + // Check user overrides + const customIcon = settings?.platformIcons?.[normalizedPlatform]; + if (customIcon && isValidIconBrand(customIcon)) { + return customIcon; + } + + // Fallback to defaults + const defaultIcon = DEFAULT_PLATFORM_ICONS[normalizedPlatform]; + if (defaultIcon) { + return defaultIcon; + } + + // Last resort: generic + return "generic"; +} + +/** + * Sanitizes a platform name to be safe for use as a folder name. + * Removes special characters and limits length. + */ +function sanitizePlatformForFolder(platform: string): string { + return platform + .replace(/[^a-z0-9]/gi, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .toLowerCase() + .slice(0, 12); +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index bde3692..2aa9429 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", - "module": "CommonJS", + "module": "ESNext", "declaration": true }, "include": [ diff --git a/tests/e2e/platform-settings.spec.ts b/tests/e2e/platform-settings.spec.ts new file mode 100644 index 0000000..dca546d --- /dev/null +++ b/tests/e2e/platform-settings.spec.ts @@ -0,0 +1,267 @@ +import { test, expect } from '@playwright/test'; + +/** + * E2E tests for Platform Settings Configuration + * + * Tests the full integration of platform acronyms and icons: + * 1. API endpoints for getting/setting platform configuration + * 2. Settings persistence across page reloads + * 3. Validation of custom acronyms and icons + */ + +test.describe('Platform Settings E2E', () => { + const API_BASE = 'http://localhost:3333'; + + test('should get default settings via API', async ({ request }) => { + const response = await request.get(`${API_BASE}/settings`); + expect(response.ok()).toBeTruthy(); + + const settings = await response.json(); + expect(settings).toHaveProperty('downloadDir'); + expect(settings).toHaveProperty('libraryDir'); + expect(settings).toHaveProperty('platformAcronyms'); + expect(settings).toHaveProperty('platformIcons'); + + // Should have empty override objects by default (or may have been set by previous tests) + expect(settings.platformAcronyms).toBeDefined(); + expect(settings.platformIcons).toBeDefined(); + }); + + test('should accept valid custom platform acronyms via API', async ({ request }) => { + const customSettings = { + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { + 'snes': 'sfc', + 'playstation': 'psx', + 'n64': 'n64' + }, + platformIcons: { + 'snes': 'nintendo', + 'playstation': 'sony' + } + }; + + const putResponse = await request.put(`${API_BASE}/settings`, { + data: customSettings + }); + expect(putResponse.ok()).toBeTruthy(); + expect(await putResponse.json()).toEqual({ ok: true }); + + // Verify settings were saved + const getResponse = await request.get(`${API_BASE}/settings`); + const savedSettings = await getResponse.json(); + + expect(savedSettings.platformAcronyms).toEqual(customSettings.platformAcronyms); + expect(savedSettings.platformIcons).toEqual(customSettings.platformIcons); + }); + + test('should reject invalid acronyms via API', async ({ request }) => { + const invalidSettings = { + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { + 'snes': 'x' // Too short + } + }; + + const response = await request.put(`${API_BASE}/settings`, { + data: invalidSettings + }); + + expect(response.status()).toBe(400); + const error = await response.json(); + expect(error.error).toContain('Invalid acronym "x"'); + expect(error.error).toContain('2-12 characters'); + }); + + test('should reject invalid icon brands via API', async ({ request }) => { + const invalidSettings = { + downloadDir: './downloads', + libraryDir: './library', + platformIcons: { + 'snes': 'playstation' // Invalid brand + } + }; + + const response = await request.put(`${API_BASE}/settings`, { + data: invalidSettings + }); + + expect(response.status()).toBe(400); + const error = await response.json(); + expect(error.error).toContain('Invalid icon brand "playstation"'); + }); + + test('should persist settings across requests', async ({ request }) => { + // Set custom settings + const customSettings = { + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { + 'genesis': 'md', + 'sega cd': 'scd' + }, + platformIcons: { + 'genesis': 'sega' + } + }; + + await request.put(`${API_BASE}/settings`, { + data: customSettings + }); + + // Verify settings are persisted with a new request + const response = await request.get(`${API_BASE}/settings`); + const settings = await response.json(); + + expect(settings.platformAcronyms).toEqual(customSettings.platformAcronyms); + expect(settings.platformIcons).toEqual(customSettings.platformIcons); + }); + + test('should accept all valid icon brands', async ({ request }) => { + const validBrands = ['nintendo', 'sony', 'xbox', 'sega', 'pc', 'atari', 'commodore', 'nec', 'generic']; + + for (const brand of validBrands) { + const settings = { + downloadDir: './downloads', + libraryDir: './library', + platformIcons: { + 'test-platform': brand + } + }; + + const response = await request.put(`${API_BASE}/settings`, { + data: settings + }); + + expect(response.ok()).toBeTruthy(); + } + }); + + test('should validate acronym length constraints', async ({ request }) => { + // Test minimum length (2 chars) + const tooShort = await request.put(`${API_BASE}/settings`, { + data: { + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { 'test': 'a' } + } + }); + expect(tooShort.status()).toBe(400); + + // Test maximum length (12 chars) + const tooLong = await request.put(`${API_BASE}/settings`, { + data: { + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { 'test': 'verylongacronym' } + } + }); + expect(tooLong.status()).toBe(400); + + // Test valid 2-char + const validMin = await request.put(`${API_BASE}/settings`, { + data: { + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { 'test': 'ab' } + } + }); + expect(validMin.ok()).toBeTruthy(); + + // Test valid 12-char + const validMax = await request.put(`${API_BASE}/settings`, { + data: { + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { 'test': 'twelve-chars' } + } + }); + expect(validMax.ok()).toBeTruthy(); + }); + + test('should validate acronym character constraints', async ({ request }) => { + // Test invalid characters + const invalidChars = ['test!', 'test@platform', 'test.platform', 'test/platform', 'test platform']; + + for (const acronym of invalidChars) { + const response = await request.put(`${API_BASE}/settings`, { + data: { + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { 'test': acronym } + } + }); + expect(response.status()).toBe(400); + } + + // Test valid characters + const validChars = ['test-abc', 'test_abc', 'test123', 'TEST', 'abc-123_xyz']; + + for (const acronym of validChars) { + const response = await request.put(`${API_BASE}/settings`, { + data: { + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { 'test': acronym } + } + }); + expect(response.ok()).toBeTruthy(); + } + }); + + test('should allow empty override objects', async ({ request }) => { + const response = await request.put(`${API_BASE}/settings`, { + data: { + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: {}, + platformIcons: {} + } + }); + + expect(response.ok()).toBeTruthy(); + }); + + test('should allow missing override fields', async ({ request }) => { + const response = await request.put(`${API_BASE}/settings`, { + data: { + downloadDir: './downloads', + libraryDir: './library' + } + }); + + expect(response.ok()).toBeTruthy(); + }); + + test('should handle mixed valid and invalid configurations', async ({ request }) => { + // Should reject if ANY value is invalid + const mixedSettings = { + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: { + 'snes': 'sfc', // valid + 'n64': 'x' // invalid - too short + } + }; + + const response = await request.put(`${API_BASE}/settings`, { + data: mixedSettings + }); + + expect(response.status()).toBe(400); + }); + + test.afterAll(async ({ request }) => { + // Reset settings to defaults + await request.put(`${API_BASE}/settings`, { + data: { + downloadDir: './downloads', + libraryDir: './library', + platformAcronyms: {}, + platformIcons: {} + } + }); + }); +});