diff --git a/js/gamePass.js b/js/gamePass.js index 741cf24..3795d2a 100644 --- a/js/gamePass.js +++ b/js/gamePass.js @@ -6,14 +6,15 @@ import path from 'path'; import { CONFIG, initConfig, outputPath } from './utils.js'; -// Set once per run from CONFIG.treatEmptyStringsAsNull -let emptyValuePlaceholder; +// The placeholder written when a requested property has no value, derived at call time from CONFIG.treatEmptyStringsAsNull +function emptyValue() { + return (CONFIG.treatEmptyStringsAsNull ?? true) ? null : ""; +} // Fetch (or read saved), format and write Game Pass data for every configured market and platform // When fromDirectory is set, previously-saved completeGameProperties_*.json files are re-formatted instead of fetching export async function run(config, { fromDirectory } = {}) { initConfig(config); - emptyValuePlaceholder = (CONFIG.treatEmptyStringsAsNull ?? true) ? null : ""; if (fromDirectory) { console.log(`Re-formatting from saved responses in "${fromDirectory}" (no fetching)...\n`); @@ -141,7 +142,7 @@ async function fetchGameProperties(gameIds, passType, market) { } // Format the data according to the configuration -function formatData(gameProperties, passType) { +export function formatData(gameProperties, passType) { const products = gameProperties.Products ?? []; console.log(`Formatting game properties for ${products.length} ${passType} games...`); @@ -238,7 +239,7 @@ function getProductTitle(game, productTitleProperty) { return game.LocalizedProperties?.[0]?.ProductTitle?.length > 0 ? game.LocalizedProperties[0].ProductTitle - : emptyValuePlaceholder; + : emptyValue(); } function getProductId(game, productIdProperty) { @@ -246,7 +247,7 @@ function getProductId(game, productIdProperty) { return game.ProductId?.length > 0 ? game.ProductId - : emptyValuePlaceholder; + : emptyValue(); } function getDeveloperName(game, developerNameProperty) { @@ -254,7 +255,7 @@ function getDeveloperName(game, developerNameProperty) { return game.LocalizedProperties?.[0]?.DeveloperName?.length > 0 ? game.LocalizedProperties[0].DeveloperName - : emptyValuePlaceholder; + : emptyValue(); } function getPublisherName(game, publisherNameProperty) { @@ -262,7 +263,7 @@ function getPublisherName(game, publisherNameProperty) { return game.LocalizedProperties?.[0]?.PublisherName?.length > 0 ? game.LocalizedProperties[0].PublisherName - : emptyValuePlaceholder; + : emptyValue(); } function getProductDescription(game, productDescriptionProperty) { @@ -273,7 +274,7 @@ function getProductDescription(game, productDescriptionProperty) { } else { return game.LocalizedProperties?.[0]?.ProductDescription?.length > 0 ? game.LocalizedProperties[0].ProductDescription - : emptyValuePlaceholder; + : emptyValue(); } } @@ -317,7 +318,7 @@ function getReleaseDate(game, releaseDateProperty) { const releaseDate = game.MarketProperties?.[0]?.OriginalReleaseDate; if (!releaseDate || releaseDate.length === 0) { - return emptyValuePlaceholder; + return emptyValue(); } if (releaseDateProperty.format === "date") { @@ -343,7 +344,7 @@ function getUserRating(game, userRatingProperty) { // Games without any rating data for the requested interval if (typeof userRating !== "number") { - return emptyValuePlaceholder; + return emptyValue(); } // Convert to a percentage if requested @@ -388,7 +389,7 @@ function getPricing(game, pricingProperty) { return prices; } -function getCategories(game, categoriesProperty) { +export function getCategories(game, categoriesProperty) { if (!categoriesProperty) { return undefined; } const properties = game.Properties ?? {}; @@ -402,11 +403,11 @@ function getCategories(game, categoriesProperty) { return categories; } -function getStorePageUrl(game, storePageUrlProperty) { +export function getStorePageUrl(game, storePageUrlProperty) { if (!storePageUrlProperty) { return undefined; } if (!game.LocalizedProperties?.[0]?.ProductTitle || !game.ProductId) { - return emptyValuePlaceholder; + return emptyValue(); } // 1. Convert to lowercase diff --git a/package.json b/package.json index 61167d2..2950707 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "node": ">=22.13.0" }, "scripts": { - "test": "node --test" + "test": "node --test --experimental-test-module-mocks" }, "funding": [ "https://github.com/sponsors/NikkelM", diff --git a/test/cli.test.mjs b/test/cli.test.mjs new file mode 100644 index 0000000..06eb023 --- /dev/null +++ b/test/cli.test.mjs @@ -0,0 +1,83 @@ +// Description: Subprocess tests for the CLI command wrappers (bin/cli.js) and the config-loading guards (no network) + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const cli = path.join(here, '..', 'bin', 'cli.js'); + +// Run the CLI in a throwaway working directory, optionally seeding files first +function runCli(args, files = {}) { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gpa-cli-')); + try { + for (const [name, contents] of Object.entries(files)) { + fs.writeFileSync(path.join(cwd, name), contents); + } + const result = spawnSync(process.execPath, [cli, ...args], { cwd, encoding: 'utf8' }); + return { code: result.status, out: (result.stdout ?? '') + (result.stderr ?? '') }; + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +} + +describe('CLI command wrappers', () => { + it('--version prints the package version', () => { + const { code, out } = runCli(['--version']); + assert.equal(code, 0); + assert.match(out.trim(), /^\d+\.\d+\.\d+/); + }); + + it('--help lists every command', () => { + const { code, out } = runCli(['--help']); + assert.equal(code, 0); + for (const command of ['run', 'init']) { + assert.match(out, new RegExp(command)); + } + }); + + it('an unknown command exits non-zero', () => { + const { code } = runCli(['definitelyNotACommand']); + assert.notEqual(code, 0); + }); +}); + +describe('CLI configuration guards', () => { + it('exits with a friendly message when no config file is found', () => { + const { code, out } = runCli([]); + assert.equal(code, 1); + assert.match(out, /no "config\.json" found/); + }); + + it('reports a malformed config file as invalid JSON', () => { + const { code, out } = runCli([], { 'config.json': '{ not valid json ' }); + assert.equal(code, 1); + assert.match(out, /Error parsing configuration file/); + }); + + it('rejects an unknown top-level config key', () => { + const config = { markets: ['US'], language: 'en-us', platformsToFetch: ['console'], outputFormat: 'array', includedProperties: { productTitle: true }, bogusKey: true }; + const { code, out } = runCli([], { 'config.json': JSON.stringify(config) }); + assert.equal(code, 1); + assert.match(out, /Error validating configuration file/); + }); + + it('strips a UTF-8 BOM before parsing the config', () => { + // A BOM-prefixed config that parses but fails schema validation reaches validation, not a load/parse error, proving the BOM was stripped + const config = { markets: ['XX'], language: 'en-us', platformsToFetch: ['console'], outputFormat: 'array', includedProperties: { productTitle: true } }; + const { code, out } = runCli([], { 'config.json': '\uFEFF' + JSON.stringify(config) }); + assert.equal(code, 1); + assert.doesNotMatch(out, /Error parsing configuration file/); + assert.match(out, /Error validating configuration file/); + }); + + it('errors when --config points at a nonexistent file', () => { + const { code, out } = runCli(['run', '--config', 'nope.json']); + assert.equal(code, 1); + assert.match(out, /no configuration file found/); + }); +}); diff --git a/test/cliConfig.test.mjs b/test/cliConfig.test.mjs index b5df786..af74079 100644 --- a/test/cliConfig.test.mjs +++ b/test/cliConfig.test.mjs @@ -3,11 +3,13 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { buildConfig, BOOLEAN_PROPERTIES } from '../js/cliConfig.js'; -import { validateConfigResult } from '../js/utils.js'; +import { validateConfigResult, saveConfigToFile } from '../js/utils.js'; const here = path.dirname(fileURLToPath(import.meta.url)); const cli = path.join(here, '..', 'bin', 'cli.js'); @@ -66,3 +68,38 @@ describe('CLI flag-driven mode', () => { assert.match((result.stdout ?? '') + (result.stderr ?? ''), /invalid market code/); }); }); + +describe('saveConfigToFile', () => { + it('writes a validated flag-built config and strips any secret fields', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gpa-save-')); + const out = path.join(dir, 'config.json'); + try { + const config = { ...buildConfig({ markets: 'US,DE' }), someSecret: 'should_not_persist' }; + await saveConfigToFile(config, out, ['someSecret']); + const written = JSON.parse(fs.readFileSync(out, 'utf8')); + assert.ok(!('someSecret' in written), 'a secret field must never be written to disk'); + assert.deepEqual(written.markets, ['US', 'DE']); + assert.equal(validateConfigResult(written).errors.length, 0); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('refuses to overwrite an existing file non-interactively', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gpa-save-')); + const out = path.join(dir, 'config.json'); + try { + fs.writeFileSync(out, '{"existing":true}'); + const originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = false; + try { + await assert.rejects(saveConfigToFile(buildConfig({}), out), /already exists/); + } finally { + process.stdin.isTTY = originalIsTTY; + } + assert.equal(fs.readFileSync(out, 'utf8'), '{"existing":true}', 'the existing file must be left untouched'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/format.test.mjs b/test/format.test.mjs new file mode 100644 index 0000000..8d54e56 --- /dev/null +++ b/test/format.test.mjs @@ -0,0 +1,118 @@ +// Description: Offline tests for the Game Pass property formatting helpers (output shaping, extractors, store-page slug) + +import { describe, it, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { formatData, getStorePageUrl, getCategories } from '../js/gamePass.js'; +import { initConfig } from '../js/utils.js'; + +// A shared temp output directory so initConfig's setupOutput never writes into the repo +const outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'gpa-format-')); +after(() => fs.rmSync(outputDirectory, { recursive: true, force: true })); + +// Point CONFIG at the temp directory while setting the fields a given test needs +function useConfig(overrides = {}) { + initConfig({ language: 'en-us', treatEmptyStringsAsNull: true, outputDirectory, ...overrides }); +} + +// A minimal display-catalog product with the fields the extractors read +function product(overrides = {}) { + return { + ProductId: 'ABC123', + LocalizedProperties: [{ ProductTitle: 'Halo Infinite', DeveloperName: '343 Industries', PublisherName: 'Xbox Game Studios' }], + Properties: { Category: 'Shooter', Categories: ['Action', 'Shooter'] }, + ...overrides + }; +} + +describe('formatData output shaping', () => { + it('produces an array keyed by insertion order', () => { + useConfig({ outputFormat: 'array', includedProperties: { productTitle: true } }); + const out = formatData({ Products: [product(), product({ ProductId: 'D2', LocalizedProperties: [{ ProductTitle: 'Forza' }] })] }, 'console'); + assert.ok(Array.isArray(out)); + assert.deepEqual(out, [{ productTitle: 'Halo Infinite' }, { productTitle: 'Forza' }]); + }); + + it('keys a dictionary by productId', () => { + useConfig({ outputFormat: 'productId', includedProperties: { productTitle: true } }); + const out = formatData({ Products: [product()] }, 'console'); + assert.deepEqual(out, { ABC123: { productTitle: 'Halo Infinite' } }); + }); + + it('keys a dictionary by productTitle and disambiguates duplicate titles', () => { + useConfig({ outputFormat: 'productTitle', includedProperties: { productId: true } }); + const out = formatData({ Products: [product(), product({ ProductId: 'DEF456' })] }, 'console'); + assert.deepEqual(Object.keys(out), ['Halo Infinite', 'Halo Infinite (DEF456)']); + assert.equal(out['Halo Infinite'].productId, 'ABC123'); + assert.equal(out['Halo Infinite (DEF456)'].productId, 'DEF456'); + }); + + it('keys a dictionary by rolling integer for 0-indexed', () => { + useConfig({ outputFormat: '0-indexed', includedProperties: { productTitle: true } }); + const out = formatData({ Products: [product(), product({ ProductId: 'D2' })] }, 'console'); + assert.deepEqual(Object.keys(out), ['0', '1']); + }); +}); + +describe('formatData property extraction', () => { + it('includes only the requested properties', () => { + useConfig({ outputFormat: 'array', includedProperties: { productTitle: true, productId: true, developerName: true, publisherName: true } }); + const [entry] = formatData({ Products: [product()] }, 'console'); + assert.deepEqual(entry, { + productTitle: 'Halo Infinite', + productId: 'ABC123', + developerName: '343 Industries', + publisherName: 'Xbox Game Studios' + }); + }); + + it('uses null for an empty value when treatEmptyStringsAsNull is true', () => { + useConfig({ outputFormat: 'array', treatEmptyStringsAsNull: true, includedProperties: { developerName: true } }); + const [entry] = formatData({ Products: [product({ LocalizedProperties: [{ ProductTitle: 'X', DeveloperName: '' }] })] }, 'console'); + assert.equal(entry.developerName, null); + }); + + it('uses an empty string for an empty value when treatEmptyStringsAsNull is false', () => { + useConfig({ outputFormat: 'array', treatEmptyStringsAsNull: false, includedProperties: { developerName: true } }); + const [entry] = formatData({ Products: [product({ LocalizedProperties: [{ ProductTitle: 'X', DeveloperName: '' }] })] }, 'console'); + assert.equal(entry.developerName, ''); + }); +}); + +describe('getStorePageUrl', () => { + it('builds a slugged Xbox store URL from the title and product ID', () => { + useConfig({ language: 'en-us' }); + assert.equal(getStorePageUrl(product(), true), 'https://www.xbox.com/en-us/games/store/halo-infinite/ABC123'); + }); + + it('collapses punctuation and repeated separators into single dashes', () => { + useConfig({ language: 'de-de' }); + const url = getStorePageUrl(product({ LocalizedProperties: [{ ProductTitle: "Marvel's Guardians: The Game!!" }] }), true); + assert.equal(url, 'https://www.xbox.com/de-de/games/store/marvel-s-guardians-the-game/ABC123'); + }); + + it('returns the empty-value placeholder when the title or ID is missing', () => { + useConfig({ treatEmptyStringsAsNull: true }); + assert.equal(getStorePageUrl(product({ ProductId: '' }), true), null); + }); +}); + +describe('getCategories', () => { + it('merges the main Category into the Categories list without duplicating it', () => { + useConfig(); + assert.deepEqual(getCategories(product(), true), ['Action', 'Shooter']); + }); + + it('appends the main Category when it is not already listed', () => { + useConfig(); + assert.deepEqual(getCategories(product({ Properties: { Category: 'RPG', Categories: ['Action'] } }), true), ['Action', 'RPG']); + }); + + it('returns undefined when the property is disabled', () => { + useConfig(); + assert.equal(getCategories(product(), false), undefined); + }); +}); diff --git a/test/validation.test.mjs b/test/validation.test.mjs new file mode 100644 index 0000000..7f86e55 --- /dev/null +++ b/test/validation.test.mjs @@ -0,0 +1,74 @@ +// Description: Offline tests for schema validation of the configuration file + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { validateConfigResult } from '../js/utils.js'; + +// A minimal valid config; helpers return mutated copies for the reject cases +const base = () => ({ + $schema: 'config.schema.json', + markets: ['US'], + language: 'en-us', + platformsToFetch: ['console', 'pc', 'eaPlay'], + outputFormat: 'array', + includedProperties: { productTitle: true } +}); +const without = (key) => { const c = base(); delete c[key]; return c; }; +const accepts = (config) => validateConfigResult(config).errors.length === 0; + +describe('config schema validation', () => { + it('accepts a minimal valid config', () => { + assert.ok(accepts(base())); + }); + + it('accepts the optional top-level fields', () => { + const config = base(); + config.outputDirectory = 'out'; + config.treatEmptyStringsAsNull = false; + config.keepCompleteProperties = true; + assert.ok(accepts(config)); + }); + + for (const key of ['markets', 'language', 'platformsToFetch', 'outputFormat', 'includedProperties']) { + it(`rejects a config missing the required "${key}"`, () => { + assert.ok(!accepts(without(key))); + }); + } + + it('rejects an unknown top-level key (additionalProperties: false)', () => { + const config = base(); + config.bogusKey = true; + assert.ok(!accepts(config)); + }); + + it('rejects an invalid market code', () => { + const config = base(); + config.markets = ['XX']; + assert.ok(!accepts(config)); + }); + + it('rejects an invalid language code', () => { + const config = base(); + config.language = 'xx-yy'; + assert.ok(!accepts(config)); + }); + + it('rejects an invalid platform', () => { + const config = base(); + config.platformsToFetch = ['switch']; + assert.ok(!accepts(config)); + }); + + it('rejects an invalid outputFormat', () => { + const config = base(); + config.outputFormat = 'nope'; + assert.ok(!accepts(config)); + }); + + it('rejects an empty includedProperties (minProperties)', () => { + const config = base(); + config.includedProperties = {}; + assert.ok(!accepts(config)); + }); +}); diff --git a/test/wizard.test.mjs b/test/wizard.test.mjs new file mode 100644 index 0000000..6310eef --- /dev/null +++ b/test/wizard.test.mjs @@ -0,0 +1,58 @@ +// Description: Verifies the init wizard assembles and writes a schema-valid config (prompts mocked) + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { validateConfigResult } from '../js/utils.js'; + +test('the wizard writes a schema-valid configuration file', async (t) => { + // Mock @inquirer/prompts so the wizard runs without a TTY, answering by prompt message + t.mock.module('@inquirer/prompts', { + namedExports: { + search: async ({ message }) => { + if (message.includes('Market')) return 'US'; + if (message.includes('Language')) return 'en-us'; + return ''; + }, + checkbox: async ({ message }) => { + if (message.includes('platforms')) return ['console', 'pc', 'eaPlay']; + if (message.includes('properties')) return ['productTitle']; + return []; + }, + select: async ({ message }) => { + if (message.includes('structured')) return 'array'; + return 'array'; + }, + input: async ({ message }) => { + if (message.includes('Output directory')) return 'output'; + return ''; + }, + confirm: async ({ message }) => { + if (message.includes('Treat empty')) return true; + // add another market / keep complete / fetch now / overwrite + return false; + } + } + }); + + const { runWizard } = await import('../js/wizard.js'); + + const outputPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'gpa-wiz-')), 'config.json'); + await runWizard(outputPath); + + assert.ok(fs.existsSync(outputPath), 'the wizard should write the config file'); + const written = JSON.parse(fs.readFileSync(outputPath, 'utf8')); + + assert.deepEqual(written.markets, ['US']); + assert.equal(written.language, 'en-us'); + assert.deepEqual(written.platformsToFetch, ['console', 'pc', 'eaPlay']); + assert.equal(written.outputFormat, 'array'); + assert.deepEqual(written.includedProperties, { productTitle: true }); + assert.ok(!('outputDirectory' in written), 'the default output directory should be omitted'); + assert.equal(validateConfigResult(written).errors.length, 0, 'the written config should validate against the schema'); + + fs.rmSync(path.dirname(outputPath), { recursive: true, force: true }); +});