skillrepo 4.5.1 → 4.6.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.
@@ -80,6 +80,31 @@ export function createMockServer(initialPayload, options = {}) {
80
80
  /** @type {{ status: number, body: any } | null} */
81
81
  let forcedError = null;
82
82
 
83
+ // Library-request inspection. Tests use these to distinguish a true
84
+ // 304 wire exchange (`If-None-Match` sent, server replied 304) from
85
+ // a request that produced "up to date" output via some other path.
86
+ // PR #1575's placement-presence fix made this distinction
87
+ // load-bearing — see the assertion at the call sites in
88
+ // update-list-contract.integration.test.mjs.
89
+ /** @type {string | null} */
90
+ let lastLibraryIfNoneMatch = null;
91
+ /** @type {string | null} */
92
+ let lastLibrarySince = null;
93
+ let libraryRequestCount = 0;
94
+ // #1832 — `?manifest=1` capture for the GET /api/v1/library route so
95
+ // tests can assert `skillrepo list` made the non-recording manifest read.
96
+ /** @type {string | null} */
97
+ let lastLibraryManifest = null;
98
+
99
+ // #1832 — POST /api/v1/library/sync-receipts inspection + response slot.
100
+ // `lastReceipt` holds the most recent JSON-parsed receipt body;
101
+ // `receiptResponse` overrides the default 200 so tests can simulate
102
+ // failures (the CLI treats a failed receipt as a non-fatal warning).
103
+ let lastReceipt = null;
104
+ let receiptRequestCount = 0;
105
+ /** @type {{ status: number, body: any } | null} */
106
+ let receiptResponse = null;
107
+
83
108
  // PR3a mutable slots for POST/DELETE library routes
84
109
  //
85
110
  // Each entry is `{ status: number, body: any }` describing the
@@ -262,6 +287,41 @@ export function createMockServer(initialPayload, options = {}) {
262
287
  return;
263
288
  }
264
289
 
290
+ // ── #1832: POST /api/v1/library/sync-receipts (confirmed-write) ─
291
+ //
292
+ // The CLI posts this after `runSync` writes skills to disk. Default
293
+ // 200 echoes a `recorded` count; tests can override via
294
+ // setReceiptResponse to exercise the non-fatal-warning path.
295
+ if (
296
+ url.pathname === "/api/v1/library/sync-receipts" &&
297
+ req.method === "POST"
298
+ ) {
299
+ if (!checkAuth(req, res)) return;
300
+ let body;
301
+ try {
302
+ body = rawBody.length > 0 ? JSON.parse(rawBody) : {};
303
+ } catch {
304
+ res.writeHead(400, { "Content-Type": "application/json" });
305
+ res.end(
306
+ JSON.stringify({ error: "Body must be JSON", code: "bad_request" }),
307
+ );
308
+ return;
309
+ }
310
+ lastReceipt = body;
311
+ receiptRequestCount++;
312
+ if (receiptResponse) {
313
+ res.writeHead(receiptResponse.status, {
314
+ "Content-Type": "application/json",
315
+ });
316
+ res.end(JSON.stringify(receiptResponse.body ?? {}));
317
+ return;
318
+ }
319
+ const recorded = Array.isArray(body?.skills) ? body.skills.length : 0;
320
+ res.writeHead(200, { "Content-Type": "application/json" });
321
+ res.end(JSON.stringify({ recorded }));
322
+ return;
323
+ }
324
+
265
325
  // ── #1452: POST /api/v1/library (multipart file-push) ──────────
266
326
  //
267
327
  // Accepts multipart/form-data and returns a synthesized
@@ -486,18 +546,58 @@ export function createMockServer(initialPayload, options = {}) {
486
546
  return;
487
547
  }
488
548
 
489
- // ETag short-circuit
549
+ // ETag short-circuit. Capture the inbound If-None-Match and
550
+ // `since` into inspectable slots BEFORE deciding 304-vs-200 so
551
+ // tests can assert on what the client actually sent — not just
552
+ // whether the response was 304. PR #1575's placement-presence
553
+ // fix makes the wire-level distinction load-bearing.
490
554
  const ifNoneMatch = req.headers["if-none-match"];
555
+ const sinceParam = url.searchParams.get("since");
556
+ lastLibraryIfNoneMatch = ifNoneMatch ?? null;
557
+ lastLibrarySince = sinceParam ?? null;
558
+ lastLibraryManifest = url.searchParams.get("manifest");
559
+ libraryRequestCount++;
491
560
  if (libraryEtag && ifNoneMatch === libraryEtag) {
492
561
  res.writeHead(304, { ETag: libraryEtag });
493
562
  res.end();
494
563
  return;
495
564
  }
496
565
 
566
+ // Mirror the production server's `since` filter — only return
567
+ // skills updated AFTER the `since` timestamp. Pre-PR #1575 the
568
+ // mock returned every skill regardless of `since`, which hid a
569
+ // real production bug: dropping the ETag without ALSO dropping
570
+ // `since` made the server respond with an empty delta, leaving
571
+ // newly-detected vendor placements empty forever. Mirroring the
572
+ // filter here means any future regression of that class fails
573
+ // at test time. See src/lib/queries/library.ts in the server
574
+ // for the production behavior (`gt(skills.updatedAt, since)`).
575
+ let respondedSkills = libraryResponse.skills ?? [];
576
+ if (sinceParam && Array.isArray(respondedSkills)) {
577
+ const sinceDate = new Date(sinceParam);
578
+ if (!Number.isNaN(sinceDate.getTime())) {
579
+ respondedSkills = respondedSkills.filter((s) => {
580
+ // updatedAt is a string ISO timestamp in the fixture skills.
581
+ // Missing/invalid → treat as "older than since" → exclude
582
+ // (matches the production semantic of "not modified in
583
+ // the window").
584
+ if (typeof s.updatedAt !== "string") return false;
585
+ const updated = new Date(s.updatedAt);
586
+ if (Number.isNaN(updated.getTime())) return false;
587
+ return updated.getTime() > sinceDate.getTime();
588
+ });
589
+ }
590
+ }
591
+
592
+ const filteredResponse = {
593
+ ...libraryResponse,
594
+ skills: respondedSkills,
595
+ };
596
+
497
597
  const headers = { "Content-Type": "application/json" };
498
598
  if (libraryEtag) headers.ETag = libraryEtag;
499
599
  res.writeHead(200, headers);
500
- res.end(JSON.stringify(libraryResponse));
600
+ res.end(JSON.stringify(filteredResponse));
501
601
  return;
502
602
  }
