@witchpot/gmc 0.9.0 → 0.11.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.
@@ -514,6 +514,111 @@ test("games ai-disclosure is listed as Provisional (NAK-804)", () => {
514
514
  assert.ok(PROVISIONAL_BETA_COMMANDS.includes("gmc games ai-disclosure <appid> --json"));
515
515
  });
516
516
 
517
+ // --- gmc games price-history / screenshot-labels (NAK-934) ----------------
518
+ //
519
+ // The ai-disclosure route shape: a plain numeric appid, no --source.
520
+
521
+ test("games price-history targets the steam route and leaves the window to the server by default", () => {
522
+ const request = buildRequestFromArgv(["games", "price-history", "730", "--json"]);
523
+
524
+ assert.equal(request.path, "/api/v1/steam/games/730/price-history");
525
+ assert.deepEqual(request.query, {});
526
+ });
527
+
528
+ test("games price-history forwards --range, or --from/--to, verbatim", () => {
529
+ assert.deepEqual(
530
+ buildRequestFromArgv(["games", "price-history", "730", "--range", "1y", "--json"]).query,
531
+ { range: "1y" }
532
+ );
533
+ assert.deepEqual(
534
+ buildRequestFromArgv([
535
+ "games", "price-history", "730", "--from", "2026-01-01", "--to", "2026-03-31", "--json"
536
+ ]).query,
537
+ { from: "2026-01-01", to: "2026-03-31" }
538
+ );
539
+ });
540
+
541
+ test("games price-history rejects a bad window before any request", () => {
542
+ for (const [argv, message] of [
543
+ [["--range", "90d"], /--range must be one of: 7d, 1m, 1y, all/],
544
+ [["--range", "1y", "--from", "2026-01-01"], /--range cannot be combined with --from or --to/],
545
+ [["--from", "2026/01/01"], /--from must use YYYY-MM-DD/],
546
+ [["--to", "yesterday"], /--to must use YYYY-MM-DD/],
547
+ // Real calendar dates only, as on the server (NAK-934 review).
548
+ [["--from", "2026-02-30"], /--from must be a real calendar date/],
549
+ [["--to", "2025-02-29"], /--to must be a real calendar date/],
550
+ [["--from", "2026-13-01"], /--from must be a real calendar date/],
551
+ [["--to", "2026-04-00"], /--to must be a real calendar date/],
552
+ // An explicit pair must be ordered.
553
+ [["--from", "2026-05-01", "--to", "2026-04-01"], /--from must be on or before --to/],
554
+ [["--country", "jp"], /--country is not supported by gmc games price-history/],
555
+ [["--source", "steam"], /--source is not supported by gmc games price-history/]
556
+ ]) {
557
+ const { output, exitCode } = captureUsageFailure(() =>
558
+ buildRequestFromArgv(["games", "price-history", "730", ...argv, "--json"])
559
+ );
560
+ assert.equal(exitCode, 2, argv.join(" "));
561
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
562
+ assert.match(output.error.message, message);
563
+ }
564
+ });
565
+
566
+ test("games price-history accepts a leap day, a single-day window and a lone --from", () => {
567
+ assert.deepEqual(
568
+ buildRequestFromArgv(["games", "price-history", "730", "--from", "2024-02-29", "--json"]).query,
569
+ { from: "2024-02-29" }
570
+ );
571
+ assert.deepEqual(
572
+ buildRequestFromArgv([
573
+ "games", "price-history", "730", "--from", "2026-04-01", "--to", "2026-04-01", "--json"
574
+ ]).query,
575
+ { from: "2026-04-01", to: "2026-04-01" }
576
+ );
577
+ });
578
+
579
+ test("games price-history rejects a malformed appid and an extra positional", () => {
580
+ for (const argv of [["0x2da"], ["730", "440"], []]) {
581
+ const { output, exitCode } = captureUsageFailure(() =>
582
+ buildRequestFromArgv(["games", "price-history", ...argv, "--json"])
583
+ );
584
+ assert.equal(exitCode, 2, argv.join(" "));
585
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
586
+ }
587
+ });
588
+
589
+ test("games screenshot-labels targets the steam route and takes no options", () => {
590
+ const request = buildRequestFromArgv(["games", "screenshot-labels", "730", "--fields", "labels", "--json"]);
591
+ assert.equal(request.path, "/api/v1/steam/games/730/screenshot-labels");
592
+ assert.deepEqual(request.query, {});
593
+
594
+ const { output, exitCode } = captureUsageFailure(() =>
595
+ buildRequestFromArgv(["games", "screenshot-labels", "730", "--limit", "5", "--json"])
596
+ );
597
+ assert.equal(exitCode, 2);
598
+ assert.match(output.error.message, /--limit is not supported by gmc games screenshot-labels/);
599
+ });
600
+
601
+ test("help documents the NAK-934 commands and lists them as Provisional", () => {
602
+ const chunks = [];
603
+ const original = process.stdout.write;
604
+ process.stdout.write = (chunk) => {
605
+ chunks.push(String(chunk));
606
+ return true;
607
+ };
608
+ try {
609
+ printHelp();
610
+ } finally {
611
+ process.stdout.write = original;
612
+ }
613
+ const help = chunks.join("");
614
+
615
+ assert.ok(help.includes("gmc games price-history <appid> [--range <preset>]"));
616
+ assert.ok(help.includes("gmc games screenshot-labels <appid> --json"));
617
+ assert.ok(help.includes("MODEL OUTPUT"));
618
+ assert.ok(PROVISIONAL_BETA_COMMANDS.includes("gmc games price-history <appid> --range 7d|1m|1y|all --json"));
619
+ assert.ok(PROVISIONAL_BETA_COMMANDS.includes("gmc games screenshot-labels <appid> --json"));
620
+ });
621
+
517
622
  // --- gmc games language-topics (NAK-455) ---------------------------------
