Skip to content

Commit 1894848

Browse files
authored
Better emptyValuePlaceholder handling, additional test coverage (#22)
1 parent c00d68c commit 1894848

7 files changed

Lines changed: 387 additions & 16 deletions

File tree

js/gamePass.js

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import path from 'path';
66

77
import { CONFIG, initConfig, outputPath } from './utils.js';
88

9-
// Set once per run from CONFIG.treatEmptyStringsAsNull
10-
let emptyValuePlaceholder;
9+
// The placeholder written when a requested property has no value, derived at call time from CONFIG.treatEmptyStringsAsNull
10+
function emptyValue() {
11+
return (CONFIG.treatEmptyStringsAsNull ?? true) ? null : "";
12+
}
1113

1214
// Fetch (or read saved), format and write Game Pass data for every configured market and platform
1315
// When fromDirectory is set, previously-saved completeGameProperties_*.json files are re-formatted instead of fetching
1416
export async function run(config, { fromDirectory } = {}) {
1517
initConfig(config);
16-
emptyValuePlaceholder = (CONFIG.treatEmptyStringsAsNull ?? true) ? null : "";
1718

1819
if (fromDirectory) {
1920
console.log(`Re-formatting from saved responses in "${fromDirectory}" (no fetching)...\n`);
@@ -141,7 +142,7 @@ async function fetchGameProperties(gameIds, passType, market) {
141142
}
142143

143144
// Format the data according to the configuration
144-
function formatData(gameProperties, passType) {
145+
export function formatData(gameProperties, passType) {
145146
const products = gameProperties.Products ?? [];
146147
console.log(`Formatting game properties for ${products.length} ${passType} games...`);
147148

@@ -238,31 +239,31 @@ function getProductTitle(game, productTitleProperty) {
238239

239240
return game.LocalizedProperties?.[0]?.ProductTitle?.length > 0
240241
? game.LocalizedProperties[0].ProductTitle
241-
: emptyValuePlaceholder;
242+
: emptyValue();
242243
}
243244

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

247248
return game.ProductId?.length > 0
248249
? game.ProductId
249-
: emptyValuePlaceholder;
250+
: emptyValue();
250251
}
251252

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

255256
return game.LocalizedProperties?.[0]?.DeveloperName?.length > 0
256257
? game.LocalizedProperties[0].DeveloperName
257-
: emptyValuePlaceholder;
258+
: emptyValue();
258259
}
259260

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

263264
return game.LocalizedProperties?.[0]?.PublisherName?.length > 0
264265
? game.LocalizedProperties[0].PublisherName
265-
: emptyValuePlaceholder;
266+
: emptyValue();
266267
}
267268

268269
function getProductDescription(game, productDescriptionProperty) {
@@ -273,7 +274,7 @@ function getProductDescription(game, productDescriptionProperty) {
273274
} else {
274275
return game.LocalizedProperties?.[0]?.ProductDescription?.length > 0
275276
? game.LocalizedProperties[0].ProductDescription
276-
: emptyValuePlaceholder;
277+
: emptyValue();
277278
}
278279
}
279280

@@ -317,7 +318,7 @@ function getReleaseDate(game, releaseDateProperty) {
317318

318319
const releaseDate = game.MarketProperties?.[0]?.OriginalReleaseDate;
319320
if (!releaseDate || releaseDate.length === 0) {
320-
return emptyValuePlaceholder;
321+
return emptyValue();
321322
}
322323

323324
if (releaseDateProperty.format === "date") {
@@ -343,7 +344,7 @@ function getUserRating(game, userRatingProperty) {
343344

344345
// Games without any rating data for the requested interval
345346
if (typeof userRating !== "number") {
346-
return emptyValuePlaceholder;
347+
return emptyValue();
347348
}
348349

349350
// Convert to a percentage if requested
@@ -388,7 +389,7 @@ function getPricing(game, pricingProperty) {
388389
return prices;
389390
}
390391

391-
function getCategories(game, categoriesProperty) {
392+
export function getCategories(game, categoriesProperty) {
392393
if (!categoriesProperty) { return undefined; }
393394

394395
const properties = game.Properties ?? {};
@@ -402,11 +403,11 @@ function getCategories(game, categoriesProperty) {
402403
return categories;
403404
}
404405

405-
function getStorePageUrl(game, storePageUrlProperty) {
406+
export function getStorePageUrl(game, storePageUrlProperty) {
406407
if (!storePageUrlProperty) { return undefined; }
407408

408409
if (!game.LocalizedProperties?.[0]?.ProductTitle || !game.ProductId) {
409-
return emptyValuePlaceholder;
410+
return emptyValue();
410411
}
411412

412413
// 1. Convert to lowercase

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"node": ">=22.13.0"
3333
},
3434
"scripts": {
35-
"test": "node --test"
35+
"test": "node --test --experimental-test-module-mocks"
3636
},
3737
"funding": [
3838
"https://github.com/sponsors/NikkelM",

test/cli.test.mjs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Description: Subprocess tests for the CLI command wrappers (bin/cli.js) and the config-loading guards (no network)
2+
3+
import { describe, it } from 'node:test';
4+
import assert from 'node:assert/strict';
5+
import { spawnSync } from 'node:child_process';
6+
import fs from 'node:fs';
7+
import os from 'node:os';
8+
import path from 'node:path';
9+
import { fileURLToPath } from 'node:url';
10+
11+
const here = path.dirname(fileURLToPath(import.meta.url));
12+
const cli = path.join(here, '..', 'bin', 'cli.js');
13+
14+
// Run the CLI in a throwaway working directory, optionally seeding files first
15+
function runCli(args, files = {}) {
16+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gpa-cli-'));
17+
try {
18+
for (const [name, contents] of Object.entries(files)) {
19+
fs.writeFileSync(path.join(cwd, name), contents);
20+
}
21+
const result = spawnSync(process.execPath, [cli, ...args], { cwd, encoding: 'utf8' });
22+
return { code: result.status, out: (result.stdout ?? '') + (result.stderr ?? '') };
23+
} finally {
24+
fs.rmSync(cwd, { recursive: true, force: true });
25+
}
26+
}
27+
28+
describe('CLI command wrappers', () => {
29+
it('--version prints the package version', () => {
30+
const { code, out } = runCli(['--version']);
31+
assert.equal(code, 0);
32+
assert.match(out.trim(), /^\d+\.\d+\.\d+/);
33+
});
34+
35+
it('--help lists every command', () => {
36+
const { code, out } = runCli(['--help']);
37+
assert.equal(code, 0);
38+
for (const command of ['run', 'init']) {
39+
assert.match(out, new RegExp(command));
40+
}
41+
});
42+
43+
it('an unknown command exits non-zero', () => {
44+
const { code } = runCli(['definitelyNotACommand']);
45+
assert.notEqual(code, 0);
46+
});
47+
});
48+
49+
describe('CLI configuration guards', () => {
50+
it('exits with a friendly message when no config file is found', () => {
51+
const { code, out } = runCli([]);
52+
assert.equal(code, 1);
53+
assert.match(out, /no "config\.json" found/);
54+
});
55+
56+
it('reports a malformed config file as invalid JSON', () => {
57+
const { code, out } = runCli([], { 'config.json': '{ not valid json ' });
58+
assert.equal(code, 1);
59+
assert.match(out, /Error parsing configuration file/);
60+
});
61+
62+
it('rejects an unknown top-level config key', () => {
63+
const config = { markets: ['US'], language: 'en-us', platformsToFetch: ['console'], outputFormat: 'array', includedProperties: { productTitle: true }, bogusKey: true };
64+
const { code, out } = runCli([], { 'config.json': JSON.stringify(config) });
65+
assert.equal(code, 1);
66+
assert.match(out, /Error validating configuration file/);
67+
});
68+
69+
it('strips a UTF-8 BOM before parsing the config', () => {
70+
// A BOM-prefixed config that parses but fails schema validation reaches validation, not a load/parse error, proving the BOM was stripped
71+
const config = { markets: ['XX'], language: 'en-us', platformsToFetch: ['console'], outputFormat: 'array', includedProperties: { productTitle: true } };
72+
const { code, out } = runCli([], { 'config.json': '\uFEFF' + JSON.stringify(config) });
73+
assert.equal(code, 1);
74+
assert.doesNotMatch(out, /Error parsing configuration file/);
75+
assert.match(out, /Error validating configuration file/);
76+
});
77+
78+
it('errors when --config points at a nonexistent file', () => {
79+
const { code, out } = runCli(['run', '--config', 'nope.json']);
80+
assert.equal(code, 1);
81+
assert.match(out, /no configuration file found/);
82+
});
83+
});

test/cliConfig.test.mjs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
import { describe, it } from 'node:test';
44
import assert from 'node:assert/strict';
55
import { spawnSync } from 'node:child_process';
6+
import fs from 'node:fs';
7+
import os from 'node:os';
68
import path from 'node:path';
79
import { fileURLToPath } from 'node:url';
810

911
import { buildConfig, BOOLEAN_PROPERTIES } from '../js/cliConfig.js';
10-
import { validateConfigResult } from '../js/utils.js';
12+
import { validateConfigResult, saveConfigToFile } from '../js/utils.js';
1113

1214
const here = path.dirname(fileURLToPath(import.meta.url));
1315
const cli = path.join(here, '..', 'bin', 'cli.js');
@@ -66,3 +68,38 @@ describe('CLI flag-driven mode', () => {
6668
assert.match((result.stdout ?? '') + (result.stderr ?? ''), /invalid market code/);
6769
});
6870
});
71+
72+
describe('saveConfigToFile', () => {
73+
it('writes a validated flag-built config and strips any secret fields', async () => {
74+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gpa-save-'));
75+
const out = path.join(dir, 'config.json');
76+
try {
77+
const config = { ...buildConfig({ markets: 'US,DE' }), someSecret: 'should_not_persist' };
78+
await saveConfigToFile(config, out, ['someSecret']);
79+
const written = JSON.parse(fs.readFileSync(out, 'utf8'));
80+
assert.ok(!('someSecret' in written), 'a secret field must never be written to disk');
81+
assert.deepEqual(written.markets, ['US', 'DE']);
82+
assert.equal(validateConfigResult(written).errors.length, 0);
83+
} finally {
84+
fs.rmSync(dir, { recursive: true, force: true });
85+
}
86+
});
87+
88+
it('refuses to overwrite an existing file non-interactively', async () => {
89+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gpa-save-'));
90+
const out = path.join(dir, 'config.json');
91+
try {
92+
fs.writeFileSync(out, '{"existing":true}');
93+
const originalIsTTY = process.stdin.isTTY;
94+
process.stdin.isTTY = false;
95+
try {
96+
await assert.rejects(saveConfigToFile(buildConfig({}), out), /already exists/);
97+
} finally {
98+
process.stdin.isTTY = originalIsTTY;
99+
}
100+
assert.equal(fs.readFileSync(out, 'utf8'), '{"existing":true}', 'the existing file must be left untouched');
101+
} finally {
102+
fs.rmSync(dir, { recursive: true, force: true });
103+
}
104+
});
105+
});

test/format.test.mjs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Description: Offline tests for the Game Pass property formatting helpers (output shaping, extractors, store-page slug)
2+
3+
import { describe, it, after } from 'node:test';
4+
import assert from 'node:assert/strict';
5+
import fs from 'node:fs';
6+
import os from 'node:os';
7+
import path from 'node:path';
8+
9+
import { formatData, getStorePageUrl, getCategories } from '../js/gamePass.js';
10+
import { initConfig } from '../js/utils.js';
11+
12+
// A shared temp output directory so initConfig's setupOutput never writes into the repo
13+
const outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'gpa-format-'));
14+
after(() => fs.rmSync(outputDirectory, { recursive: true, force: true }));
15+
16+
// Point CONFIG at the temp directory while setting the fields a given test needs
17+
function useConfig(overrides = {}) {
18+
initConfig({ language: 'en-us', treatEmptyStringsAsNull: true, outputDirectory, ...overrides });
19+
}
20+
21+
// A minimal display-catalog product with the fields the extractors read
22+
function product(overrides = {}) {
23+
return {
24+
ProductId: 'ABC123',
25+
LocalizedProperties: [{ ProductTitle: 'Halo Infinite', DeveloperName: '343 Industries', PublisherName: 'Xbox Game Studios' }],
26+
Properties: { Category: 'Shooter', Categories: ['Action', 'Shooter'] },
27+
...overrides
28+
};
29+
}
30+
31+
describe('formatData output shaping', () => {
32+
it('produces an array keyed by insertion order', () => {
33+
useConfig({ outputFormat: 'array', includedProperties: { productTitle: true } });
34+
const out = formatData({ Products: [product(), product({ ProductId: 'D2', LocalizedProperties: [{ ProductTitle: 'Forza' }] })] }, 'console');
35+
assert.ok(Array.isArray(out));
36+
assert.deepEqual(out, [{ productTitle: 'Halo Infinite' }, { productTitle: 'Forza' }]);
37+
});
38+
39+
it('keys a dictionary by productId', () => {
40+
useConfig({ outputFormat: 'productId', includedProperties: { productTitle: true } });
41+
const out = formatData({ Products: [product()] }, 'console');
42+
assert.deepEqual(out, { ABC123: { productTitle: 'Halo Infinite' } });
43+
});
44+
45+
it('keys a dictionary by productTitle and disambiguates duplicate titles', () => {
46+
useConfig({ outputFormat: 'productTitle', includedProperties: { productId: true } });
47+
const out = formatData({ Products: [product(), product({ ProductId: 'DEF456' })] }, 'console');
48+
assert.deepEqual(Object.keys(out), ['Halo Infinite', 'Halo Infinite (DEF456)']);
49+
assert.equal(out['Halo Infinite'].productId, 'ABC123');
50+
assert.equal(out['Halo Infinite (DEF456)'].productId, 'DEF456');
51+
});
52+
53+
it('keys a dictionary by rolling integer for 0-indexed', () => {
54+
useConfig({ outputFormat: '0-indexed', includedProperties: { productTitle: true } });
55+
const out = formatData({ Products: [product(), product({ ProductId: 'D2' })] }, 'console');
56+
assert.deepEqual(Object.keys(out), ['0', '1']);
57+
});
58+
});
59+
60+
describe('formatData property extraction', () => {
61+
it('includes only the requested properties', () => {
62+
useConfig({ outputFormat: 'array', includedProperties: { productTitle: true, productId: true, developerName: true, publisherName: true } });
63+
const [entry] = formatData({ Products: [product()] }, 'console');
64+
assert.deepEqual(entry, {
65+
productTitle: 'Halo Infinite',
66+
productId: 'ABC123',
67+
developerName: '343 Industries',
68+
publisherName: 'Xbox Game Studios'
69+
});
70+
});
71+
72+
it('uses null for an empty value when treatEmptyStringsAsNull is true', () => {
73+
useConfig({ outputFormat: 'array', treatEmptyStringsAsNull: true, includedProperties: { developerName: true } });
74+
const [entry] = formatData({ Products: [product({ LocalizedProperties: [{ ProductTitle: 'X', DeveloperName: '' }] })] }, 'console');
75+
assert.equal(entry.developerName, null);
76+
});
77+
78+
it('uses an empty string for an empty value when treatEmptyStringsAsNull is false', () => {
79+
useConfig({ outputFormat: 'array', treatEmptyStringsAsNull: false, includedProperties: { developerName: true } });
80+
const [entry] = formatData({ Products: [product({ LocalizedProperties: [{ ProductTitle: 'X', DeveloperName: '' }] })] }, 'console');
81+
assert.equal(entry.developerName, '');
82+
});
83+
});
84+
85+
describe('getStorePageUrl', () => {
86+
it('builds a slugged Xbox store URL from the title and product ID', () => {
87+
useConfig({ language: 'en-us' });
88+
assert.equal(getStorePageUrl(product(), true), 'https://www.xbox.com/en-us/games/store/halo-infinite/ABC123');
89+
});
90+
91+
it('collapses punctuation and repeated separators into single dashes', () => {
92+
useConfig({ language: 'de-de' });
93+
const url = getStorePageUrl(product({ LocalizedProperties: [{ ProductTitle: "Marvel's Guardians: The Game!!" }] }), true);
94+
assert.equal(url, 'https://www.xbox.com/de-de/games/store/marvel-s-guardians-the-game/ABC123');
95+
});
96+
97+
it('returns the empty-value placeholder when the title or ID is missing', () => {
98+
useConfig({ treatEmptyStringsAsNull: true });
99+
assert.equal(getStorePageUrl(product({ ProductId: '' }), true), null);
100+
});
101+
});
102+
103+
describe('getCategories', () => {
104+
it('merges the main Category into the Categories list without duplicating it', () => {
105+
useConfig();
106+
assert.deepEqual(getCategories(product(), true), ['Action', 'Shooter']);
107+
});
108+
109+
it('appends the main Category when it is not already listed', () => {
110+
useConfig();
111+
assert.deepEqual(getCategories(product({ Properties: { Category: 'RPG', Categories: ['Action'] } }), true), ['Action', 'RPG']);
112+
});
113+
114+
it('returns undefined when the property is disabled', () => {
115+
useConfig();
116+
assert.equal(getCategories(product(), false), undefined);
117+
});
118+
});

0 commit comments

Comments
 (0)