503
603
 
@@ -586,6 +686,89 @@ export function createMockServer(initialPayload, options = {}) {
586
686
  libraryEtag = etag;
587
687
  },
588
688
 
689
+ /**
690
+ * Inspect the inbound `If-None-Match` header from the LAST request
691
+ * to GET /api/v1/library. Returns the literal header string, or
692
+ * `null` if the request didn't send one (full-fetch path). Used
693
+ * by tests asserting that the placement-presence check correctly
694
+ * dropped or sent the ETag header. See PR #1575's coverage gap
695
+ * 2 — without this accessor, the "304 fires" tests could pass on
696
+ * an implementation that broke the wire-level optimization while
697
+ * preserving the user-facing "up to date" output.
698
+ */
699
+ getLastLibraryIfNoneMatch() {
700
+ return lastLibraryIfNoneMatch;
701
+ },
702
+
703
+ /** Count of GET /api/v1/library requests since server start or reset. */
704
+ getLibraryRequestCount() {
705
+ return libraryRequestCount;
706
+ },
707
+
708
+ /**
709
+ * Inspect the inbound `?since=` query param from the LAST request
710
+ * to GET /api/v1/library. Returns the literal string or `null` if
711
+ * not present. The placement-presence fix must drop BOTH the
712
+ * If-None-Match header AND the `since` query param when placements
713
+ * are incomplete — otherwise the server returns an empty delta
714
+ * and missing placements stay empty. Tests use this to assert the
715
+ * fix at the wire level.
716
+ */
717
+ getLastLibrarySince() {
718
+ return lastLibrarySince;
719
+ },
720
+
721
+ /** Reset the inspection slots — useful between phases of a multi-step test. */
722
+ resetLibraryInspection() {
723
+ lastLibraryIfNoneMatch = null;
724
+ lastLibrarySince = null;
725
+ lastLibraryManifest = null;
726
+ libraryRequestCount = 0;
727
+ },
728
+
729
+ /**
730
+ * Inspect the inbound `?manifest=` query param from the LAST request
731
+ * to GET /api/v1/library. `"1"` means the client made the
732
+ * non-recording manifest read (#1832, `skillrepo list`); `null`
733
+ * means a normal recording sync.
734
+ */
735
+ getLastLibraryManifest() {
736
+ return lastLibraryManifest;
737
+ },
738
+
739
+ // ── #1832: sync-receipt slots ─────────────────────────────────
740
+
741
+ /**
742
+ * Return the most recent POST /api/v1/library/sync-receipts body
743
+ * (JSON-parsed), or null if none received. Tests assert on
744
+ * `.skills` (the confirmed-write list) and `.syncedAt`.
745
+ */
746
+ getLastReceipt() {
747
+ return lastReceipt;
748
+ },
749
+
750
+ /** Count of sync-receipt POSTs since server start or reset. */
751
+ getReceiptRequestCount() {
752
+ return receiptRequestCount;
753
+ },
754
+
755
+ /**
756
+ * Override the sync-receipt response to simulate a failure (e.g.
757
+ * `{ status: 500, body: { error: "boom" } }`). The CLI treats a
758
+ * failed receipt as a non-fatal warning, so tests use this to
759
+ * assert the sync still succeeds. Pass null to restore the 200.
760
+ */
761
+ setReceiptResponse(override) {
762
+ receiptResponse = override;
763
+ },
764
+
765
+ /** Reset sync-receipt inspection slots. */
766
+ resetReceipts() {
767
+ lastReceipt = null;
768
+ receiptRequestCount = 0;
769
+ receiptResponse = null;
770
+ },
771
+
589
772
  /** Register a single-skill response keyed by `owner/name`. */
590
773
  setSkillResponse(owner, name, skill) {
591
774
  skillResponses.set(`${owner}/${name}`, skill);