Bambuddy sync: match the real spools API

Checked against Greg's Bambuddy: the key goes in X-API-Key, colors are
color_name (often null on RFID spools) and rgba, grams left is
label_weight minus weight_used, and archived spools carry archived_at.
Unnamed spools get the nearest plain color name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-12 02:47:48 +00:00
parent bd97720be1
commit 449ffcd62d
6 changed files with 84 additions and 71 deletions
+12 -12
View File
@@ -148,7 +148,7 @@ being down never takes a page down. Code: [`services/content/src/sync/`](../serv
| `covers` | 15 min | Library items with no `cover`: Open Library (books), TMDB (film, TV; needs `TMDB_API_KEY`), AniList (anime, manga, manhwa, light novels, webnovels; by `anilistId` when set) | `cover` | | `covers` | 15 min | Library items with no `cover`: Open Library (books), TMDB (film, TV; needs `TMDB_API_KEY`), AniList (anime, manga, manhwa, light novels, webnovels; by `anilistId` when set) | `cover` |
| `suwayomi` | hour | PersonalReader's manga and manhwa: Suwayomi's GraphQL library (category, furthest chapter read and when, AniList tracker id), matched to Library items by `anilistId`; AniList for covers | `chapterProgress` (`title`, `status`, `chapter`, `readAt`), `cover` (AniList); changes `state` (below) | | `suwayomi` | hour | PersonalReader's manga and manhwa: Suwayomi's GraphQL library (category, furthest chapter read and when, AniList tracker id), matched to Library items by `anilistId`; AniList for covers | `chapterProgress` (`title`, `status`, `chapter`, `readAt`), `cover` (AniList); changes `state` (below) |
| `kavita` | day | PersonalReader's light novels and books: Kavita's API, matched by `kavitaSeriesId`, or by `anilistId` against an anilist.co link in the series' metadata | `bookProgress` (`title`, `percent`, `readAt`); changes `state` (below) | | `kavita` | day | PersonalReader's light novels and books: Kavita's API, matched by `kavitaSeriesId`, or by `anilistId` against an anilist.co link in the series' metadata | `bookProgress` (`title`, `percent`, `readAt`); changes `state` (below) |
| `bambuddy` | hour | Filament spools in stock (**unverified API**, below) | `filamentStock` on product lines with a Filament option | | `bambuddy` | hour | Filament spools in stock | `filamentStock` on product lines with a Filament option |
| `homebox` | hour | Homebox items with the labels or locations in `HOMEBOX_LABELS` / `HOMEBOX_LOCATIONS` | `materialStock` on product lines with a Leather or Paper option | | `homebox` | hour | Homebox items with the labels or locations in `HOMEBOX_LABELS` / `HOMEBOX_LOCATIONS` | `materialStock` on product lines with a Leather or Paper option |
The covers job looks every 15 minutes, so a new Library item gets a cover The covers job looks every 15 minutes, so a new Library item gets a cover
@@ -227,15 +227,15 @@ stock when the source does not have it; in-stock colors the note does not
list are added after the note's own. With nothing synced, options show list are added after the note's own. With nothing synced, options show
exactly as the note says (a missing `inStock` counts as in stock). exactly as the note says (a missing `inStock` counts as in stock).
- **Bambuddy: unverified.** Its API is not documented anywhere this project - **Bambuddy** reads `GET /api/v1/inventory/spools` (`BAMBUDDY_SPOOLS_PATH`)
has, and no request has been made to one. The endpoint is with an API key made in Bambuddy's Settings, sent in the `X-API-Key` header.
`BAMBUDDY_SPOOLS_PATH` (default `/api/v1/inventory/spools`, a guess), and all Field names were checked against Greg's Bambuddy (2026-10-12) and are all in
knowledge of the response is in `mapBambuddySpools` in `mapBambuddySpools` in `sync/jobs/bambuddy.ts`. Grams left is `label_weight`
`sync/jobs/bambuddy.ts`: it accepts a list (or one under `items`, `spools`, minus `weight_used`; archived spools are skipped. A spool with no
`data`, `results`) and reads color name, hex, material and grams left under `color_name` (RFID spools) is named by its nearest plain color (Black,
common spellings. Check it against Greg's Bambuddy and fix that one function Blue...); setting a color name in Bambuddy overrides that. A color is in
before relying on it. A color is in stock when some spool of it has at least stock when some spool of it has at least `BAMBUDDY_MIN_GRAMS` (default 50)
`BAMBUDDY_MIN_GRAMS` (default 50) left, or does not say. left.
- **Homebox** logs in at `POST /api/v1/users/login`, looks up the label and - **Homebox** logs in at `POST /api/v1/users/login`, looks up the label and
location ids, and lists `GET /api/v1/items?labels=<id>` (or location ids, and lists `GET /api/v1/items?labels=<id>` (or
`locations=<id>`). Each `HOMEBOX_LABELS` / `HOMEBOX_LOCATIONS` entry is `locations=<id>`). Each `HOMEBOX_LABELS` / `HOMEBOX_LOCATIONS` entry is
@@ -250,8 +250,8 @@ exactly as the note says (a missing `inStock` counts as in stock).
| `SUWAYOMI_AUTH` | `ui_login` (default with a user), `basic`, or `none`. | | `SUWAYOMI_AUTH` | `ui_login` (default with a user), `basic`, or `none`. |
| `KAVITA_URL`, `KAVITA_API_KEY` | Enable `kavita`, like `http://kavita:5000`; the key of a Kavita user that can see the libraries. | | `KAVITA_URL`, `KAVITA_API_KEY` | Enable `kavita`, like `http://kavita:5000`; the key of a Kavita user that can see the libraries. |
| `BAMBUDDY_URL` | Enables `bambuddy`. | | `BAMBUDDY_URL` | Enables `bambuddy`. |
| `BAMBUDDY_SPOOLS_PATH` | Endpoint path (default `/api/v1/inventory/spools`, unverified). | | `BAMBUDDY_SPOOLS_PATH` | Endpoint path (default `/api/v1/inventory/spools`). |
| `BAMBUDDY_API_KEY`, `BAMBUDDY_AUTH_HEADER` | Sent as `Authorization: Bearer <key>`, or as the raw key in the named header. | | `BAMBUDDY_API_KEY`, `BAMBUDDY_AUTH_HEADER` | The API key from Bambuddy's Settings, sent in `X-API-Key` (or in the named header). |
| `BAMBUDDY_GROUP`, `BAMBUDDY_MATERIALS`, `BAMBUDDY_MIN_GRAMS` | Option group (Filament), materials to include (like `PLA,PETG`; all when empty), grams that count as in stock (50). | | `BAMBUDDY_GROUP`, `BAMBUDDY_MATERIALS`, `BAMBUDDY_MIN_GRAMS` | Option group (Filament), materials to include (like `PLA,PETG`; all when empty), grams that count as in stock (50). |
| `HOMEBOX_URL`, `HOMEBOX_USER`, `HOMEBOX_PASS` | Enable `homebox`, with a read-only Homebox user. | | `HOMEBOX_URL`, `HOMEBOX_USER`, `HOMEBOX_PASS` | Enable `homebox`, with a read-only Homebox user. |
| `HOMEBOX_LABELS`, `HOMEBOX_LOCATIONS` | Which items count, `name` or `name=Group`, comma separated. Labels default to `leather,paper`. | | `HOMEBOX_LABELS`, `HOMEBOX_LOCATIONS` | Which items count, `name` or `name=Group`, comma separated. Labels default to `leather,paper`. |
+3 -2
View File
@@ -43,10 +43,11 @@ SUWAYOMI_PASS=
# PersonalReader, light novels and books (daily). # PersonalReader, light novels and books (daily).
KAVITA_URL= KAVITA_URL=
KAVITA_API_KEY= KAVITA_API_KEY=
# Filament in stock (hourly). UNVERIFIED API: check the path and mapping first. # Filament in stock (hourly).
BAMBUDDY_URL= BAMBUDDY_URL=
# BAMBUDDY_SPOOLS_PATH=/api/v1/inventory/spools # BAMBUDDY_SPOOLS_PATH=/api/v1/inventory/spools
# BAMBUDDY_API_KEY= # API key from Bambuddy's Settings, sent in X-API-Key
BAMBUDDY_API_KEY=
# BAMBUDDY_AUTH_HEADER= # BAMBUDDY_AUTH_HEADER=
# BAMBUDDY_GROUP=Filament # BAMBUDDY_GROUP=Filament
# BAMBUDDY_MATERIALS=PLA,PETG # BAMBUDDY_MATERIALS=PLA,PETG
+48 -35
View File
@@ -1,13 +1,8 @@
/** /**
* Bambuddy: filament colors in stock, for the Store's Filament options. * Bambuddy: filament colors in stock, for the Store's Filament options.
* Hourly. Read only. * Hourly. Read only. GET /api/v1/inventory/spools with an API key (made in
* * Bambuddy's Settings) in the X-API-Key header. The field names below were
* UNVERIFIED: Bambuddy's API is not documented anywhere this project has, and * checked against Greg's Bambuddy on 2026-10-12.
* no request has been made to a real Bambuddy. The endpoint path is a setting
* (BAMBUDDY_SPOOLS_PATH) and everything that depends on the response's shape
* is in `mapBambuddySpools` below, so fixing it against the real API is a
* change to one function. Check it against a live Bambuddy before relying on
* it (docs/content-service.md, "Sync jobs").
*/ */
import type { SyncJob } from "../types.ts"; import type { SyncJob } from "../types.ts";
import { mergeOptions, normalizeHex, storeOnLines, type StockOption } from "./stock.ts"; import { mergeOptions, normalizeHex, storeOnLines, type StockOption } from "./stock.ts";
@@ -18,7 +13,7 @@ export interface BambuddyOptions {
url: string; url: string;
/** Path of the spool or filament inventory endpoint. */ /** Path of the spool or filament inventory endpoint. */
path?: string; path?: string;
/** Sent as `Authorization: Bearer`, or in `authHeader` when that is set. */ /** Sent in the X-API-Key header, or in `authHeader` when that is set. */
apiKey?: string; apiKey?: string;
authHeader?: string; authHeader?: string;
/** Option group the colors feed. Default Filament. */ /** Option group the colors feed. Default Filament. */
@@ -39,40 +34,58 @@ export interface Spool {
remaining?: number; remaining?: number;
} }
const pick = (o: Record<string, unknown>, keys: string[]) => { /** Plain names for spools Bambuddy leaves unnamed (RFID spools have no color_name). */
for (const k of keys) { const BASIC_COLORS: [string, number, number, number][] = [
const v = o[k]; ["Black", 0, 0, 0],
if (v !== undefined && v !== null && v !== "") return v; ["White", 255, 255, 255],
["Gray", 128, 128, 128],
["Silver", 192, 192, 192],
["Red", 200, 30, 40],
["Orange", 255, 120, 0],
["Yellow", 255, 210, 0],
["Green", 0, 160, 70],
["Blue", 20, 90, 200],
["Purple", 120, 40, 160],
["Pink", 240, 110, 170],
["Brown", 110, 70, 40],
["Beige", 240, 225, 190],
];
/** The nearest of BASIC_COLORS to a #rrggbb hex. */
export function basicColorName(hex: string): string {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as [number, number, number];
let best = BASIC_COLORS[0]!;
let bestD = Infinity;
for (const c of BASIC_COLORS) {
const d = (c[1] - r) ** 2 + (c[2] - g) ** 2 + (c[3] - b) ** 2;
if (d < bestD) [best, bestD] = [c, d];
} }
return undefined; return best[0];
}; }
/** /**
* THE ONE PLACE that knows Bambuddy's response shape (a guess; see above). * THE ONE PLACE that knows Bambuddy's spool shape. The response is a list (or
* Accepts a list, or an object holding one under items, spools, data or * an object holding one under items). Each spool has material, color_name
* results, and reads each spool's color name, hex, material and grams left * (often null), rgba like "000000FF", label_weight (grams of filament when
* under the common spellings. Archived or empty-named entries are skipped. * full), weight_used and archived_at. A spool with no color_name is named
* from its color (Black, Blue...); a color name set in Bambuddy wins.
*/ */
export function mapBambuddySpools(json: unknown): Spool[] { export function mapBambuddySpools(json: unknown): Spool[] {
const root = json as Record<string, unknown> | unknown[]; const list = Array.isArray(json) ? json : (json as Record<string, unknown> | null)?.items;
const list = Array.isArray(root) if (!Array.isArray(list)) throw new Error("Bambuddy answered with no list of spools");
? root
: (["items", "spools", "data", "results"].map((k) => (root as Record<string, unknown>)?.[k]).find(Array.isArray) as unknown[] | undefined);
if (!list) throw new Error("Bambuddy answered with no list of spools");
const out: Spool[] = []; const out: Spool[] = [];
for (const raw of list) { for (const raw of list) {
if (!raw || typeof raw !== "object") continue; if (!raw || typeof raw !== "object") continue;
const o = raw as Record<string, unknown>; const o = raw as Record<string, unknown>;
if (o.archived === true || o.deleted === true) continue; if (o.archived_at) continue;
const name = pick(o, ["color_name", "colorName", "color", "name"]); const hex = normalizeHex(o.rgba);
if (typeof name !== "string" || !name.trim()) continue; const named = typeof o.color_name === "string" && o.color_name.trim() ? o.color_name.trim() : undefined;
const s: Spool = { name: name.trim() }; const name = named ?? (hex ? basicColorName(hex) : undefined);
const hex = normalizeHex(pick(o, ["color_hex", "colorHex", "hex", "rgba", "color_code"])); if (!name) continue;
const s: Spool = { name };
if (hex) s.hex = hex; if (hex) s.hex = hex;
const material = pick(o, ["material", "filament_type", "type"]); if (typeof o.material === "string") s.material = o.material.trim();
if (typeof material === "string") s.material = material.trim(); if (typeof o.label_weight === "number") s.remaining = o.label_weight - (typeof o.weight_used === "number" ? o.weight_used : 0);
const grams = Number(pick(o, ["remaining_weight", "remainingWeight", "weight_remaining", "remaining", "remaining_grams"]));
if (Number.isFinite(grams)) s.remaining = grams;
out.push(s); out.push(s);
} }
return out; return out;
@@ -97,10 +110,10 @@ export function bambuddyJob(o: BambuddyOptions): SyncJob {
healthcheckUrl: o.healthcheckUrl, healthcheckUrl: o.healthcheckUrl,
async run(ctx) { async run(ctx) {
const headers: Record<string, string> = { accept: "application/json" }; const headers: Record<string, string> = { accept: "application/json" };
if (o.apiKey) headers[o.authHeader?.toLowerCase() || "authorization"] = o.authHeader ? o.apiKey : `Bearer ${o.apiKey}`; if (o.apiKey) headers[(o.authHeader || "x-api-key").toLowerCase()] = o.apiKey;
const url = `${o.url.replace(/\/$/, "")}/${(o.path ?? BAMBUDDY_DEFAULT_PATH).replace(/^\//, "")}`; const url = `${o.url.replace(/\/$/, "")}/${(o.path ?? BAMBUDDY_DEFAULT_PATH).replace(/^\//, "")}`;
const res = await ctx.fetch(url, { headers }); const res = await ctx.fetch(url, { headers });
if (res.status === 401 || res.status === 403) throw new Error(`Bambuddy refused the request (${res.status}); check BAMBUDDY_API_KEY`); if (res.status === 401 || res.status === 403) throw new Error(`Bambuddy refused the request (${res.status}); check BAMBUDDY_API_KEY (made in Bambuddy's Settings)`);
if (!res.ok) throw new Error(`Bambuddy answered ${res.status}; check BAMBUDDY_SPOOLS_PATH`); if (!res.ok) throw new Error(`Bambuddy answered ${res.status}; check BAMBUDDY_SPOOLS_PATH`);
const options = filamentOptions(mapBambuddySpools(await res.json()), o); const options = filamentOptions(mapBambuddySpools(await res.json()), o);
const lines = storeOnLines(ctx, "filamentStock", [{ group, options }]); const lines = storeOnLines(ctx, "filamentStock", [{ group, options }]);
@@ -17,6 +17,6 @@ and ids, names and image addresses are illustrative.
| `anilist-page.sample.json` | `POST https://graphql.anilist.co` with the batched `Page { media(id_in:) }` query | | `anilist-page.sample.json` | `POST https://graphql.anilist.co` with the batched `Page { media(id_in:) }` query |
| `kavita-series.sample.json` | `POST <KAVITA_URL>/api/Series/all-v2` (SeriesDto list) | | `kavita-series.sample.json` | `POST <KAVITA_URL>/api/Series/all-v2` (SeriesDto list) |
| `kavita-metadata.sample.json` | `GET <KAVITA_URL>/api/Series/metadata?seriesId=`, one answer per series id | | `kavita-metadata.sample.json` | `GET <KAVITA_URL>/api/Series/metadata?seriesId=`, one answer per series id |
| `bambuddy-spools.sample.json` | **A guess.** Bambuddy's API is not documented anywhere this project has; this only exercises `mapBambuddySpools`' tolerant reading. | | `bambuddy-spools.sample.json` | GET `/api/v1/inventory/spools`, fields copied from one spool on Greg's Bambuddy (2026-10-12); the other spools are made up. |
| `homebox-labels.sample.json` | `GET <HOMEBOX_URL>/api/v1/labels` | | `homebox-labels.sample.json` | `GET <HOMEBOX_URL>/api/v1/labels` |
| `homebox-items.sample.json` | `GET <HOMEBOX_URL>/api/v1/items?labels=…` | | `homebox-items.sample.json` | `GET <HOMEBOX_URL>/api/v1/items?labels=…` |
@@ -1,11 +1,8 @@
{ [
"_comment": "GUESSED SHAPE. Bambuddy's API is undocumented here; this only exercises mapBambuddySpools' tolerant reading.", { "id": 20, "material": "PLA", "subtype": "Tough+", "color_name": null, "rgba": "000000FF", "brand": "Bambu Lab", "label_weight": 1000, "core_weight": 216, "weight_used": 6.07, "archived_at": null },
"items": [ { "id": 21, "material": "PLA", "subtype": "Basic", "color_name": "Jade White", "rgba": "F5F5DCFF", "brand": "Bambu Lab", "label_weight": 1000, "core_weight": 216, "weight_used": 360, "archived_at": null },
{ "id": 1, "material": "PLA", "color_name": "Jade White", "color_hex": "F5F5DCFF", "remaining_weight": 640 }, { "id": 22, "material": "PLA", "subtype": "Basic", "color_name": "Jade White", "rgba": "F5F5DCFF", "brand": "Bambu Lab", "label_weight": 1000, "core_weight": 216, "weight_used": 980, "archived_at": null },
{ "id": 2, "material": "PLA", "color_name": "Jade White", "color_hex": "F5F5DC", "remaining_weight": 20 }, { "id": 23, "material": "PLA", "subtype": "Basic", "color_name": "Bambu Green", "rgba": "00AE42FF", "brand": "Bambu Lab", "label_weight": 1000, "core_weight": 216, "weight_used": 985, "archived_at": null },
{ "id": 3, "material": "PLA", "colorName": "Bambu Green", "colorHex": "#00AE42", "remainingWeight": 15 }, { "id": 24, "material": "PETG", "subtype": "Translucent", "color_name": "Translucent Orange", "rgba": "FF6A13FF", "brand": "Bambu Lab", "label_weight": 1000, "core_weight": 216, "weight_used": 100, "archived_at": null },
{ "id": 4, "material": "PETG", "color": "Translucent Orange", "hex": "#FF6A13", "remaining": 900 }, { "id": 25, "material": "PLA", "subtype": "Basic", "color_name": "Old Spool", "rgba": "808080FF", "brand": "Bambu Lab", "label_weight": 1000, "core_weight": 216, "weight_used": 0, "archived_at": "2026-09-01T00:00:00" }
{ "id": 5, "material": "PLA", "color_name": "Old Spool", "archived": true, "remaining_weight": 500 }, ]
{ "id": 6, "material": "PLA", "color_name": "Charcoal", "color_hex": "#2b2b2b" }
]
}
+12 -10
View File
@@ -1,7 +1,7 @@
/** /**
* The homelab jobs (Suwayomi, Kavita, Bambuddy, Homebox) and Library * The homelab jobs (Suwayomi, Kavita, Bambuddy, Homebox) and Library
* suggestions, against the hand-written sample payloads in ./fixtures. Those * suggestions, against the hand-written sample payloads in ./fixtures. Those
* are documented shapes (Bambuddy's is a guess), never live responses; see * are documented shapes, never live responses; see
* fixtures/README.md. * fixtures/README.md.
*/ */
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
@@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { parseTokens } from "../../src/config.ts"; import { parseTokens } from "../../src/config.ts";
import { createHttpServer } from "../../src/http.ts"; import { createHttpServer } from "../../src/http.ts";
import { listSuggestions } from "../../src/suggestions.ts"; import { listSuggestions } from "../../src/suggestions.ts";
import { bambuddyJob, filamentOptions, mapBambuddySpools } from "../../src/sync/jobs/bambuddy.ts"; import { basicColorName, bambuddyJob, filamentOptions, mapBambuddySpools } from "../../src/sync/jobs/bambuddy.ts";
import { homelabSettings } from "../../src/sync/jobs/homelab.ts"; import { homelabSettings } from "../../src/sync/jobs/homelab.ts";
import { homeboxJob, materialOptions, parseFilters, parseItems } from "../../src/sync/jobs/homebox.ts"; import { homeboxJob, materialOptions, parseFilters, parseItems } from "../../src/sync/jobs/homebox.ts";
import { kavitaJob, kavitaState, parseMetadata, parseSeries } from "../../src/sync/jobs/kavita.ts"; import { kavitaJob, kavitaState, parseMetadata, parseSeries } from "../../src/sync/jobs/kavita.ts";
@@ -284,18 +284,20 @@ describe("Kavita", () => {
// ---------------------------------------------------------------- Bambuddy // ---------------------------------------------------------------- Bambuddy
describe("Bambuddy (unverified API shape)", () => { describe("Bambuddy", () => {
it("maps spools to colors: merged by name, enough grams is in stock, archived skipped", () => { it("maps spools to colors: unnamed spools named by color, merged by name, grams left decide stock, archived skipped", () => {
const spools = mapBambuddySpools(fixture("bambuddy-spools.sample.json")); const spools = mapBambuddySpools(fixture("bambuddy-spools.sample.json"));
expect(spools.map((s) => s.name)).toEqual(["Jade White", "Jade White", "Bambu Green", "Translucent Orange", "Charcoal"]); expect(spools.map((s) => s.name)).toEqual(["Black", "Jade White", "Jade White", "Bambu Green", "Translucent Orange"]);
expect(spools[0]).toEqual({ name: "Black", hex: "#000000", material: "PLA", remaining: 1000 - 6.07 });
expect(filamentOptions(spools)).toEqual([ expect(filamentOptions(spools)).toEqual([
{ name: "Charcoal", hex: "#2b2b2b", inStock: true }, { name: "Black", hex: "#000000", inStock: true },
{ name: "Jade White", hex: "#f5f5dc", inStock: true }, { name: "Jade White", hex: "#f5f5dc", inStock: true },
{ name: "Translucent Orange", hex: "#ff6a13", inStock: true }, { name: "Translucent Orange", hex: "#ff6a13", inStock: true },
{ name: "Bambu Green", hex: "#00ae42", inStock: false }, { name: "Bambu Green", hex: "#00ae42", inStock: false },
]); ]);
expect(filamentOptions(spools, { materials: ["PETG"] }).map((o) => o.name)).toEqual(["Translucent Orange"]); expect(filamentOptions(spools, { materials: ["PETG"] }).map((o) => o.name)).toEqual(["Translucent Orange"]);
expect(mapBambuddySpools([{ name: "Plain list" }])).toEqual([{ name: "Plain list" }]); expect(mapBambuddySpools({ items: [{ color_name: "Wrapped" }] })).toEqual([{ name: "Wrapped" }]);
expect(basicColorName("#1e3a8a")).toBe("Blue");
expect(() => mapBambuddySpools({ nothing: true })).toThrow(/no list/); expect(() => mapBambuddySpools({ nothing: true })).toThrow(/no list/);
}); });
@@ -316,10 +318,10 @@ describe("Bambuddy (unverified API shape)", () => {
const cards = r.put(line("Cards", "Finish")); const cards = r.put(line("Cards", "Finish"));
r.service.scan(); r.service.scan();
const f = fakeFetch(() => ok(fixture("bambuddy-spools.sample.json"))); const f = fakeFetch(() => ok(fixture("bambuddy-spools.sample.json")));
const report = await scheduler(bambuddyJob({ url: "http://bambuddy:8000/", path: "/api/v1/spools", apiKey: "k" }), f.fn).runNow("bambuddy"); const report = await scheduler(bambuddyJob({ url: "http://bambuddy:8000/", apiKey: "k" }), f.fn).runNow("bambuddy");
expect(report).toMatchObject({ ok: true, counts: { colors: 4, inStock: 3, lines: 1 } }); expect(report).toMatchObject({ ok: true, counts: { colors: 4, inStock: 3, lines: 1 } });
expect(f.calls[0]!.url).toBe("http://bambuddy:8000/api/v1/spools"); expect(f.calls[0]!.url).toBe("http://bambuddy:8000/api/v1/inventory/spools");
expect(headerOf(f.calls[0]!, "authorization")).toBe("Bearer k"); expect(headerOf(f.calls[0]!, "x-api-key")).toBe("k");
expect(r.store.syncCache.forPath(printed).filamentStock).toEqual([{ group: "Filament", options: expect.arrayContaining([{ name: "Jade White", hex: "#f5f5dc", inStock: true }]) }]); expect(r.store.syncCache.forPath(printed).filamentStock).toEqual([{ group: "Filament", options: expect.arrayContaining([{ name: "Jade White", hex: "#f5f5dc", inStock: true }]) }]);
expect(r.store.syncCache.forPath(cards)).toEqual({}); expect(r.store.syncCache.forPath(cards)).toEqual({});
// Notes are never written. // Notes are never written.