518
623
 
519
624
  test("games language-topics targets the canonical route and sends no invented parameters", () => {
@@ -1297,3 +1402,443 @@ test('trend-stories preserves story, edition, locale and list pagination options
1297
1402
  assert.equal(captureUsageFailure(() => buildRequestFromArgv(['steam', 'trend-stories', '--family', 'mood'])).exitCode, 2);
1298
1403
  assert.equal(captureUsageFailure(() => buildRequestFromArgv(['steam', 'trend-stories', 'unexpected'])).exitCode, 2);
1299
1404
  });
1405
+
1406
+ /**
1407
+ * NAK-715: `gmc roblox ...`.
1408
+ *
1409
+ * The CLI is a pass-through here on the same rule the tag-trends block above
1410
+ * documents — the per-endpoint allowlists, enum members, limit ceilings and
1411
+ * window bounds live on the server — so these tests pin the MAPPING (flag name
1412
+ * to query parameter, omission when unset, path construction) and the one
1413
+ * thing the CLI does own: a universe id is a STRING and is not a Steam appid.
1414
+ */
1415
+ test("(NAK-715) roblox charts defaults to the bare endpoint and lets the server pick the cohort", () => {
1416
+ const request = buildRequestFromArgv(["roblox", "charts", "--json"]);
1417
+
1418
+ assert.equal(request.path, "/api/v1/roblox/charts");
1419
+ // Empty, not {country: "all"}: a default copied here could drift from the
1420
+ // server's.
1421
+ assert.deepEqual(request.query, {});
1422
+ });
1423
+
1424
+ test("(NAK-715) roblox charts maps every flag to its query parameter", () => {
1425
+ const request = buildRequestFromArgv([
1426
+ "roblox", "charts",
1427
+ "--country", "JP",
1428
+ "--device", "computer",
1429
+ "--sort", "top-revisited",
1430
+ "--as-of", "2026-09-14T00:00:00.000Z",
1431
+ "--limit", "50",
1432
+ "--offset", "10",
1433
+ "--json"
1434
+ ]);
1435
+
1436
+ assert.deepEqual(request.query, {
1437
+ country: "JP",
1438
+ device: "computer",
1439
+ // Still forwarded although the source stopped publishing this sort: a
1440
+ // historical cutoff has real data, and the server reports the gap.
1441
+ sort: "top-revisited",
1442
+ // The kebab-case flag reaches the endpoint's snake_case parameter.
1443
+ as_of: "2026-09-14T00:00:00.000Z",
1444
+ limit: "50",
1445
+ offset: "10"
1446
+ });
1447
+ });
1448
+
1449
+ test("(NAK-715) roblox experiences maps the browse filters and the cursor", () => {
1450
+ const request = buildRequestFromArgv([
1451
+ "roblox", "experiences",
1452
+ "--country", "US",
1453
+ "--eligibility-days", "30",
1454
+ "--min-player-count", "1000",
1455
+ "--max-rank", "50",
1456
+ "--genre", "Adventure",
1457
+ "--creator-type", "Group",
1458
+ "--detail", "chart_only",
1459
+ "--order-by", "player_count",
1460
+ "--order", "desc",
1461
+ "--limit", "25",
1462
+ "--cursor", "abc.def",
1463
+ "--json"
1464
+ ]);
1465
+
1466
+ assert.equal(request.path, "/api/v1/roblox/experiences");
1467
+ assert.deepEqual(request.query, {
1468
+ country: "US",
1469
+ eligibility_days: "30",
1470
+ min_player_count: "1000",
1471
+ max_rank: "50",
1472
+ genre: "Adventure",
1473
+ creator_type: "Group",
1474
+ detail: "chart_only",
1475
+ order_by: "player_count",
1476
+ order: "desc",
1477
+ limit: "25",
1478
+ cursor: "abc.def"
1479
+ });
1480
+ });
1481
+
1482
+ /**
1483
+ * The id is carried as the decimal STRING the user typed. Parsing it into a
1484
+ * number would round anything above 2^53 into a different experience's id.
1485
+ */
1486
+ test("(NAK-715) a universe id travels as a decimal string, never as a number", () => {
1487
+ const request = buildRequestFromArgv(["roblox", "experience", "9007199254740993", "--json"]);
1488
+
1489
+ assert.equal(request.path, "/api/v1/roblox/experiences/9007199254740993");
1490
+ assert.deepEqual(request.query, {});
1491
+ });
1492
+
1493
+ test("(NAK-715) roblox history builds the history path and maps the window flags", () => {
1494
+ const request = buildRequestFromArgv([
1495
+ "roblox", "history", "4924922222",
1496
+ "--granularity", "hourly",
1497
+ "--from", "2026-09-13T00:00:00.000Z",
1498
+ "--to", "2026-09-20T00:00:00.000Z",
1499
+ "--json"
1500
+ ]);
1501
+
1502
+ assert.equal(request.path, "/api/v1/roblox/experiences/4924922222/history");
1503
+ assert.deepEqual(request.query, {
1504
+ granularity: "hourly",
1505
+ from: "2026-09-13T00:00:00.000Z",
1506
+ to: "2026-09-20T00:00:00.000Z"
1507
+ });
1508
+ });
1509
+
1510
+ test("(NAK-715) roblox coverage takes only the cutoff", () => {
1511
+ assert.deepEqual(buildRequestFromArgv(["roblox", "coverage", "--json"]), {
1512
+ path: "/api/v1/roblox/coverage",
1513
+ query: {}
1514
+ });
1515
+
1516
+ const { exitCode } = captureUsageFailure(() =>
1517
+ buildRequestFromArgv(["roblox", "coverage", "--country", "JP", "--json"])
1518
+ );
1519
+ assert.equal(exitCode, 2);
1520
+ });
1521
+
1522
+ /**
1523
+ * A Steam appid spelling the app's validator rejects must not reach the wire
1524
+ * looking fine, and it must never be mistaken for a universe id.
1525
+ */
1526
+ test("(NAK-715) a malformed universe id is a usage error naming the Steam-appid confusion", () => {
1527
+ for (const bad of ["730.0", "0x2da", "7e2", "-1", "0", "007", "abc"]) {
1528
+ const { output, exitCode } = captureUsageFailure(() =>
1529
+ buildRequestFromArgv(["roblox", "experience", bad, "--json"])
1530
+ );
1531
+ assert.equal(exitCode, 2, `expected ${bad} to be refused`);
1532
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1533
+ assert.match(output.error.message, /not a Steam appid/);
1534
+ }
1535
+
1536
+ const missing = captureUsageFailure(() => buildRequestFromArgv(["roblox", "history", "--json"]));
1537
+ assert.equal(missing.exitCode, 2);
1538
+ });
1539
+
1540
+ test("(NAK-715) a flag the endpoint has no parameter for is a usage error, not a dropped narrowing", () => {
1541
+ const { output, exitCode } = captureUsageFailure(() =>
1542
+ buildRequestFromArgv(["roblox", "charts", "--source", "steam", "--json"])
1543
+ );
1544
+
1545
+ assert.equal(exitCode, 2);
1546
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1547
+ assert.match(output.error.message, /--source is not supported by gmc roblox charts/);
1548
+ });
1549
+
1550
+ test("(NAK-715) a stray positional is a usage error rather than a silently ignored argument", () => {
1551
+ const { exitCode } = captureUsageFailure(() =>
1552
+ buildRequestFromArgv(["roblox", "charts", "cozy", "--json"])
1553
+ );
1554
+ assert.equal(exitCode, 2);
1555
+ });
1556
+
1557
+ /**
1558
+ * Mirrors the tag-trends precedent: removing these from
1559
+ * PROVISIONAL_BETA_COMMANDS would leave every test above green while the
1560
+ * commands silently vanished from `--help`.
1561
+ */
1562
+ test("(NAK-715) the roblox commands are published as provisional in --help", () => {
1563
+ for (const command of [
1564
+ "gmc roblox charts --country all --sort top-playing-now --json",
1565
+ "gmc roblox experiences --country all --limit 50 --json",
1566
+ "gmc roblox experience <universeId> --json",
1567
+ "gmc roblox history <universeId> --granularity daily|hourly --json",
1568
+ "gmc roblox coverage --json"
1569
+ ]) {
1570
+ assert.ok(
1571
+ PROVISIONAL_BETA_COMMANDS.includes(command),
1572
+ `${command} must stay listed as provisional`
1573
+ );
1574
+ }
1575
+ });
1576
+
1577
+ /**
1578
+ * NAK-723: `gmc roblox saved | save | remove`.
1579
+ *
1580
+ * The saved surface is workspace-scoped and MUTATING, so these tests pin what
1581
+ * the read tests above do not have to: which flags belong to the list and not
1582
+ * to the writes, that the two writes carry the right HTTP method and path,
1583
+ * and that the id reaches the BODY as a decimal string rather than a number.
1584
+ */
1585
+ test("(NAK-723) roblox saved reads the bare endpoint and lets the server pick the scope", () => {
1586
+ assert.deepEqual(buildRequestFromArgv(["roblox", "saved", "--json"]), {
1587
+ path: "/api/v1/roblox/saved",
1588
+ // Empty, not {country: "all"}: a default copied here could drift from the
1589
+ // server's, exactly as on the read family.
1590
+ query: {}
1591
+ });
1592
+ });
1593
+
1594
+ test("(NAK-723) roblox saved maps every scope flag to its query parameter", () => {
1595
+ const request = buildRequestFromArgv([
1596
+ "roblox", "saved",
1597
+ "--country", "JP",
1598
+ "--device", "computer",
1599
+ "--sort", "top-trending",
1600
+ "--as-of", "2026-09-20T00:00:00.000Z",
1601
+ "--eligibility-days", "30",
1602
+ "--json"
1603
+ ]);
1604
+
1605
+ assert.equal(request.path, "/api/v1/roblox/saved");
1606
+ assert.equal(request.method, undefined);
1607
+ assert.deepEqual(request.query, {
1608
+ country: "JP",
1609
+ device: "computer",
1610
+ sort: "top-trending",
1611
+ as_of: "2026-09-20T00:00:00.000Z",
1612
+ eligibility_days: "30"
1613
+ });
1614
+ });
1615
+
1616
+ /**
1617
+ * The saved set is capped per workspace and one response always holds all of
1618
+ * it. Accepting a pager would imply the list can hide part of itself, and the
1619
+ * REST allowlist rejects both.
1620
+ */
1621
+ test("(NAK-723) roblox saved is not paginated and refuses a pager rather than dropping it", () => {
1622
+ for (const flag of ["--limit", "--cursor", "--offset"]) {
1623
+ const { output, exitCode } = captureUsageFailure(() =>
1624
+ buildRequestFromArgv(["roblox", "saved", flag, "10", "--json"])
1625
+ );
1626
+ assert.equal(exitCode, 2, `expected ${flag} to be refused`);
1627
+ assert.match(output.error.message, /is not supported by gmc roblox saved/);
1628
+ }
1629
+ });
1630
+
1631
+ test("(NAK-723) roblox save posts the id as a decimal string body", () => {
1632
+ const request = buildRequestFromArgv(["roblox", "save", "4924922222", "--json"]);
1633
+
1634
+ assert.equal(request.path, "/api/v1/roblox/saved");
1635
+ assert.equal(request.method, "POST");
1636
+ assert.deepEqual(request.query, {});
1637
+ assert.deepEqual(request.body, { universeId: "4924922222" });
1638
+ // The server refuses a JSON number outright, because a number is what a
1639
+ // caller sends when it has routed a Steam appid into this path.
1640
+ assert.equal(typeof request.body.universeId, "string");
1641
+ });
1642
+
1643
+ /**
1644
+ * Above 2^53 a number would round into a DIFFERENT experience's id, and the
1645
+ * body is where that would silently succeed.
1646
+ */
1647
+ test("(NAK-723) a large universe id keeps every digit through the save body", () => {
1648
+ const request = buildRequestFromArgv(["roblox", "save", "9007199254740993", "--json"]);
1649
+ assert.deepEqual(request.body, { universeId: "9007199254740993" });
1650
+ assert.notEqual(String(Number("9007199254740993")), request.body.universeId);
1651
+ });
1652
+
1653
+ test("(NAK-723) roblox remove deletes the per-id path", () => {
1654
+ const request = buildRequestFromArgv(["roblox", "remove", "4924922222", "--json"]);
1655
+
1656
+ assert.equal(request.path, "/api/v1/roblox/saved/4924922222");
1657
+ assert.equal(request.method, "DELETE");
1658
+ assert.deepEqual(request.query, {});
1659
+ assert.equal(request.body, undefined);
1660
+ });
1661
+
1662
+ /**
1663
+ * The scope flags belong to `saved` alone. A save that quietly ignored
1664
+ * --country would look correct while answering a question nobody asked, and
1665
+ * the identical REST request is a 400.
1666
+ */
1667
+ test("(NAK-723) the writes take no flags, so a scope flag is a usage error", () => {
1668
+ for (const subcommand of ["save", "remove"]) {
1669
+ for (const [flag, value] of [["--country", "JP"], ["--as-of", "2026-09-20T00:00:00.000Z"]]) {
1670
+ const { output, exitCode } = captureUsageFailure(() =>
1671
+ buildRequestFromArgv(["roblox", subcommand, "4924922222", flag, value, "--json"])
1672
+ );
1673
+ assert.equal(exitCode, 2, `expected ${subcommand} ${flag} to be refused`);
1674
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1675
+ assert.match(output.error.message, new RegExp(`is not supported by gmc roblox ${subcommand}`));
1676
+ // A command with an empty allowlist says so, rather than rendering
1677
+ // "Supported options: ." and reading like a truncated message.
1678
+ assert.match(output.error.message, /This command takes no options\./);
1679
+ }
1680
+ }
1681
+ });
1682
+
1683
+ /**
1684
+ * The same lexical contract the read commands enforce, applied before any
1685
+ * request is built: a spelling the server's `parseRobloxId` rejects must not
1686
+ * reach the wire looking fine, and the message must name the Steam-appid
1687
+ * confusion rather than only "invalid id".
1688
+ */
1689
+ test("(NAK-723) a malformed universe id fails fast on both writes", () => {
1690
+ for (const subcommand of ["save", "remove"]) {
1691
+ for (const bad of ["730.0", "0x2da", "7e2", "-1", "0", "007", "abc"]) {
1692
+ const { output, exitCode } = captureUsageFailure(() =>
1693
+ buildRequestFromArgv(["roblox", subcommand, bad, "--json"])
1694
+ );
1695
+ assert.equal(exitCode, 2, `expected ${subcommand} ${bad} to be refused`);
1696
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1697
+ assert.match(output.error.message, /not a Steam appid/);
1698
+ assert.match(output.error.message, new RegExp(`gmc roblox ${subcommand} <universeId>`));
1699
+ }
1700
+
1701
+ const missing = captureUsageFailure(() => buildRequestFromArgv(["roblox", subcommand, "--json"]));
1702
+ assert.equal(missing.exitCode, 2, `expected a missing id on ${subcommand} to be refused`);
1703
+ }
1704
+ });
1705
+
1706
+ /**
1707
+ * A bare Steam appid is a LEXICALLY valid universe id on both sides, so it is
1708
+ * forwarded and the server's tracked-cohort gate answers NOT_FOUND naming that
1709
+ * fact. Guessing from the id's magnitude here would invent a rule the server
1710
+ * does not have and would reject real small universe ids.
1711
+ */
1712
+ test("(NAK-723) a lexically valid small id is forwarded for the server's cohort gate to judge", () => {
1713
+ assert.deepEqual(buildRequestFromArgv(["roblox", "save", "730", "--json"]).body, {
1714
+ universeId: "730"
1715
+ });
1716
+ });
1717
+
1718
+ test("(NAK-723) a stray positional on a write is a usage error, not a second id", () => {
1719
+ for (const subcommand of ["save", "remove"]) {
1720
+ const { exitCode } = captureUsageFailure(() =>
1721
+ buildRequestFromArgv(["roblox", subcommand, "4924922222", "4924922223", "--json"])
1722
+ );
1723
+ assert.equal(exitCode, 2, `expected a second id on ${subcommand} to be refused`);
1724
+ }
1725
+ });
1726
+
1727
+ /**
1728
+ * A token missing from the args.js whitelist falls through to positional, so
1729
+ * `gmc roblox save <id>` would parse as the unknown command `roblox` — the same
1730
+ * trap `lists materialize` documents, and worse here because both are writes.
1731
+ */
1732
+ test("(NAK-723) saved, save and remove are real command tokens, not positionals", () => {
1733
+ for (const subcommand of ["saved", "save", "remove"]) {
1734
+ const { command, positional } = parseArgs(["roblox", subcommand, "4924922222", "--json"]);
1735
+ assert.deepEqual(command, ["roblox", subcommand]);
1736
+ // The id is the command's own positional, so the branch above receives it
1737
+ // rather than the subcommand name landing here as a stray argument.
1738
+ assert.deepEqual(positional, ["4924922222"]);
1739
+ }
1740
+
1741
+ // And `saved` takes none, so the same token there is an extra argument
1742
+ // rather than a silently ignored one.
1743
+ assert.equal(
1744
+ captureUsageFailure(() => buildRequestFromArgv(["roblox", "saved", "4924922222", "--json"])).exitCode,
1745
+ 2
1746
+ );
1747
+ });
1748
+
1749
+ test("(NAK-723) the saved commands are published as provisional in --help", () => {
1750
+ for (const command of [
1751
+ "gmc roblox saved --country all --json",
1752
+ "gmc roblox save <universeId> --json",
1753
+ "gmc roblox remove <universeId> --json"
1754
+ ]) {
1755
+ assert.ok(
1756
+ PROVISIONAL_BETA_COMMANDS.includes(command),
1757
+ `${command} must stay listed as provisional`
1758
+ );
1759
+ }
1760
+
1761
+ const chunks = [];
1762
+ const original = process.stdout.write;
1763
+ process.stdout.write = (chunk) => {
1764
+ chunks.push(String(chunk));
1765
+ return true;
1766
+ };
1767
+ try {
1768
+ printHelp();
1769
+ } finally {
1770
+ process.stdout.write = original;
1771
+ }
1772
+ const help = chunks.join("");
1773
+
1774
+ assert.ok(help.includes("gmc roblox saved --country all --json"));
1775
+ assert.ok(help.includes("gmc roblox save <universeId> --json"));
1776
+ assert.ok(help.includes("gmc roblox remove <universeId> --json"));
1777
+ // The two facts an agent most needs from the help text, and the two most
1778
+ // expensive to get wrong: a repeat write is success, and an out-of-scope row
1779
+ // is still saved.
1780
+ assert.match(help, /changed: false is success/);
1781
+ assert.match(help, /out_of_scope is saved and\s+tracked/);
1782
+ });
1783
+
1784
+ // --- gmc games follower-history / events (NAK-934) -------------------------
1785
+
1786
+ test("games follower-history targets the steam route and leaves the window to the server by default", () => {
1787
+ const request = buildRequestFromArgv(["games", "follower-history", "2436940", "--json"]);
1788
+ assert.equal(request.path, "/api/v1/steam/games/2436940/follower-history");
1789
+ assert.deepEqual(request.query, {});
1790
+ });
1791
+
1792
+ test("games follower-history passes a preset or an explicit window", () => {
1793
+ assert.deepEqual(buildRequestFromArgv(["games", "follower-history", "730", "--range", "1y", "--json"]).query, { range: "1y" });
1794
+ assert.deepEqual(
1795
+ buildRequestFromArgv(["games", "follower-history", "730", "--from", "2026-01-01", "--to", "2026-06-30", "--json"]).query,
1796
+ { from: "2026-01-01", to: "2026-06-30" }
1797
+ );
1798
+ });
1799
+
1800
+ test("games follower-history rejects a bad window before any request", () => {
1801
+ for (const [argv, message] of [
1802
+ [["--range", "1y", "--from", "2026-01-01"], /--range cannot be combined with --from or --to/],
1803
+ [["--range", "90d"], /--range must be one of: 7d, 1m, 1y, all/],
1804
+ [["--from", "2026/01/01"], /--from must use YYYY-MM-DD/],
1805
+ [["--from", "2026-02-30"], /--from must be a real calendar date/],
1806
+ [["--from", "2026-05-01", "--to", "2026-04-01"], /--from must be on or before --to/],
1807
+ [["--country", "jp"], /--country is not supported by gmc games follower-history/]
1808
+ ]) {
1809
+ const { output, exitCode } = captureUsageFailure(() =>
1810
+ buildRequestFromArgv(["games", "follower-history", "730", ...argv, "--json"])
1811
+ );
1812
+ assert.equal(exitCode, 2, argv.join(" "));
1813
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1814
+ assert.match(output.error.message, message);
1815
+ }
1816
+ });
1817
+
1818
+ test("games events targets the steam route, with rank_series opt-in", () => {
1819
+ assert.equal(buildRequestFromArgv(["games", "events", "3364220", "--json"]).path, "/api/v1/steam/games/3364220/events");
1820
+ assert.deepEqual(buildRequestFromArgv(["games", "events", "3364220", "--json"]).query, {});
1821
+ assert.deepEqual(buildRequestFromArgv(["games", "events", "3364220", "--include", "rank_series", "--json"]).query, { include: "rank_series" });
1822
+ });
1823
+
1824
+ test("games events rejects an unknown include value and unsupported flags", () => {
1825
+ for (const [argv, message] of [
1826
+ [["--include", "ranks"], /--include must be one of: rank_series/],
1827
+ [["--include", "bad", "--include", "rank_series"], /--include may be given once; its only value is rank_series/],
1828
+ [["--include", "rank_series", "--include=rank_series"], /--include may be given once/],
1829
+ [["--range", "1y"], /--range is not supported by gmc games events/]
1830
+ ]) {
1831
+ const { output, exitCode } = captureUsageFailure(() =>
1832
+ buildRequestFromArgv(["games", "events", "730", ...argv, "--json"])
1833
+ );
1834
+ assert.equal(exitCode, 2, argv.join(" "));
1835
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1836
+ assert.match(output.error.message, message);
1837
+ }
1838
+ });
1839
+
1840
+ test("the new commands are listed and documented", async () => {
1841
+ const { PROVISIONAL_BETA_COMMANDS } = await import("./constants.js");
1842
+ assert.ok(PROVISIONAL_BETA_COMMANDS.some((command) => command.startsWith("gmc games follower-history ")));
1843
+ assert.ok(PROVISIONAL_BETA_COMMANDS.some((command) => command.startsWith("gmc games events ")));
1844
+ });