@witchpot/gmc 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/README.md +3 -0
- package/package.json +2 -2
- package/skill/gmc-analysis/CHANGELOG.md +172 -0
- package/skill/gmc-analysis/SKILL.md +372 -7
- package/skill/gmc-analysis.manifest.json +2 -2
- package/src/args.js +42 -4
- package/src/constants.js +20 -3
- package/src/help.js +208 -1
- package/src/index.js +1 -0
- package/src/requests.js +399 -2
- package/src/requests.test.js +870 -0
- package/src/workflows.js +15 -1
- package/src/workflows.test.js +156 -0
package/src/requests.test.js
CHANGED
|
@@ -3,6 +3,7 @@ import test from "node:test";
|
|
|
3
3
|
|
|
4
4
|
import { parseArgs } from "./args.js";
|
|
5
5
|
import { buildRequest } from "./requests.js";
|
|
6
|
+
import { printHelp } from "./help.js";
|
|
6
7
|
|
|
7
8
|
function buildRequestFromArgv(argv) {
|
|
8
9
|
const { command, options, positional } = parseArgs(argv);
|
|
@@ -136,6 +137,48 @@ test("games count forwards coverage for cohort pre-estimation", () => {
|
|
|
136
137
|
assert.equal(request.query.coverage, "full");
|
|
137
138
|
});
|
|
138
139
|
|
|
140
|
+
/**
|
|
141
|
+
* NAK-247. Before this, --has-demo had no mapping in buildGameCountQuery: the
|
|
142
|
+
* generic arg parser (args.js) accepts any --flag with no allowlist for
|
|
143
|
+
* games search/count/aggregate, so a caller's --has-demo silently vanished
|
|
144
|
+
* rather than reaching the server — an accepted-then-dropped request that
|
|
145
|
+
* still gets charged unfiltered. Covers all three commands that share
|
|
146
|
+
* buildGameCountQuery, and both true/false (false is a real filter value,
|
|
147
|
+
* not "unset").
|
|
148
|
+
*/
|
|
149
|
+
test("(NAK-247) games search/count/aggregate forward --has-demo as the hasDemo query param", () => {
|
|
150
|
+
const search = buildRequestFromArgv(["games", "search", "--source", "steam", "--has-demo", "true", "--json"]);
|
|
151
|
+
assert.equal(search.query.hasDemo, "true");
|
|
152
|
+
|
|
153
|
+
const count = buildRequestFromArgv(["games", "count", "--source", "steam", "--has-demo", "false", "--json"]);
|
|
154
|
+
assert.equal(count.query.hasDemo, "false");
|
|
155
|
+
|
|
156
|
+
const aggregate = buildRequestFromArgv([
|
|
157
|
+
"games",
|
|
158
|
+
"aggregate",
|
|
159
|
+
"--source",
|
|
160
|
+
"steam",
|
|
161
|
+
"--metric",
|
|
162
|
+
"count",
|
|
163
|
+
"--has-demo",
|
|
164
|
+
"true",
|
|
165
|
+
"--json"
|
|
166
|
+
]);
|
|
167
|
+
assert.equal(aggregate.query.hasDemo, "true");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("(NAK-247) --has-demo normalizes case and rejects a non-boolean value with a usage error rather than silently dropping the filter", () => {
|
|
171
|
+
const mixedCase = buildRequestFromArgv(["games", "count", "--source", "steam", "--has-demo", "FALSE", "--json"]);
|
|
172
|
+
assert.equal(mixedCase.query.hasDemo, "false");
|
|
173
|
+
|
|
174
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
175
|
+
buildRequestFromArgv(["games", "count", "--source", "steam", "--has-demo", "maybe", "--json"])
|
|
176
|
+
);
|
|
177
|
+
assert.equal(exitCode, 2);
|
|
178
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
179
|
+
assert.match(output.error.message, /--has-demo must be true or false/);
|
|
180
|
+
});
|
|
181
|
+
|
|
139
182
|
test("reviews patterns forwards coverage into the cohort search request", () => {
|
|
140
183
|
const request = buildRequestFromArgv([
|
|
141
184
|
"reviews",
|
|
@@ -173,6 +216,69 @@ test("lists create builds a POST request body", () => {
|
|
|
173
216
|
});
|
|
174
217
|
});
|
|
175
218
|
|
|
219
|
+
test("lists games forwards limit, offset and cursor (NAK-525)", () => {
|
|
220
|
+
const request = buildRequestFromArgv(["lists", "games", "list-1", "--limit", "50", "--cursor", "c1", "--json"]);
|
|
221
|
+
|
|
222
|
+
assert.equal(request.path, "/api/v1/lists/list-1/games");
|
|
223
|
+
assert.equal(request.method, undefined);
|
|
224
|
+
// A dropped --cursor walks the list by offset while the caller believes it is
|
|
225
|
+
// following the cursor, which silently reorders a resolution-backed page.
|
|
226
|
+
assert.deepEqual(request.query, { limit: "50", cursor: "c1" });
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("lists resolve posts to the resolve endpoint with no body (NAK-525)", () => {
|
|
230
|
+
const request = buildRequestFromArgv(["lists", "resolve", "list-1", "--json"]);
|
|
231
|
+
|
|
232
|
+
assert.equal(request.path, "/api/v1/lists/list-1/resolve");
|
|
233
|
+
assert.equal(request.method, "POST");
|
|
234
|
+
assert.deepEqual(request.query, {});
|
|
235
|
+
assert.equal(request.body, undefined);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("lists materialize posts with --confirm, and an empty body means the current revision (NAK-525)", () => {
|
|
239
|
+
const request = buildRequestFromArgv(["lists", "materialize", "list-1", "--confirm", "--json"]);
|
|
240
|
+
|
|
241
|
+
assert.equal(request.path, "/api/v1/lists/list-1/materialize");
|
|
242
|
+
assert.equal(request.method, "POST");
|
|
243
|
+
assert.deepEqual(request.query, {});
|
|
244
|
+
assert.equal(request.body, undefined);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("lists materialize sends --expected-revision as the compare-and-swap body (NAK-525)", () => {
|
|
248
|
+
const request = buildRequestFromArgv([
|
|
249
|
+
"lists",
|
|
250
|
+
"materialize",
|
|
251
|
+
"list-1",
|
|
252
|
+
"--confirm",
|
|
253
|
+
"--expected-revision",
|
|
254
|
+
"4",
|
|
255
|
+
"--json"
|
|
256
|
+
]);
|
|
257
|
+
|
|
258
|
+
assert.deepEqual(request.body, { expectedDefinitionRevision: 4 });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("lists materialize and lists resolve are real command tokens, not positionals (NAK-525)", () => {
|
|
262
|
+
// A token missing from the args.js whitelist falls through to positional and
|
|
263
|
+
// silently runs the bare `gmc lists` index with exit 0 — the worst possible
|
|
264
|
+
// failure mode for a destructive-adjacent command.
|
|
265
|
+
for (const [subcommand, path] of [
|
|
266
|
+
["materialize", "/api/v1/lists/list-1/materialize"],
|
|
267
|
+
["resolve", "/api/v1/lists/list-1/resolve"]
|
|
268
|
+
]) {
|
|
269
|
+
const { command, positional } = parseArgs(["lists", subcommand, "list-1", "--confirm", "--json"]);
|
|
270
|
+
assert.deepEqual(command, ["lists", subcommand]);
|
|
271
|
+
assert.deepEqual(positional, ["list-1"]);
|
|
272
|
+
assert.equal(buildRequest(command, { confirm: true, json: true }, positional).path, path);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("--confirm is a boolean flag, so it does not swallow the flag after it (NAK-525)", () => {
|
|
277
|
+
const { options } = parseArgs(["lists", "materialize", "list-1", "--confirm", "--json"]);
|
|
278
|
+
assert.equal(options.confirm, true);
|
|
279
|
+
assert.equal(options.json, true);
|
|
280
|
+
});
|
|
281
|
+
|
|
176
282
|
test("lists add maps comma-separated app ids to item mutation body", () => {
|
|
177
283
|
const request = buildRequestFromArgv([
|
|
178
284
|
"lists",
|
|
@@ -232,3 +338,767 @@ test("sampled workflows keep exact totals in a separate count request", () => {
|
|
|
232
338
|
releaseTo: "2025-12-31"
|
|
233
339
|
});
|
|
234
340
|
});
|
|
341
|
+
|
|
342
|
+
test("showcases history builds a showcaseHistory request plan requesting the showcases analysis section", () => {
|
|
343
|
+
const request = buildRequestFromArgv([
|
|
344
|
+
"showcases",
|
|
345
|
+
"history",
|
|
346
|
+
"--source",
|
|
347
|
+
"steam",
|
|
348
|
+
"730",
|
|
349
|
+
"--json"
|
|
350
|
+
]);
|
|
351
|
+
|
|
352
|
+
assert.equal(request.kind, "showcaseHistory");
|
|
353
|
+
assert.equal(request.source, "steam");
|
|
354
|
+
assert.equal(request.externalId, "730");
|
|
355
|
+
assert.deepEqual(request.detailQuery, { sections: "showcases" });
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("showcases history forwards locale", () => {
|
|
359
|
+
const request = buildRequestFromArgv([
|
|
360
|
+
"showcases",
|
|
361
|
+
"history",
|
|
362
|
+
"--source",
|
|
363
|
+
"steam",
|
|
364
|
+
"730",
|
|
365
|
+
"--locale",
|
|
366
|
+
"ja",
|
|
367
|
+
"--json"
|
|
368
|
+
]);
|
|
369
|
+
|
|
370
|
+
assert.deepEqual(request.detailQuery, { sections: "showcases", locale: "ja" });
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("games more-like-this targets the canonical route with no country by default", () => {
|
|
374
|
+
const request = buildRequestFromArgv([
|
|
375
|
+
"games",
|
|
376
|
+
"more-like-this",
|
|
377
|
+
"--source",
|
|
378
|
+
"steam",
|
|
379
|
+
"730",
|
|
380
|
+
"--json"
|
|
381
|
+
]);
|
|
382
|
+
|
|
383
|
+
assert.equal(request.path, "/api/v1/steam/games/730/more-like-this");
|
|
384
|
+
// The server owns the us default; the CLI must not send a country of its
|
|
385
|
+
// own invention, or the two defaults could drift apart.
|
|
386
|
+
assert.deepEqual(request.query, {});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("games more-like-this forwards an explicit country, normalized", () => {
|
|
390
|
+
const request = buildRequestFromArgv([
|
|
391
|
+
"games",
|
|
392
|
+
"more-like-this",
|
|
393
|
+
"--source",
|
|
394
|
+
"steam",
|
|
395
|
+
"730",
|
|
396
|
+
"--country",
|
|
397
|
+
"JP",
|
|
398
|
+
"--json"
|
|
399
|
+
]);
|
|
400
|
+
|
|
401
|
+
assert.equal(request.path, "/api/v1/steam/games/730/more-like-this");
|
|
402
|
+
assert.deepEqual(request.query, { country: "jp" });
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// --- gmc games language-topics (NAK-455) ---------------------------------
|
|
406
|
+
|
|
407
|
+
test("games language-topics targets the canonical route and sends no invented parameters", () => {
|
|
408
|
+
const request = buildRequestFromArgv([
|
|
409
|
+
"games",
|
|
410
|
+
"language-topics",
|
|
411
|
+
"--source",
|
|
412
|
+
"steam",
|
|
413
|
+
"730",
|
|
414
|
+
"--json"
|
|
415
|
+
]);
|
|
416
|
+
|
|
417
|
+
assert.equal(request.path, "/api/v1/steam/games/730/review-topics/languages");
|
|
418
|
+
// The server owns the min_reviews default (30) and the include_insufficient
|
|
419
|
+
// default (false). The CLI must not send a copy of either, or the two
|
|
420
|
+
// defaults could drift apart.
|
|
421
|
+
assert.deepEqual(request.query, {});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("games language-topics forwards min_reviews and include_insufficient", () => {
|
|
425
|
+
const request = buildRequestFromArgv([
|
|
426
|
+
"games",
|
|
427
|
+
"language-topics",
|
|
428
|
+
"--source",
|
|
429
|
+
"steam",
|
|
430
|
+
"730",
|
|
431
|
+
"--min-reviews",
|
|
432
|
+
"50",
|
|
433
|
+
"--include-insufficient",
|
|
434
|
+
"--json"
|
|
435
|
+
]);
|
|
436
|
+
|
|
437
|
+
assert.equal(request.path, "/api/v1/steam/games/730/review-topics/languages");
|
|
438
|
+
assert.deepEqual(request.query, { min_reviews: "50", include_insufficient: "true" });
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("games language-topics accepts both ends of the documented min_reviews range", () => {
|
|
442
|
+
for (const value of ["10", "200"]) {
|
|
443
|
+
const request = buildRequestFromArgv([
|
|
444
|
+
"games",
|
|
445
|
+
"language-topics",
|
|
446
|
+
"--source",
|
|
447
|
+
"steam",
|
|
448
|
+
"730",
|
|
449
|
+
"--min-reviews",
|
|
450
|
+
value,
|
|
451
|
+
"--json"
|
|
452
|
+
]);
|
|
453
|
+
|
|
454
|
+
assert.deepEqual(request.query, { min_reviews: value });
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("games language-topics rejects an out-of-range min_reviews before any request is built", () => {
|
|
459
|
+
for (const value of ["9", "201", "30.5", "abc"]) {
|
|
460
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
461
|
+
buildRequestFromArgv([
|
|
462
|
+
"games",
|
|
463
|
+
"language-topics",
|
|
464
|
+
"--source",
|
|
465
|
+
"steam",
|
|
466
|
+
"730",
|
|
467
|
+
"--min-reviews",
|
|
468
|
+
value,
|
|
469
|
+
"--json"
|
|
470
|
+
])
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
assert.equal(exitCode, 2);
|
|
474
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
475
|
+
// Same bound, same wording direction as the REST 400 (param min_reviews).
|
|
476
|
+
assert.match(output.error.message, /--min-reviews must be an integer between 10 and 200/);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* `Number("1e2")` and `Number("0x64")` are both 100, so a `Number`-based bounds
|
|
482
|
+
* check would forward these spellings to an endpoint that rejects them:
|
|
483
|
+
* `parseIntParam` (src/server/gmc/api/validation.ts) tests `/^-?\d+$/` and
|
|
484
|
+
* answers 400 `min_reviews must be an integer`. The CLI must not turn a string
|
|
485
|
+
* the API refuses into a successful request.
|
|
486
|
+
*/
|
|
487
|
+
test("games language-topics rejects the exponent and hex spellings REST rejects", () => {
|
|
488
|
+
for (const value of ["1e2", "0x64", "1_0", " 50", "+50", "50 "]) {
|
|
489
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
490
|
+
buildRequestFromArgv([
|
|
491
|
+
"games",
|
|
492
|
+
"language-topics",
|
|
493
|
+
"--source",
|
|
494
|
+
"steam",
|
|
495
|
+
"730",
|
|
496
|
+
"--min-reviews",
|
|
497
|
+
value,
|
|
498
|
+
"--json"
|
|
499
|
+
])
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
assert.equal(exitCode, 2, `expected --min-reviews ${JSON.stringify(value)} to be rejected`);
|
|
503
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
504
|
+
assert.match(output.error.message, /--min-reviews must be an integer between 10 and 200/);
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Boolean flags are presence-only. `--include-insufficient=false` previously
|
|
510
|
+
* parsed to TRUE (the inline value was discarded), which inverted exactly the
|
|
511
|
+
* intent the caller spelled out. A usage error is the only reading that cannot
|
|
512
|
+
* silently do the opposite of what was asked.
|
|
513
|
+
*/
|
|
514
|
+
test("a boolean flag rejects the --flag=value spelling instead of silently reading it as true", () => {
|
|
515
|
+
for (const flag of ["--include-insufficient=false", "--include-insufficient=true", "--json=false"]) {
|
|
516
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
517
|
+
buildRequestFromArgv([
|
|
518
|
+
"games",
|
|
519
|
+
"language-topics",
|
|
520
|
+
"--source",
|
|
521
|
+
"steam",
|
|
522
|
+
"730",
|
|
523
|
+
flag,
|
|
524
|
+
"--json"
|
|
525
|
+
])
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
assert.equal(exitCode, 2, `expected ${flag} to be rejected`);
|
|
529
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
530
|
+
assert.match(output.error.message, /is a flag and takes no value/);
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* NAK-428 found the same silent-ignore gap here: this endpoint's whole
|
|
536
|
+
* parameter surface is `min_reviews` + `include_insufficient`, so a --limit or
|
|
537
|
+
* --locale was accepted and dropped, and the caller's narrowing vanished with
|
|
538
|
+
* exit 0.
|
|
539
|
+
*/
|
|
540
|
+
test("games language-topics rejects request-shaping flags the endpoint does not have", () => {
|
|
541
|
+
for (const [flag, value] of [
|
|
542
|
+
["--limit", "50"],
|
|
543
|
+
["--locale", "ja"],
|
|
544
|
+
["--offset", "10"],
|
|
545
|
+
["--language", "japanese"]
|
|
546
|
+
]) {
|
|
547
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
548
|
+
buildRequestFromArgv([
|
|
549
|
+
"games",
|
|
550
|
+
"language-topics",
|
|
551
|
+
"--source",
|
|
552
|
+
"steam",
|
|
553
|
+
"730",
|
|
554
|
+
flag,
|
|
555
|
+
value,
|
|
556
|
+
"--json"
|
|
557
|
+
])
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
assert.equal(exitCode, 2, `expected ${flag} to be rejected`);
|
|
561
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
562
|
+
assert.match(
|
|
563
|
+
output.error.message,
|
|
564
|
+
new RegExp(`\\${flag} is not supported by gmc games language-topics`)
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("games language-topics rejects an extra positional argument", () => {
|
|
570
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
571
|
+
buildRequestFromArgv(["games", "language-topics", "--source", "steam", "730", "440", "--json"])
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
assert.equal(exitCode, 2);
|
|
575
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
576
|
+
assert.match(output.error.message, /Unexpected extra argument "440"/);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("the bare boolean flag still enables the option", () => {
|
|
580
|
+
const request = buildRequestFromArgv([
|
|
581
|
+
"games",
|
|
582
|
+
"language-topics",
|
|
583
|
+
"--source",
|
|
584
|
+
"steam",
|
|
585
|
+
"730",
|
|
586
|
+
"--include-insufficient",
|
|
587
|
+
"--json"
|
|
588
|
+
]);
|
|
589
|
+
|
|
590
|
+
assert.deepEqual(request.query, { include_insufficient: "true" });
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// --- gmc games review-history (NAK-428) ----------------------------------
|
|
594
|
+
|
|
595
|
+
test("games review-history targets the canonical route", () => {
|
|
596
|
+
const request = buildRequestFromArgv([
|
|
597
|
+
"games",
|
|
598
|
+
"review-history",
|
|
599
|
+
"--source",
|
|
600
|
+
"steam",
|
|
601
|
+
"730",
|
|
602
|
+
"--json"
|
|
603
|
+
]);
|
|
604
|
+
|
|
605
|
+
assert.equal(request.path, "/api/v1/steam/games/730/review-history");
|
|
606
|
+
assert.deepEqual(request.query, {});
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* The endpoint takes NO query parameters. Range presets are a display concern
|
|
611
|
+
* the web UI applies client-side over the full series, so a --range/--from/
|
|
612
|
+
* --to/--limit here would make the CLI, rather than the service, decide which
|
|
613
|
+
* observations a caller sees.
|
|
614
|
+
*
|
|
615
|
+
* Not supporting them is not the same as tolerating them. Accepting the flag
|
|
616
|
+
* and dropping it returns the FULL series with exit 0, so the caller who asked
|
|
617
|
+
* for a window reads the unwindowed answer as the windowed one — the failure is
|
|
618
|
+
* invisible at exactly the moment it matters. Each rejection is asserted
|
|
619
|
+
* individually so a partial allowlist cannot pass on the strength of its
|
|
620
|
+
* neighbours.
|
|
621
|
+
*/
|
|
622
|
+
test("games review-history rejects every request-shaping flag by name", () => {
|
|
623
|
+
for (const [flag, value] of [
|
|
624
|
+
["--range", "1y"],
|
|
625
|
+
["--from", "2026-01-01"],
|
|
626
|
+
["--to", "2026-08-01"],
|
|
627
|
+
["--limit", "50"],
|
|
628
|
+
["--locale", "ja"],
|
|
629
|
+
["--offset", "10"]
|
|
630
|
+
]) {
|
|
631
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
632
|
+
buildRequestFromArgv([
|
|
633
|
+
"games",
|
|
634
|
+
"review-history",
|
|
635
|
+
"--source",
|
|
636
|
+
"steam",
|
|
637
|
+
"730",
|
|
638
|
+
flag,
|
|
639
|
+
value,
|
|
640
|
+
"--json"
|
|
641
|
+
])
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
assert.equal(exitCode, 2, `expected ${flag} to be rejected`);
|
|
645
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
646
|
+
// The message has to name the flag the caller typed: "unsupported option"
|
|
647
|
+
// alone leaves them guessing which of several it was.
|
|
648
|
+
assert.match(
|
|
649
|
+
output.error.message,
|
|
650
|
+
new RegExp(`\\${flag} is not supported by gmc games review-history`)
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Output shaping never reaches the wire, so it must not trip the allowlist.
|
|
657
|
+
*/
|
|
658
|
+
test("games review-history still accepts the global output flags", () => {
|
|
659
|
+
const request = buildRequestFromArgv([
|
|
660
|
+
"games",
|
|
661
|
+
"review-history",
|
|
662
|
+
"--source",
|
|
663
|
+
"steam",
|
|
664
|
+
"730",
|
|
665
|
+
"--fields",
|
|
666
|
+
"series",
|
|
667
|
+
"--json"
|
|
668
|
+
]);
|
|
669
|
+
|
|
670
|
+
assert.equal(request.path, "/api/v1/steam/games/730/review-history");
|
|
671
|
+
assert.deepEqual(request.query, {});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* A second bare argument is either a typo or a second appid the caller expected
|
|
676
|
+
* to be fetched. Both readings are worse when the first app's history comes
|
|
677
|
+
* back as if nothing were wrong.
|
|
678
|
+
*/
|
|
679
|
+
test("games review-history rejects an extra positional argument", () => {
|
|
680
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
681
|
+
buildRequestFromArgv(["games", "review-history", "--source", "steam", "730", "440", "--json"])
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
assert.equal(exitCode, 2);
|
|
685
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
686
|
+
assert.match(output.error.message, /Unexpected extra argument "440"/);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test("games review-history percent-encodes the external id into the path", () => {
|
|
690
|
+
const request = buildRequestFromArgv([
|
|
691
|
+
"games",
|
|
692
|
+
"review-history",
|
|
693
|
+
"--source",
|
|
694
|
+
"steam",
|
|
695
|
+
"7 30",
|
|
696
|
+
"--json"
|
|
697
|
+
]);
|
|
698
|
+
|
|
699
|
+
assert.equal(request.path, "/api/v1/steam/games/7%2030/review-history");
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test("games review-history rejects a missing external id", () => {
|
|
703
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
704
|
+
buildRequestFromArgv(["games", "review-history", "--source", "steam", "--json"])
|
|
705
|
+
);
|
|
706
|
+
|
|
707
|
+
assert.equal(exitCode, 2);
|
|
708
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
709
|
+
assert.match(output.error.message, /gmc games review-history --source steam <externalId> --json/);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test("games review-history rejects a non-steam source", () => {
|
|
713
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
714
|
+
buildRequestFromArgv(["games", "review-history", "--source", "epic", "730", "--json"])
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
assert.equal(exitCode, 2);
|
|
718
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
719
|
+
assert.match(output.error.message, /Only --source steam is supported/);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* The CLI reports usage errors by writing a JSON error to stdout and calling
|
|
724
|
+
* process.exit — there is no throwing variant to assert on. Replacing exit
|
|
725
|
+
* with a throw is what makes the bounded-error path testable in-process; the
|
|
726
|
+
* captured code and payload are exactly what a real shell would see.
|
|
727
|
+
*/
|
|
728
|
+
function captureUsageFailure(run) {
|
|
729
|
+
const originalWrite = process.stdout.write;
|
|
730
|
+
const originalExit = process.exit;
|
|
731
|
+
const chunks = [];
|
|
732
|
+
let exitCode;
|
|
733
|
+
process.stdout.write = (chunk) => {
|
|
734
|
+
chunks.push(String(chunk));
|
|
735
|
+
return true;
|
|
736
|
+
};
|
|
737
|
+
process.exit = (code) => {
|
|
738
|
+
exitCode = code;
|
|
739
|
+
throw new Error("__cli_exit__");
|
|
740
|
+
};
|
|
741
|
+
try {
|
|
742
|
+
run();
|
|
743
|
+
throw new Error("expected the CLI to exit with a usage error");
|
|
744
|
+
} catch (error) {
|
|
745
|
+
if (error.message !== "__cli_exit__") throw error;
|
|
746
|
+
} finally {
|
|
747
|
+
process.stdout.write = originalWrite;
|
|
748
|
+
process.exit = originalExit;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return { output: JSON.parse(chunks.join("")), exitCode };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
test("games aggregate forwards as_of and compare_as_of (NAK-248)", () => {
|
|
755
|
+
const request = buildRequestFromArgv([
|
|
756
|
+
"games",
|
|
757
|
+
"aggregate",
|
|
758
|
+
"--source",
|
|
759
|
+
"steam",
|
|
760
|
+
"--metric",
|
|
761
|
+
"count",
|
|
762
|
+
"--group-by",
|
|
763
|
+
"release_year",
|
|
764
|
+
"--as-of",
|
|
765
|
+
"2026-08-01",
|
|
766
|
+
"--compare-as-of",
|
|
767
|
+
"2026-06-01",
|
|
768
|
+
"--json"
|
|
769
|
+
]);
|
|
770
|
+
|
|
771
|
+
assert.equal(request.path, "/api/v1/steam/games/aggregate");
|
|
772
|
+
assert.equal(request.query.as_of, "2026-08-01");
|
|
773
|
+
assert.equal(request.query.compare_as_of, "2026-06-01");
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test("games aggregate omits the comparison params when they are not supplied", () => {
|
|
777
|
+
const request = buildRequestFromArgv([
|
|
778
|
+
"games",
|
|
779
|
+
"aggregate",
|
|
780
|
+
"--source",
|
|
781
|
+
"steam",
|
|
782
|
+
"--metric",
|
|
783
|
+
"count",
|
|
784
|
+
"--json"
|
|
785
|
+
]);
|
|
786
|
+
|
|
787
|
+
assert.equal("as_of" in request.query, false);
|
|
788
|
+
assert.equal("compare_as_of" in request.query, false);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
test("help documents the v2 query envelope and both new lists commands (NAK-525)", () => {
|
|
792
|
+
const chunks = [];
|
|
793
|
+
const original = process.stdout.write;
|
|
794
|
+
process.stdout.write = (chunk) => {
|
|
795
|
+
chunks.push(String(chunk));
|
|
796
|
+
return true;
|
|
797
|
+
};
|
|
798
|
+
try {
|
|
799
|
+
printHelp();
|
|
800
|
+
} finally {
|
|
801
|
+
process.stdout.write = original;
|
|
802
|
+
}
|
|
803
|
+
const help = chunks.join("");
|
|
804
|
+
|
|
805
|
+
assert.ok(help.includes("gmc lists resolve <id> --json"));
|
|
806
|
+
assert.ok(help.includes("gmc lists materialize <id> --confirm --json"));
|
|
807
|
+
assert.ok(help.includes("gmc lists games <id> --limit <number> --cursor <cursor> --json"));
|
|
808
|
+
// The envelope itself, not just its name: a caller composing --filter needs
|
|
809
|
+
// the shape and the rule that structured decides membership.
|
|
810
|
+
assert.ok(help.includes('"version":2,"mode":"query"'));
|
|
811
|
+
assert.ok(help.includes("structured decides membership"));
|
|
812
|
+
assert.ok(help.includes("total_kind"));
|
|
813
|
+
assert.ok(help.includes("RESOLUTION_REQUIRED"));
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* NAK-207. `--event-name` reaches games search/count/aggregate through the
|
|
818
|
+
* SAME shared query builder as every other filter, so an event cohort can be
|
|
819
|
+
* combined with tags and bounds in one command. Validating the slug is the
|
|
820
|
+
* server's job (INVALID_EVENT with suggestions); the CLI only refuses a blank
|
|
821
|
+
* value, which the query parser would otherwise drop, leaving the caller
|
|
822
|
+
* reading the whole catalog as an event cohort.
|
|
823
|
+
*/
|
|
824
|
+
test("(NAK-207) games search/count/aggregate forward --event-name as the eventName query param", () => {
|
|
825
|
+
const search = buildRequestFromArgv([
|
|
826
|
+
"games", "search", "--source", "steam", "--event-name", "tokyo_game_dungeon_2026", "--json"
|
|
827
|
+
]);
|
|
828
|
+
assert.equal(search.query.eventName, "tokyo_game_dungeon_2026");
|
|
829
|
+
|
|
830
|
+
const count = buildRequestFromArgv([
|
|
831
|
+
"games", "count", "--source", "steam", "--event-name", "nextfest_june_2026", "--json"
|
|
832
|
+
]);
|
|
833
|
+
assert.equal(count.query.eventName, "nextfest_june_2026");
|
|
834
|
+
|
|
835
|
+
const aggregate = buildRequestFromArgv([
|
|
836
|
+
"games", "aggregate", "--source", "steam", "--metric", "count",
|
|
837
|
+
"--event-name", "nextfest_june_2026", "--json"
|
|
838
|
+
]);
|
|
839
|
+
assert.equal(aggregate.query.eventName, "nextfest_june_2026");
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
test("(NAK-207) --event-name composes with the other filters instead of replacing them", () => {
|
|
843
|
+
const request = buildRequestFromArgv([
|
|
844
|
+
"games", "count", "--source", "steam",
|
|
845
|
+
"--event-name", "tokyo_game_dungeon_2026",
|
|
846
|
+
"--tags", "roguelite",
|
|
847
|
+
"--rating-min", "80",
|
|
848
|
+
"--json"
|
|
849
|
+
]);
|
|
850
|
+
assert.equal(request.query.eventName, "tokyo_game_dungeon_2026");
|
|
851
|
+
assert.equal(request.query.tags, "roguelite");
|
|
852
|
+
assert.equal(request.query.ratingMin, "80");
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
test("(NAK-207) a blank --event-name is a usage error, not a silently unfiltered request", () => {
|
|
856
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
857
|
+
buildRequestFromArgv(["games", "count", "--source", "steam", "--event-name", " ", "--json"])
|
|
858
|
+
);
|
|
859
|
+
assert.equal(exitCode, 2);
|
|
860
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
861
|
+
assert.match(output.error.message, /--event-name must be a non-empty Steam event slug/);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* NAK-587. `--catalog-as-of` reaches games search/count/aggregate through the
|
|
866
|
+
* SAME shared query builder as every other filter (it is a filter, not a
|
|
867
|
+
* snapshot dial like `--as-of`), and it is forwarded under the REST spelling
|
|
868
|
+
* `catalog_as_of` rather than a camelCase one — matching `as_of` /
|
|
869
|
+
* `compare_as_of`, the other date parameters.
|
|
870
|
+
*/
|
|
871
|
+
test("(NAK-587) games search/count/aggregate forward --catalog-as-of as the catalog_as_of query param", () => {
|
|
872
|
+
const search = buildRequestFromArgv([
|
|
873
|
+
"games", "search", "--source", "steam", "--catalog-as-of", "2026-08-01", "--json"
|
|
874
|
+
]);
|
|
875
|
+
assert.equal(search.query.catalog_as_of, "2026-08-01");
|
|
876
|
+
|
|
877
|
+
const count = buildRequestFromArgv([
|
|
878
|
+
"games", "count", "--source", "steam", "--catalog-as-of", "2026-08-01", "--json"
|
|
879
|
+
]);
|
|
880
|
+
assert.equal(count.query.catalog_as_of, "2026-08-01");
|
|
881
|
+
|
|
882
|
+
const aggregate = buildRequestFromArgv([
|
|
883
|
+
"games", "aggregate", "--source", "steam", "--metric", "count",
|
|
884
|
+
"--catalog-as-of", "2026-08-01", "--json"
|
|
885
|
+
]);
|
|
886
|
+
assert.equal(aggregate.query.catalog_as_of, "2026-08-01");
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
test("(NAK-587) --catalog-as-of composes with the other filters and with --as-of", () => {
|
|
890
|
+
const request = buildRequestFromArgv([
|
|
891
|
+
"games", "aggregate", "--source", "steam", "--metric", "count",
|
|
892
|
+
"--catalog-as-of", "2026-08-01",
|
|
893
|
+
"--as-of", "2026-08-15",
|
|
894
|
+
"--tags", "roguelite",
|
|
895
|
+
"--rating-min", "80",
|
|
896
|
+
"--json"
|
|
897
|
+
]);
|
|
898
|
+
// The two dates are different dials and must both survive: `as_of` chooses
|
|
899
|
+
// the metric snapshot, `catalog_as_of` chooses the population.
|
|
900
|
+
assert.equal(request.query.catalog_as_of, "2026-08-01");
|
|
901
|
+
assert.equal(request.query.as_of, "2026-08-15");
|
|
902
|
+
assert.equal(request.query.tags, "roguelite");
|
|
903
|
+
assert.equal(request.query.ratingMin, "80");
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
test("(NAK-587) a blank --catalog-as-of is a usage error, not a silently unfiltered request", () => {
|
|
907
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
908
|
+
buildRequestFromArgv(["games", "count", "--source", "steam", "--catalog-as-of", " ", "--json"])
|
|
909
|
+
);
|
|
910
|
+
assert.equal(exitCode, 2);
|
|
911
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
912
|
+
assert.match(output.error.message, /--catalog-as-of must be a date in YYYY-MM-DD format/);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
test("(NAK-587) an omitted --catalog-as-of leaves the query untouched", () => {
|
|
916
|
+
const request = buildRequestFromArgv(["games", "count", "--source", "steam", "--json"]);
|
|
917
|
+
assert.equal("catalog_as_of" in request.query, false);
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* NAK-773. `--ai-disclosure` reaches games search/count/aggregate through the
|
|
922
|
+
* SAME shared query builder as every other filter, and is forwarded verbatim
|
|
923
|
+
* (kebab-case flag to camelCase query param) as the three literal values the
|
|
924
|
+
* server matches case-sensitively (assertAiDisclosureFilterValue,
|
|
925
|
+
* src/server/gmc/repositories/ai-disclosure.ts) -- the CLI does not
|
|
926
|
+
* lower-case or trim a typo into a value the caller did not send.
|
|
927
|
+
*/
|
|
928
|
+
test("(NAK-773) games search/count/aggregate forward --ai-disclosure as the aiDisclosure query param", () => {
|
|
929
|
+
const search = buildRequestFromArgv([
|
|
930
|
+
"games", "search", "--source", "steam", "--ai-disclosure", "disclosed", "--json"
|
|
931
|
+
]);
|
|
932
|
+
assert.equal(search.query.aiDisclosure, "disclosed");
|
|
933
|
+
|
|
934
|
+
const count = buildRequestFromArgv([
|
|
935
|
+
"games", "count", "--source", "steam", "--ai-disclosure", "absent", "--json"
|
|
936
|
+
]);
|
|
937
|
+
assert.equal(count.query.aiDisclosure, "absent");
|
|
938
|
+
|
|
939
|
+
const aggregate = buildRequestFromArgv([
|
|
940
|
+
"games", "aggregate", "--source", "steam", "--metric", "count",
|
|
941
|
+
"--ai-disclosure", "unconfirmed", "--json"
|
|
942
|
+
]);
|
|
943
|
+
assert.equal(aggregate.query.aiDisclosure, "unconfirmed");
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
test("(NAK-773) --ai-disclosure accepts each of the three enum values", () => {
|
|
947
|
+
for (const value of ["disclosed", "absent", "unconfirmed"]) {
|
|
948
|
+
const request = buildRequestFromArgv(["games", "count", "--source", "steam", "--ai-disclosure", value, "--json"]);
|
|
949
|
+
assert.equal(request.query.aiDisclosure, value);
|
|
950
|
+
}
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
test("(NAK-773) --ai-disclosure composes with the other filters instead of replacing them", () => {
|
|
954
|
+
const request = buildRequestFromArgv([
|
|
955
|
+
"games", "count", "--source", "steam",
|
|
956
|
+
"--ai-disclosure", "disclosed",
|
|
957
|
+
"--tags", "roguelite",
|
|
958
|
+
"--rating-min", "80",
|
|
959
|
+
"--json"
|
|
960
|
+
]);
|
|
961
|
+
assert.equal(request.query.aiDisclosure, "disclosed");
|
|
962
|
+
assert.equal(request.query.tags, "roguelite");
|
|
963
|
+
assert.equal(request.query.ratingMin, "80");
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
test("(NAK-773) an invalid --ai-disclosure value is a usage error, not a silently unfiltered request", () => {
|
|
967
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
968
|
+
buildRequestFromArgv(["games", "count", "--source", "steam", "--ai-disclosure", "confirmed", "--json"])
|
|
969
|
+
);
|
|
970
|
+
assert.equal(exitCode, 2);
|
|
971
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
972
|
+
assert.match(output.error.message, /--ai-disclosure must be one of: disclosed, absent, unconfirmed/);
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
test("(NAK-773) --ai-disclosure is case-sensitive: a differently-cased value is rejected, not normalized", () => {
|
|
976
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
977
|
+
buildRequestFromArgv(["games", "count", "--source", "steam", "--ai-disclosure", "Disclosed", "--json"])
|
|
978
|
+
);
|
|
979
|
+
assert.equal(exitCode, 2);
|
|
980
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
981
|
+
assert.match(output.error.message, /--ai-disclosure must be one of: disclosed, absent, unconfirmed/);
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
test("(NAK-773) a blank --ai-disclosure is a usage error, not a silently unfiltered request", () => {
|
|
985
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
986
|
+
buildRequestFromArgv(["games", "count", "--source", "steam", "--ai-disclosure", " ", "--json"])
|
|
987
|
+
);
|
|
988
|
+
assert.equal(exitCode, 2);
|
|
989
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
990
|
+
assert.match(output.error.message, /--ai-disclosure must be one of: disclosed, absent, unconfirmed/);
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
test("(NAK-773) an omitted --ai-disclosure leaves the query untouched", () => {
|
|
994
|
+
const request = buildRequestFromArgv(["games", "count", "--source", "steam", "--json"]);
|
|
995
|
+
assert.equal("aiDisclosure" in request.query, false);
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* NAK-773. The aggregate `--group-by` cross-tab is a plain pass-through
|
|
1000
|
+
* string (buildGameAggregateQuery forwards it verbatim via pickQuery), so a
|
|
1001
|
+
* multi-dimension cross-tab like `release_month,ai_disclosure` needs no
|
|
1002
|
+
* special-casing here -- proving that is the point of this test, not
|
|
1003
|
+
* asserting new behavior.
|
|
1004
|
+
*/
|
|
1005
|
+
test("(NAK-773) --group-by release_month,ai_disclosure flows through the aggregate builder unchanged", () => {
|
|
1006
|
+
const request = buildRequestFromArgv([
|
|
1007
|
+
"games", "aggregate", "--source", "steam", "--metric", "count",
|
|
1008
|
+
"--group-by", "release_month,ai_disclosure",
|
|
1009
|
+
"--json"
|
|
1010
|
+
]);
|
|
1011
|
+
assert.equal(request.query.groupBy, "release_month,ai_disclosure");
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
test("(NAK-773) --ai-disclosure composes with the release_month,ai_disclosure cross-tab", () => {
|
|
1015
|
+
const request = buildRequestFromArgv([
|
|
1016
|
+
"games", "aggregate", "--source", "steam", "--metric", "count",
|
|
1017
|
+
"--group-by", "release_month,ai_disclosure",
|
|
1018
|
+
"--ai-disclosure", "unconfirmed",
|
|
1019
|
+
"--json"
|
|
1020
|
+
]);
|
|
1021
|
+
assert.equal(request.query.groupBy, "release_month,ai_disclosure");
|
|
1022
|
+
assert.equal(request.query.aiDisclosure, "unconfirmed");
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* NAK-706: `gmc steam tag-trends`.
|
|
1027
|
+
*
|
|
1028
|
+
* The CLI is a pass-through here on purpose — the per-view allowlist, the enum
|
|
1029
|
+
* members and the two limit ceilings live on the server, so these tests pin
|
|
1030
|
+
* the MAPPING (flag name to query parameter, and omission when unset) rather
|
|
1031
|
+
* than restating a contract the CLI does not own.
|
|
1032
|
+
*/
|
|
1033
|
+
test("(NAK-706) steam tag-trends defaults to the bare endpoint and lets the server pick the view", () => {
|
|
1034
|
+
const request = buildRequestFromArgv(["steam", "tag-trends", "--json"]);
|
|
1035
|
+
|
|
1036
|
+
assert.equal(request.path, "/api/v1/steam/tag-trends");
|
|
1037
|
+
// Empty, not {view: "featured"}: a default copied here could drift from the
|
|
1038
|
+
// server's.
|
|
1039
|
+
assert.deepEqual(request.query, {});
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
test("(NAK-706) steam tag-trends maps every flag to its query parameter", () => {
|
|
1043
|
+
const request = buildRequestFromArgv([
|
|
1044
|
+
"steam", "tag-trends",
|
|
1045
|
+
"--view", "table",
|
|
1046
|
+
"--family", "mechanic",
|
|
1047
|
+
"--q", "cozy",
|
|
1048
|
+
"--supply-direction", "up",
|
|
1049
|
+
"--sort", "current_share",
|
|
1050
|
+
"--order", "asc",
|
|
1051
|
+
"--limit", "50",
|
|
1052
|
+
"--cursor", "abc.def",
|
|
1053
|
+
"--json"
|
|
1054
|
+
]);
|
|
1055
|
+
|
|
1056
|
+
assert.equal(request.path, "/api/v1/steam/tag-trends");
|
|
1057
|
+
assert.deepEqual(request.query, {
|
|
1058
|
+
view: "table",
|
|
1059
|
+
family: "mechanic",
|
|
1060
|
+
q: "cozy",
|
|
1061
|
+
// The one renaming: the CLI's kebab-case flag reaches the endpoint's
|
|
1062
|
+
// snake_case parameter.
|
|
1063
|
+
supply_direction: "up",
|
|
1064
|
+
sort: "current_share",
|
|
1065
|
+
order: "asc",
|
|
1066
|
+
limit: "50",
|
|
1067
|
+
cursor: "abc.def"
|
|
1068
|
+
});
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
test("(NAK-706) steam tag-trends forwards --tag for the single-tag view", () => {
|
|
1072
|
+
const request = buildRequestFromArgv(["steam", "tag-trends", "--view", "tag", "--tag", "cozy", "--json"]);
|
|
1073
|
+
|
|
1074
|
+
assert.deepEqual(request.query, { view: "tag", tag: "cozy" });
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
test("(NAK-706) a flag the endpoint has no parameter for is a usage error, not a dropped narrowing", () => {
|
|
1078
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
1079
|
+
buildRequestFromArgv(["steam", "tag-trends", "--view", "board", "--source", "steam", "--json"])
|
|
1080
|
+
);
|
|
1081
|
+
|
|
1082
|
+
assert.equal(exitCode, 2);
|
|
1083
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
1084
|
+
assert.match(output.error.message, /--source is not supported by gmc steam tag-trends/);
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
test("(NAK-706) a stray positional is a usage error rather than a silently ignored argument", () => {
|
|
1088
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
1089
|
+
buildRequestFromArgv(["steam", "tag-trends", "cozy", "--json"])
|
|
1090
|
+
);
|
|
1091
|
+
|
|
1092
|
+
assert.equal(exitCode, 2);
|
|
1093
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
1094
|
+
assert.match(output.error.message, /Unexpected extra argument "cozy"/);
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
test('trend-stories preserves story, edition, locale and list pagination options', () => {
|
|
1098
|
+
assert.deepEqual(buildRequestFromArgv(['steam', 'trend-stories', '--story', 'story-id', '--edition', 'edition-id', '--locale', 'ja', '--json']), {
|
|
1099
|
+
path: '/api/v1/steam/trend-stories', query: { story: 'story-id', edition: 'edition-id', locale: 'ja' }
|
|
1100
|
+
});
|
|
1101
|
+
assert.deepEqual(buildRequestFromArgv(['steam', 'trend-stories', '--tag', 'cozy', '--limit', '1', '--offset', '1', '--fields', 'summary', '--json']).query, { tag: 'cozy', limit: '1', offset: '1', fields: 'summary' });
|
|
1102
|
+
assert.equal(captureUsageFailure(() => buildRequestFromArgv(['steam', 'trend-stories', '--family', 'mood'])).exitCode, 2);
|
|
1103
|
+
assert.equal(captureUsageFailure(() => buildRequestFromArgv(['steam', 'trend-stories', 'unexpected'])).exitCode, 2);
|
|
1104
|
+
});
|