Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 15 additions & 14 deletions js/gamePass.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -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...`);

Expand Down Expand Up @@ -238,31 +239,31 @@ function getProductTitle(game, productTitleProperty) {

return game.LocalizedProperties?.[0]?.ProductTitle?.length > 0
? game.LocalizedProperties[0].ProductTitle
: emptyValuePlaceholder;
: emptyValue();
}

function getProductId(game, productIdProperty) {
if (!productIdProperty) { return undefined; }

return game.ProductId?.length > 0
? game.ProductId
: emptyValuePlaceholder;
: emptyValue();
}

function getDeveloperName(game, developerNameProperty) {
if (!developerNameProperty) { return undefined; }

return game.LocalizedProperties?.[0]?.DeveloperName?.length > 0
? game.LocalizedProperties[0].DeveloperName
: emptyValuePlaceholder;
: emptyValue();
}

function getPublisherName(game, publisherNameProperty) {
if (!publisherNameProperty) { return undefined; }

return game.LocalizedProperties?.[0]?.PublisherName?.length > 0
? game.LocalizedProperties[0].PublisherName
: emptyValuePlaceholder;
: emptyValue();
}

function getProductDescription(game, productDescriptionProperty) {
Expand All @@ -273,7 +274,7 @@ function getProductDescription(game, productDescriptionProperty) {
} else {
return game.LocalizedProperties?.[0]?.ProductDescription?.length > 0
? game.LocalizedProperties[0].ProductDescription
: emptyValuePlaceholder;
: emptyValue();
}
}

Expand Down Expand Up @@ -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") {
Expand All @@ -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
Expand Down Expand Up @@ -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 ?? {};
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
83 changes: 83 additions & 0 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
});
39 changes: 38 additions & 1 deletion test/cliConfig.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 });
}
});
});
118 changes: 118 additions & 0 deletions test/format.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading