@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.
@@ -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
+ });