@openparachute/vault 0.5.1 → 0.5.2-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/core.test.ts +183 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +77 -0
- package/core/src/mcp.ts +130 -22
- package/core/src/notes.ts +36 -0
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/schema.ts +7 -4
- package/core/src/store.ts +1 -1
- package/core/src/tag-schemas.ts +59 -44
- package/core/src/types.ts +31 -3
- package/package.json +1 -1
- package/src/auth.test.ts +37 -1
- package/src/auth.ts +29 -0
- package/src/cli.ts +286 -68
- package/src/config.test.ts +16 -0
- package/src/config.ts +39 -0
- package/src/init-summary.test.ts +77 -5
- package/src/init-summary.ts +37 -19
- package/src/mcp-tools.ts +60 -6
- package/src/routes.ts +486 -53
- package/src/routing.test.ts +185 -0
- package/src/routing.ts +32 -2
- package/src/server.ts +7 -0
- package/src/storage.test.ts +162 -0
- package/src/tag-scope.ts +68 -1
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +298 -11
- package/src/vault.test.ts +1064 -7
package/src/vault.test.ts
CHANGED
|
@@ -11,8 +11,13 @@ import { BunStore } from "./vault-store.ts";
|
|
|
11
11
|
import { generateMcpTools } from "../core/src/mcp.ts";
|
|
12
12
|
import { getLinksHydrated } from "../core/src/links.ts";
|
|
13
13
|
import { buildVaultProjection } from "../core/src/vault-projection.ts";
|
|
14
|
-
import { handleNotes, handleTags, handleFindPath, handleVault } from "./routes.ts";
|
|
14
|
+
import { handleNotes, handleTags, handleFindPath, handleVault, handleUnresolvedWikilinks } from "./routes.ts";
|
|
15
|
+
import { expandTokenTagScope } from "./tag-scope.ts";
|
|
16
|
+
import type { TagScopeCtx } from "./routes.ts";
|
|
15
17
|
import { extractApiKey } from "./auth.ts";
|
|
18
|
+
import { startTranscriptionWorker } from "./transcription-worker.ts";
|
|
19
|
+
import { setTranscriptionWorker } from "./transcription-registry.ts";
|
|
20
|
+
import type { Store } from "../core/src/types.ts";
|
|
16
21
|
|
|
17
22
|
let db: Database;
|
|
18
23
|
let store: BunStore;
|
|
@@ -1462,6 +1467,143 @@ describe("scoped MCP wrapper", async () => {
|
|
|
1462
1467
|
|
|
1463
1468
|
close();
|
|
1464
1469
|
});
|
|
1470
|
+
|
|
1471
|
+
// -- tag-scope confidentiality: expand_links + include_links (security
|
|
1472
|
+
// review) -----------------------------------------------------------
|
|
1473
|
+
//
|
|
1474
|
+
// These pin the MCP side of the expand_links / include_links leaks. A
|
|
1475
|
+
// tag-scoped session must NOT inline out-of-scope note content via
|
|
1476
|
+
// expand_links, and must NOT hydrate out-of-scope neighbor summaries via
|
|
1477
|
+
// include_links. The unscoped path must remain fully functional. They
|
|
1478
|
+
// MUST fail if the predicate / link-scrub is removed.
|
|
1479
|
+
|
|
1480
|
+
test("MCP expand_links does NOT inline out-of-scope wikilinked content", async () => {
|
|
1481
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1482
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1483
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1484
|
+
|
|
1485
|
+
const vaultName = `tagscope-expand-${Date.now()}`;
|
|
1486
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1487
|
+
const store = getVaultStore(vaultName);
|
|
1488
|
+
// Out-of-scope #personal note holds the secret; in-scope #work note links it.
|
|
1489
|
+
await store.createNote("SECRET PERSONAL BODY", { path: "Secret", tags: ["personal"] });
|
|
1490
|
+
const work = await store.createNote("intro [[Secret]]", { path: "Work", tags: ["work"] });
|
|
1491
|
+
|
|
1492
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
|
|
1493
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1494
|
+
const result = await query.execute({
|
|
1495
|
+
id: work.id,
|
|
1496
|
+
include_content: true,
|
|
1497
|
+
expand_links: true,
|
|
1498
|
+
}) as any;
|
|
1499
|
+
|
|
1500
|
+
expect(result.content).not.toContain("SECRET PERSONAL BODY");
|
|
1501
|
+
// Wikilink stays literal — indistinguishable from not-found.
|
|
1502
|
+
expect(result.content).toContain("[[Secret]]");
|
|
1503
|
+
|
|
1504
|
+
closeAllStores();
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
test("MCP expand_links multi-hop (depth>1) does not leak out-of-scope content", async () => {
|
|
1508
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1509
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1510
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1511
|
+
|
|
1512
|
+
const vaultName = `tagscope-expand-deep-${Date.now()}`;
|
|
1513
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1514
|
+
const store = getVaultStore(vaultName);
|
|
1515
|
+
await store.createNote("DEEP PERSONAL SECRET", { path: "Deep", tags: ["personal"] });
|
|
1516
|
+
await store.createNote("mid [[Deep]]", { path: "Mid", tags: ["work"] });
|
|
1517
|
+
const top = await store.createNote("top [[Mid]]", { path: "Top", tags: ["work"] });
|
|
1518
|
+
|
|
1519
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
|
|
1520
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1521
|
+
const result = await query.execute({
|
|
1522
|
+
id: top.id,
|
|
1523
|
+
include_content: true,
|
|
1524
|
+
expand_links: true,
|
|
1525
|
+
expand_depth: 3,
|
|
1526
|
+
}) as any;
|
|
1527
|
+
|
|
1528
|
+
// In-scope Mid inlines; out-of-scope Deep never does, at any depth.
|
|
1529
|
+
expect(result.content).toContain("mid");
|
|
1530
|
+
expect(result.content).not.toContain("DEEP PERSONAL SECRET");
|
|
1531
|
+
|
|
1532
|
+
closeAllStores();
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
test("UNSCOPED MCP expand_links still inlines wikilinked content (regression)", async () => {
|
|
1536
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1537
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1538
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1539
|
+
|
|
1540
|
+
const vaultName = `tagscope-expand-unscoped-${Date.now()}`;
|
|
1541
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1542
|
+
const store = getVaultStore(vaultName);
|
|
1543
|
+
await store.createNote("PERSONAL BODY", { path: "Secret", tags: ["personal"] });
|
|
1544
|
+
const work = await store.createNote("intro [[Secret]]", { path: "Work", tags: ["work"] });
|
|
1545
|
+
|
|
1546
|
+
// No auth → unscoped session. Expansion must behave exactly as before.
|
|
1547
|
+
const tools = generateScopedMcpTools(vaultName);
|
|
1548
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1549
|
+
const result = await query.execute({
|
|
1550
|
+
id: work.id,
|
|
1551
|
+
include_content: true,
|
|
1552
|
+
expand_links: true,
|
|
1553
|
+
}) as any;
|
|
1554
|
+
|
|
1555
|
+
expect(result.content).toContain("PERSONAL BODY");
|
|
1556
|
+
|
|
1557
|
+
closeAllStores();
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
test("MCP include_links strips out-of-scope NEIGHBOR summaries", async () => {
|
|
1561
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1562
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1563
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1564
|
+
|
|
1565
|
+
const vaultName = `tagscope-incl-links-${Date.now()}`;
|
|
1566
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1567
|
+
const store = getVaultStore(vaultName);
|
|
1568
|
+
const secret = await store.createNote("secret", { path: "Secret", tags: ["personal"] });
|
|
1569
|
+
const work = await store.createNote("work", { path: "Work", tags: ["work"] });
|
|
1570
|
+
await store.createLink(work.id, secret.id, "references");
|
|
1571
|
+
|
|
1572
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
|
|
1573
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1574
|
+
const result = await query.execute({ id: work.id, include_links: true }) as any;
|
|
1575
|
+
|
|
1576
|
+
const links = (result.links ?? []) as any[];
|
|
1577
|
+
// No surviving link may reference the out-of-scope note's id/path.
|
|
1578
|
+
const serialized = JSON.stringify(links);
|
|
1579
|
+
expect(serialized).not.toContain(secret.id);
|
|
1580
|
+
expect(serialized).not.toContain("Secret");
|
|
1581
|
+
|
|
1582
|
+
closeAllStores();
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
test("UNSCOPED MCP include_links still hydrates the full neighbor (regression)", async () => {
|
|
1586
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1587
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1588
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1589
|
+
|
|
1590
|
+
const vaultName = `tagscope-incl-links-unscoped-${Date.now()}`;
|
|
1591
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1592
|
+
const store = getVaultStore(vaultName);
|
|
1593
|
+
const secret = await store.createNote("secret", { path: "Secret", tags: ["personal"] });
|
|
1594
|
+
const work = await store.createNote("work", { path: "Work", tags: ["work"] });
|
|
1595
|
+
await store.createLink(work.id, secret.id, "references");
|
|
1596
|
+
|
|
1597
|
+
const tools = generateScopedMcpTools(vaultName);
|
|
1598
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1599
|
+
const result = await query.execute({ id: work.id, include_links: true }) as any;
|
|
1600
|
+
|
|
1601
|
+
const links = (result.links ?? []) as any[];
|
|
1602
|
+
expect(links.length).toBe(1);
|
|
1603
|
+
expect(JSON.stringify(links)).toContain(secret.id);
|
|
1604
|
+
|
|
1605
|
+
closeAllStores();
|
|
1606
|
+
});
|
|
1465
1607
|
});
|
|
1466
1608
|
|
|
1467
1609
|
describe("auth permissions", () => {
|
|
@@ -2721,6 +2863,171 @@ describe("HTTP /notes", async () => {
|
|
|
2721
2863
|
const body = await res.json() as any[];
|
|
2722
2864
|
expect(body.map((n) => n.content)).toEqual(["new"]);
|
|
2723
2865
|
});
|
|
2866
|
+
|
|
2867
|
+
// ---- JSON `metadata=<json>` alias (symmetric with the MCP nested obj) ----
|
|
2868
|
+
//
|
|
2869
|
+
// Before this alias, a `?metadata={...}` param was silently dropped: the
|
|
2870
|
+
// bracket grammar never matched it, `queryOpts.metadata` stayed undefined,
|
|
2871
|
+
// and the query returned ALL tag-matching notes — a silent wrong result.
|
|
2872
|
+
|
|
2873
|
+
test("alias `metadata={field:{op:value}}` filters on an indexed field", async () => {
|
|
2874
|
+
await declareIndexed();
|
|
2875
|
+
await store.createNote("open-1", { metadata: { status: "open" } });
|
|
2876
|
+
await store.createNote("open-2", { metadata: { status: "open" } });
|
|
2877
|
+
await store.createNote("closed", { metadata: { status: "closed" } });
|
|
2878
|
+
const q = encodeURIComponent(JSON.stringify({ status: { eq: "open" } }));
|
|
2879
|
+
const res = await handleNotes(
|
|
2880
|
+
mkReq("GET", `/notes?metadata=${q}&include_content=true`),
|
|
2881
|
+
store,
|
|
2882
|
+
"",
|
|
2883
|
+
);
|
|
2884
|
+
expect(res.status).toBe(200);
|
|
2885
|
+
const body = await res.json() as any[];
|
|
2886
|
+
expect(body.map((n) => n.content).sort()).toEqual(["open-1", "open-2"]);
|
|
2887
|
+
});
|
|
2888
|
+
|
|
2889
|
+
test("alias shorthand equality `metadata={field:value}` works via json_extract fallback", async () => {
|
|
2890
|
+
// No declareIndexed — shorthand routes through the engine's json_extract
|
|
2891
|
+
// exact-match path, no indexed declaration required.
|
|
2892
|
+
await store.createNote("matches", { metadata: { status: "open" } });
|
|
2893
|
+
await store.createNote("other", { metadata: { status: "closed" } });
|
|
2894
|
+
const q = encodeURIComponent(JSON.stringify({ status: "open" }));
|
|
2895
|
+
const res = await handleNotes(
|
|
2896
|
+
mkReq("GET", `/notes?metadata=${q}&include_content=true`),
|
|
2897
|
+
store,
|
|
2898
|
+
"",
|
|
2899
|
+
);
|
|
2900
|
+
expect(res.status).toBe(200);
|
|
2901
|
+
const body = await res.json() as any[];
|
|
2902
|
+
expect(body.map((n) => n.content)).toEqual(["matches"]);
|
|
2903
|
+
});
|
|
2904
|
+
|
|
2905
|
+
test("alias and bracket form return identical results for the same indexed-field operator query", async () => {
|
|
2906
|
+
await declareIndexed();
|
|
2907
|
+
for (const p of [1, 2, 3, 4, 5]) {
|
|
2908
|
+
await store.createNote(`p${p}`, { metadata: { priority: p } });
|
|
2909
|
+
}
|
|
2910
|
+
// JSON preserves the real number type 3; bracket form passes "3" as a
|
|
2911
|
+
// string. Both must coerce to the same range result against the INTEGER
|
|
2912
|
+
// indexed column — this guards the type-coercion edge.
|
|
2913
|
+
const aliasQ = encodeURIComponent(JSON.stringify({ priority: { gte: 3 } }));
|
|
2914
|
+
const aliasRes = await handleNotes(
|
|
2915
|
+
mkReq("GET", `/notes?metadata=${aliasQ}&include_content=true`),
|
|
2916
|
+
store,
|
|
2917
|
+
"",
|
|
2918
|
+
);
|
|
2919
|
+
const bracketRes = await handleNotes(
|
|
2920
|
+
mkReq("GET", "/notes?meta[priority][gte]=3&include_content=true"),
|
|
2921
|
+
store,
|
|
2922
|
+
"",
|
|
2923
|
+
);
|
|
2924
|
+
const aliasBody = await aliasRes.json() as any[];
|
|
2925
|
+
const bracketBody = await bracketRes.json() as any[];
|
|
2926
|
+
expect(aliasBody.map((n) => n.content).sort()).toEqual(["p3", "p4", "p5"]);
|
|
2927
|
+
expect(aliasBody.map((n) => n.content).sort()).toEqual(
|
|
2928
|
+
bracketBody.map((n) => n.content).sort(),
|
|
2929
|
+
);
|
|
2930
|
+
});
|
|
2931
|
+
|
|
2932
|
+
test("malformed JSON in `metadata=` rejects with 400 INVALID_QUERY", async () => {
|
|
2933
|
+
const res = await handleNotes(
|
|
2934
|
+
mkReq("GET", "/notes?metadata=" + encodeURIComponent("{not json")),
|
|
2935
|
+
store,
|
|
2936
|
+
"",
|
|
2937
|
+
);
|
|
2938
|
+
expect(res.status).toBe(400);
|
|
2939
|
+
const body = await res.json() as any;
|
|
2940
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
2941
|
+
expect(body.error).toContain("JSON object");
|
|
2942
|
+
});
|
|
2943
|
+
|
|
2944
|
+
test("non-object `metadata=` JSON (array) rejects with 400 INVALID_QUERY", async () => {
|
|
2945
|
+
const res = await handleNotes(
|
|
2946
|
+
mkReq("GET", "/notes?metadata=" + encodeURIComponent(JSON.stringify(["status"]))),
|
|
2947
|
+
store,
|
|
2948
|
+
"",
|
|
2949
|
+
);
|
|
2950
|
+
expect(res.status).toBe(400);
|
|
2951
|
+
const body = await res.json() as any;
|
|
2952
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
2953
|
+
});
|
|
2954
|
+
|
|
2955
|
+
test("primitive-scalar `metadata=` JSON (number / bare string) rejects with 400 INVALID_QUERY", async () => {
|
|
2956
|
+
// `metadata=42` and `metadata="open"` are valid JSON but not objects —
|
|
2957
|
+
// both fall through the non-object branch.
|
|
2958
|
+
for (const raw of ["42", JSON.stringify("open")]) {
|
|
2959
|
+
const res = await handleNotes(
|
|
2960
|
+
mkReq("GET", "/notes?metadata=" + encodeURIComponent(raw)),
|
|
2961
|
+
store,
|
|
2962
|
+
"",
|
|
2963
|
+
);
|
|
2964
|
+
expect(res.status).toBe(400);
|
|
2965
|
+
const body = await res.json() as any;
|
|
2966
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
2967
|
+
}
|
|
2968
|
+
});
|
|
2969
|
+
|
|
2970
|
+
test("empty-object alias `metadata={}` is treated as absent and composes with a bracket filter", async () => {
|
|
2971
|
+
// `{}` carries no filter intent — it must neither set a metadata filter
|
|
2972
|
+
// NOR trip the both-forms 400 guard. So `metadata={}` + a bracket
|
|
2973
|
+
// metadata filter is a 200 filtered by the bracket form only.
|
|
2974
|
+
await declareIndexed();
|
|
2975
|
+
await store.createNote("hi", { metadata: { priority: 5 } });
|
|
2976
|
+
await store.createNote("lo", { metadata: { priority: 1 } });
|
|
2977
|
+
const res = await handleNotes(
|
|
2978
|
+
mkReq("GET", "/notes?metadata=" + encodeURIComponent("{}") + "&meta[priority][gte]=3&include_content=true"),
|
|
2979
|
+
store,
|
|
2980
|
+
"",
|
|
2981
|
+
);
|
|
2982
|
+
expect(res.status).toBe(200);
|
|
2983
|
+
const body = await res.json() as any[];
|
|
2984
|
+
expect(body.map((n) => n.content)).toEqual(["hi"]);
|
|
2985
|
+
});
|
|
2986
|
+
|
|
2987
|
+
test("both `metadata=` alias AND `meta[...]` bracket params present rejects with 400 INVALID_QUERY", async () => {
|
|
2988
|
+
await declareIndexed();
|
|
2989
|
+
const q = encodeURIComponent(JSON.stringify({ status: { eq: "open" } }));
|
|
2990
|
+
const res = await handleNotes(
|
|
2991
|
+
mkReq("GET", `/notes?metadata=${q}&meta[priority][gte]=3`),
|
|
2992
|
+
store,
|
|
2993
|
+
"",
|
|
2994
|
+
);
|
|
2995
|
+
expect(res.status).toBe(400);
|
|
2996
|
+
const body = await res.json() as any;
|
|
2997
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
2998
|
+
expect(body.error).toContain("not both");
|
|
2999
|
+
});
|
|
3000
|
+
|
|
3001
|
+
test("regression: previously-silently-dropped `?metadata={status:{eq:pending}}` now actually filters", async () => {
|
|
3002
|
+
await declareIndexed();
|
|
3003
|
+
await store.createNote("pending-1", { metadata: { status: "pending" } });
|
|
3004
|
+
await store.createNote("pending-2", { metadata: { status: "pending" } });
|
|
3005
|
+
await store.createNote("done", { metadata: { status: "done" } });
|
|
3006
|
+
const q = encodeURIComponent(JSON.stringify({ status: { eq: "pending" } }));
|
|
3007
|
+
const res = await handleNotes(
|
|
3008
|
+
mkReq("GET", `/notes?metadata=${q}&include_content=true`),
|
|
3009
|
+
store,
|
|
3010
|
+
"",
|
|
3011
|
+
);
|
|
3012
|
+
expect(res.status).toBe(200);
|
|
3013
|
+
const body = await res.json() as any[];
|
|
3014
|
+
// Before the fix this returned ALL three notes (filter dropped). Now it
|
|
3015
|
+
// returns only the two pending ones.
|
|
3016
|
+
expect(body.map((n) => n.content).sort()).toEqual(["pending-1", "pending-2"]);
|
|
3017
|
+
});
|
|
3018
|
+
|
|
3019
|
+
test("alias with an unknown operator surfaces the engine's 400 UNKNOWN_OPERATOR", async () => {
|
|
3020
|
+
await declareIndexed();
|
|
3021
|
+
const q = encodeURIComponent(JSON.stringify({ priority: { bogus: 5 } }));
|
|
3022
|
+
const res = await handleNotes(
|
|
3023
|
+
mkReq("GET", `/notes?metadata=${q}`),
|
|
3024
|
+
store,
|
|
3025
|
+
"",
|
|
3026
|
+
);
|
|
3027
|
+
expect(res.status).toBe(400);
|
|
3028
|
+
const body = await res.json() as any;
|
|
3029
|
+
expect(body.code).toBe("UNKNOWN_OPERATOR");
|
|
3030
|
+
});
|
|
2724
3031
|
});
|
|
2725
3032
|
|
|
2726
3033
|
// -------------------------------------------------------------------------
|
|
@@ -2803,7 +3110,10 @@ describe("HTTP /notes", async () => {
|
|
|
2803
3110
|
expect(res.status).toBe(404);
|
|
2804
3111
|
});
|
|
2805
3112
|
|
|
2806
|
-
test("400
|
|
3113
|
+
test("400 no_failed_attachment when target is a regular note with no failed audio", async () => {
|
|
3114
|
+
// A note without `transcript_status` frontmatter is treated as a
|
|
3115
|
+
// possible legacy in-body memo (finding F). With no attachment carrying
|
|
3116
|
+
// a failed transcription there's nothing to retry → no_failed_attachment.
|
|
2807
3117
|
await store.createNote("regular note", { id: "regular" });
|
|
2808
3118
|
const res = await handleNotes(
|
|
2809
3119
|
mkReq("POST", "/notes/regular/retry-transcription"),
|
|
@@ -2813,7 +3123,7 @@ describe("HTTP /notes", async () => {
|
|
|
2813
3123
|
);
|
|
2814
3124
|
expect(res.status).toBe(400);
|
|
2815
3125
|
const body = await res.json() as any;
|
|
2816
|
-
expect(body.error).toBe("
|
|
3126
|
+
expect(body.error).toBe("no_failed_attachment");
|
|
2817
3127
|
});
|
|
2818
3128
|
|
|
2819
3129
|
test("400 not_failed when transcript already succeeded", async () => {
|
|
@@ -2897,6 +3207,606 @@ describe("HTTP /notes", async () => {
|
|
|
2897
3207
|
expect(res.status).toBe(405);
|
|
2898
3208
|
delete process.env.ASSETS_DIR;
|
|
2899
3209
|
});
|
|
3210
|
+
|
|
3211
|
+
// -----------------------------------------------------------------------
|
|
3212
|
+
// Legacy in-body memo retry (finding F). The target is the memo note
|
|
3213
|
+
// itself (no `transcript_status` frontmatter); it directly owns a failed
|
|
3214
|
+
// audio attachment. The request must reset the attachment preserving
|
|
3215
|
+
// `transcribe_origin: "legacy"` and re-arm `transcribe_stub: true` so the
|
|
3216
|
+
// worker's legacy success path will write the transcript back into the
|
|
3217
|
+
// body. End-to-end re-transcription is covered in
|
|
3218
|
+
// transcription-worker.test.ts.
|
|
3219
|
+
// -----------------------------------------------------------------------
|
|
3220
|
+
async function seedLegacyFailedMemo(opts: {
|
|
3221
|
+
noteId?: string;
|
|
3222
|
+
audioPath?: string;
|
|
3223
|
+
withFile?: boolean;
|
|
3224
|
+
} = {}): Promise<{ noteId: string; attachmentId: string; audioPath: string }> {
|
|
3225
|
+
const noteId = opts.noteId ?? "legacy-memo";
|
|
3226
|
+
const audioPath = opts.audioPath ?? `${noteId}/voice.webm`;
|
|
3227
|
+
// The capture body after a terminal failure: marker replaced the
|
|
3228
|
+
// placeholder, embed intact, stub cleared by the worker.
|
|
3229
|
+
const note = await store.createNote(
|
|
3230
|
+
`# 🎙️ Voice memo\n\n_Recorded sometime._\n\n_Transcription unavailable._\n\n![[${audioPath}]]\n`,
|
|
3231
|
+
{ id: noteId },
|
|
3232
|
+
);
|
|
3233
|
+
const att = await store.addAttachment(note.id, audioPath, "audio/webm", {
|
|
3234
|
+
transcribe_status: "failed",
|
|
3235
|
+
// legacy origin is the default (undefined); leave it off to model the
|
|
3236
|
+
// genuine legacy capture shape.
|
|
3237
|
+
transcribe_error: "scribe down",
|
|
3238
|
+
transcribe_attempts: 3,
|
|
3239
|
+
});
|
|
3240
|
+
const assetsRoot = join(tmpDir, "assets");
|
|
3241
|
+
if (opts.withFile !== false) {
|
|
3242
|
+
mkdirSync(join(assetsRoot, audioPath.split("/").slice(0, -1).join("/")), { recursive: true });
|
|
3243
|
+
writeFileSync(join(assetsRoot, audioPath), Buffer.from([1, 2, 3]));
|
|
3244
|
+
}
|
|
3245
|
+
process.env.ASSETS_DIR = assetsRoot;
|
|
3246
|
+
return { noteId, attachmentId: att.id, audioPath };
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
test("legacy in-body memo: 202, resets attachment (legacy origin) + re-arms stub", async () => {
|
|
3250
|
+
const { noteId, attachmentId, audioPath } = await seedLegacyFailedMemo();
|
|
3251
|
+
const res = await handleNotes(
|
|
3252
|
+
mkReq("POST", `/notes/${noteId}/retry-transcription`),
|
|
3253
|
+
store,
|
|
3254
|
+
`/${noteId}/retry-transcription`,
|
|
3255
|
+
"default",
|
|
3256
|
+
);
|
|
3257
|
+
expect(res.status).toBe(202);
|
|
3258
|
+
const body = await res.json() as any;
|
|
3259
|
+
expect(body.status).toBe("queued");
|
|
3260
|
+
expect(body.attachment_id).toBe(attachmentId);
|
|
3261
|
+
expect(body.attachment_path).toBe(audioPath);
|
|
3262
|
+
expect(body.transcript_note_id).toBe(noteId);
|
|
3263
|
+
|
|
3264
|
+
// Attachment reset to pending, legacy origin preserved (NOT flipped to
|
|
3265
|
+
// auto — that would orphan the in-body embed), failure state cleared.
|
|
3266
|
+
const att = await store.getAttachment(attachmentId);
|
|
3267
|
+
expect(att?.metadata?.transcribe_status).toBe("pending");
|
|
3268
|
+
expect(att?.metadata?.transcribe_origin).toBe("legacy");
|
|
3269
|
+
expect(att?.metadata?.transcribe_error).toBeUndefined();
|
|
3270
|
+
expect(att?.metadata?.transcribe_attempts).toBeUndefined();
|
|
3271
|
+
|
|
3272
|
+
// Stub re-armed on the note — without this the worker's legacy success
|
|
3273
|
+
// path early-returns and never writes the transcript back.
|
|
3274
|
+
const updated = await store.getNote(noteId);
|
|
3275
|
+
expect((updated!.metadata as any)?.transcribe_stub).toBe(true);
|
|
3276
|
+
// Body untouched by the retry request itself (embed + marker intact).
|
|
3277
|
+
expect(updated!.content).toContain(`![[${audioPath}]]`);
|
|
3278
|
+
expect(updated!.content).toContain("_Transcription unavailable._");
|
|
3279
|
+
|
|
3280
|
+
delete process.env.ASSETS_DIR;
|
|
3281
|
+
});
|
|
3282
|
+
|
|
3283
|
+
test("legacy in-body memo: 404 audio_missing when the file is gone", async () => {
|
|
3284
|
+
const { noteId } = await seedLegacyFailedMemo({
|
|
3285
|
+
noteId: "legacy-gone",
|
|
3286
|
+
withFile: false,
|
|
3287
|
+
});
|
|
3288
|
+
const res = await handleNotes(
|
|
3289
|
+
mkReq("POST", `/notes/${noteId}/retry-transcription`),
|
|
3290
|
+
store,
|
|
3291
|
+
`/${noteId}/retry-transcription`,
|
|
3292
|
+
"default",
|
|
3293
|
+
);
|
|
3294
|
+
expect(res.status).toBe(404);
|
|
3295
|
+
const body = await res.json() as any;
|
|
3296
|
+
expect(body.error).toBe("audio_missing");
|
|
3297
|
+
delete process.env.ASSETS_DIR;
|
|
3298
|
+
});
|
|
3299
|
+
|
|
3300
|
+
test("legacy in-body memo: end-to-end retry round-trip (capture → fail → retry → success)", async () => {
|
|
3301
|
+
// Start from the CANONICAL capture body (recorder.ts memoNoteContent
|
|
3302
|
+
// shape): header + _Recorded_ + _Transcript pending._ + ![[embed]],
|
|
3303
|
+
// with transcribe_stub: true.
|
|
3304
|
+
const audioPath = "e2e/voice.webm";
|
|
3305
|
+
const captureBody =
|
|
3306
|
+
"# 🎙️ Voice memo\n\n_Recorded sometime._\n\n_Transcript pending._\n\n![[e2e/voice.webm]]\n";
|
|
3307
|
+
await store.createNote(captureBody, {
|
|
3308
|
+
id: "e2e-memo",
|
|
3309
|
+
metadata: { transcribe_stub: true },
|
|
3310
|
+
});
|
|
3311
|
+
const att = await store.addAttachment("e2e-memo", audioPath, "audio/webm", {
|
|
3312
|
+
transcribe_status: "pending",
|
|
3313
|
+
transcribe_attempts: 2, // one more failure flips to terminal at maxAttempts=3
|
|
3314
|
+
});
|
|
3315
|
+
const assetsRoot = join(tmpDir, "assets");
|
|
3316
|
+
mkdirSync(join(assetsRoot, "e2e"), { recursive: true });
|
|
3317
|
+
writeFileSync(join(assetsRoot, audioPath), Buffer.from([1, 2, 3]));
|
|
3318
|
+
process.env.ASSETS_DIR = assetsRoot;
|
|
3319
|
+
|
|
3320
|
+
// What a first-try success would have produced (for the final assert).
|
|
3321
|
+
const firstTrySuccessBody =
|
|
3322
|
+
"# 🎙️ Voice memo\n\n_Recorded sometime._\n\nthe spoken words\n\n![[e2e/voice.webm]]\n";
|
|
3323
|
+
|
|
3324
|
+
// --- Phase 1: terminal failure. Worker writes the marker in place,
|
|
3325
|
+
// preserving the embed, and clears the stub.
|
|
3326
|
+
let fetchMode: "fail" | "succeed" = "fail";
|
|
3327
|
+
const fetchImpl = (async () => {
|
|
3328
|
+
if (fetchMode === "fail") {
|
|
3329
|
+
return new Response("scribe down", { status: 500 });
|
|
3330
|
+
}
|
|
3331
|
+
return new Response(JSON.stringify({ text: "the spoken words" }), {
|
|
3332
|
+
status: 200,
|
|
3333
|
+
headers: { "content-type": "application/json" },
|
|
3334
|
+
});
|
|
3335
|
+
}) as typeof fetch;
|
|
3336
|
+
|
|
3337
|
+
const worker = startTranscriptionWorker({
|
|
3338
|
+
vaultList: () => ["default"],
|
|
3339
|
+
getStore: () => store as unknown as Store,
|
|
3340
|
+
scribeUrl: "http://scribe.test",
|
|
3341
|
+
resolveAssetsDir: () => process.env.ASSETS_DIR!,
|
|
3342
|
+
pollIntervalMs: 10_000_000,
|
|
3343
|
+
maxAttempts: 3,
|
|
3344
|
+
fetchImpl,
|
|
3345
|
+
logger: { error: () => {}, info: () => {} },
|
|
3346
|
+
});
|
|
3347
|
+
setTranscriptionWorker(worker);
|
|
3348
|
+
try {
|
|
3349
|
+
await worker.tick();
|
|
3350
|
+
|
|
3351
|
+
const failedNote = await store.getNote("e2e-memo");
|
|
3352
|
+
// Marker replaced the placeholder in place; embed + surrounding body intact.
|
|
3353
|
+
expect(failedNote!.content).toBe(
|
|
3354
|
+
"# 🎙️ Voice memo\n\n_Recorded sometime._\n\n_Transcription unavailable._\n\n![[e2e/voice.webm]]\n",
|
|
3355
|
+
);
|
|
3356
|
+
expect((failedNote!.metadata as any)?.transcribe_stub).toBeUndefined();
|
|
3357
|
+
const failedAtt = await store.getAttachment(att.id);
|
|
3358
|
+
expect(failedAtt?.metadata?.transcribe_status).toBe("failed");
|
|
3359
|
+
|
|
3360
|
+
// --- Phase 2: retry via the legacy route form (POST on the memo note).
|
|
3361
|
+
// Deregister the worker so the retry is "sweep-only" — that lets us
|
|
3362
|
+
// observe the reset + stub re-arm deterministically before the worker
|
|
3363
|
+
// picks the row back up (otherwise the route's fire-and-forget kick
|
|
3364
|
+
// would race our assertions and complete the success in-line).
|
|
3365
|
+
setTranscriptionWorker(null);
|
|
3366
|
+
fetchMode = "succeed";
|
|
3367
|
+
const retryRes = await handleNotes(
|
|
3368
|
+
mkReq("POST", "/notes/e2e-memo/retry-transcription"),
|
|
3369
|
+
store,
|
|
3370
|
+
"/e2e-memo/retry-transcription",
|
|
3371
|
+
"default",
|
|
3372
|
+
);
|
|
3373
|
+
expect(retryRes.status).toBe(202);
|
|
3374
|
+
expect((await retryRes.json() as any).worker).toBe("sweep-only");
|
|
3375
|
+
|
|
3376
|
+
// Attachment back to pending + legacy origin; stub re-armed on the note.
|
|
3377
|
+
const pendingAtt = await store.getAttachment(att.id);
|
|
3378
|
+
expect(pendingAtt?.metadata?.transcribe_status).toBe("pending");
|
|
3379
|
+
expect(pendingAtt?.metadata?.transcribe_origin).toBe("legacy");
|
|
3380
|
+
const rearmed = await store.getNote("e2e-memo");
|
|
3381
|
+
expect((rearmed!.metadata as any)?.transcribe_stub).toBe(true);
|
|
3382
|
+
|
|
3383
|
+
// --- Phase 3: worker succeeds on the retry (sweep tick). Transcript
|
|
3384
|
+
// replaces the _Transcription unavailable._ marker IN PLACE; embed
|
|
3385
|
+
// preserved; final body is byte-identical to a first-try success.
|
|
3386
|
+
setTranscriptionWorker(worker);
|
|
3387
|
+
await worker.tick();
|
|
3388
|
+
const success = await store.getNote("e2e-memo");
|
|
3389
|
+
expect(success!.content).toBe(firstTrySuccessBody);
|
|
3390
|
+
expect(success!.content).toContain("![[e2e/voice.webm]]");
|
|
3391
|
+
expect((success!.metadata as any)?.transcribe_stub).toBeUndefined();
|
|
3392
|
+
const doneAtt = await store.getAttachment(att.id);
|
|
3393
|
+
expect(doneAtt?.metadata?.transcribe_status).toBe("done");
|
|
3394
|
+
expect(doneAtt?.metadata?.transcript).toBe("the spoken words");
|
|
3395
|
+
} finally {
|
|
3396
|
+
await worker.stop();
|
|
3397
|
+
setTranscriptionWorker(null);
|
|
3398
|
+
delete process.env.ASSETS_DIR;
|
|
3399
|
+
}
|
|
3400
|
+
});
|
|
3401
|
+
|
|
3402
|
+
// ---- Optimistic concurrency on the stub re-stamp (vault#435) ----------
|
|
3403
|
+
// The retry endpoint does a read-transform-write on the memo note to
|
|
3404
|
+
// re-arm `transcribe_stub: true`. Without an `if_updated_at` precondition,
|
|
3405
|
+
// a user edit landing between the read (`resolveNote`) and this write is
|
|
3406
|
+
// silently clobbered — the static-write/stale-read class of vault#208.
|
|
3407
|
+
//
|
|
3408
|
+
// We inject a store wrapper that fires a concurrent USER edit immediately
|
|
3409
|
+
// before the route's first OC `updateNote` runs, making its precondition
|
|
3410
|
+
// stale. The route must NOT clobber the user's edit; it must re-read and
|
|
3411
|
+
// re-apply the metadata-only re-stamp against fresh content.
|
|
3412
|
+
|
|
3413
|
+
/**
|
|
3414
|
+
* Wrap a store so the first `N` `updateNote` calls carrying an
|
|
3415
|
+
* `if_updated_at` precondition fire `userEdit()` (a concurrent user write
|
|
3416
|
+
* that bumps `updated_at`) just before delegating — forcing the precondition
|
|
3417
|
+
* stale exactly `interfereTimes` times. Non-OC writes pass through.
|
|
3418
|
+
*
|
|
3419
|
+
* NOTE: duplicated in src/transcription-worker.test.ts (worker-layer race
|
|
3420
|
+
* tests) — keep in sync.
|
|
3421
|
+
*/
|
|
3422
|
+
function withRace(
|
|
3423
|
+
base: Store,
|
|
3424
|
+
interfereTimes: number,
|
|
3425
|
+
userEdit: () => Promise<void>,
|
|
3426
|
+
): Store {
|
|
3427
|
+
let fired = 0;
|
|
3428
|
+
return new Proxy(base, {
|
|
3429
|
+
get(target, prop, receiver) {
|
|
3430
|
+
if (prop === "updateNote") {
|
|
3431
|
+
return async (id: string, updates: any) => {
|
|
3432
|
+
if (updates?.if_updated_at !== undefined && fired < interfereTimes) {
|
|
3433
|
+
fired++;
|
|
3434
|
+
// bun:sqlite stamps `updated_at` at ms granularity. Sleep so
|
|
3435
|
+
// the concurrent user write lands at a strictly-greater
|
|
3436
|
+
// timestamp than the precondition the route captured — making
|
|
3437
|
+
// the conflict deterministic rather than racing inside the
|
|
3438
|
+
// same millisecond.
|
|
3439
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
3440
|
+
await userEdit();
|
|
3441
|
+
}
|
|
3442
|
+
return (target as any).updateNote(id, updates);
|
|
3443
|
+
};
|
|
3444
|
+
}
|
|
3445
|
+
return Reflect.get(target, prop, receiver);
|
|
3446
|
+
},
|
|
3447
|
+
}) as Store;
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
test("OC: single race → user edit survives, stub still re-armed (no clobber)", async () => {
|
|
3451
|
+
const { noteId, attachmentId } = await seedLegacyFailedMemo({ noteId: "race-1" });
|
|
3452
|
+
|
|
3453
|
+
// One interference: the very first OC write conflicts; the route re-reads
|
|
3454
|
+
// and re-applies against the user's new content.
|
|
3455
|
+
const raceStore = withRace(store, 1, async () => {
|
|
3456
|
+
// User appends a line to the body while the retry is in flight.
|
|
3457
|
+
await store.updateNote(noteId, { append: "\n\nMY EDIT WHILE PENDING" });
|
|
3458
|
+
});
|
|
3459
|
+
|
|
3460
|
+
const res = await handleNotes(
|
|
3461
|
+
mkReq("POST", `/notes/${noteId}/retry-transcription`),
|
|
3462
|
+
raceStore,
|
|
3463
|
+
`/${noteId}/retry-transcription`,
|
|
3464
|
+
"default",
|
|
3465
|
+
);
|
|
3466
|
+
// (a) User edit NOT clobbered + (c) re-stamp succeeded on retry → 202.
|
|
3467
|
+
expect(res.status).toBe(202);
|
|
3468
|
+
|
|
3469
|
+
const after = await store.getNote(noteId);
|
|
3470
|
+
// (a) The user's concurrent edit survives.
|
|
3471
|
+
expect(after!.content).toContain("MY EDIT WHILE PENDING");
|
|
3472
|
+
// Original capture body also intact (re-stamp is metadata-only).
|
|
3473
|
+
expect(after!.content).toContain("_Transcription unavailable._");
|
|
3474
|
+
// (c) Stub re-armed despite the race.
|
|
3475
|
+
expect((after!.metadata as any)?.transcribe_stub).toBe(true);
|
|
3476
|
+
|
|
3477
|
+
const att = await store.getAttachment(attachmentId);
|
|
3478
|
+
expect(att?.metadata?.transcribe_status).toBe("pending");
|
|
3479
|
+
expect(att?.metadata?.transcribe_origin).toBe("legacy");
|
|
3480
|
+
|
|
3481
|
+
delete process.env.ASSETS_DIR;
|
|
3482
|
+
});
|
|
3483
|
+
|
|
3484
|
+
test("OC: double race → 409 (user-facing request can retry)", async () => {
|
|
3485
|
+
const { noteId } = await seedLegacyFailedMemo({ noteId: "race-2" });
|
|
3486
|
+
|
|
3487
|
+
// Interfere on BOTH the first write and the retry write → the route
|
|
3488
|
+
// exhausts its single retry and surfaces 409.
|
|
3489
|
+
const raceStore = withRace(store, 2, async () => {
|
|
3490
|
+
await store.updateNote(noteId, { append: " x" });
|
|
3491
|
+
});
|
|
3492
|
+
|
|
3493
|
+
const res = await handleNotes(
|
|
3494
|
+
mkReq("POST", `/notes/${noteId}/retry-transcription`),
|
|
3495
|
+
raceStore,
|
|
3496
|
+
`/${noteId}/retry-transcription`,
|
|
3497
|
+
"default",
|
|
3498
|
+
);
|
|
3499
|
+
// (c) Double-conflict policy for a user-facing endpoint: 409.
|
|
3500
|
+
expect(res.status).toBe(409);
|
|
3501
|
+
const body = await res.json() as any;
|
|
3502
|
+
expect(body.error_type).toBe("conflict");
|
|
3503
|
+
expect(body.note_id).toBe(noteId);
|
|
3504
|
+
|
|
3505
|
+
// The note was never clobbered — the user's two appends are both present.
|
|
3506
|
+
const after = await store.getNote(noteId);
|
|
3507
|
+
expect(after!.content).toContain(" x x");
|
|
3508
|
+
|
|
3509
|
+
delete process.env.ASSETS_DIR;
|
|
3510
|
+
});
|
|
3511
|
+
|
|
3512
|
+
test("OC: happy path unchanged when no race occurs", async () => {
|
|
3513
|
+
// With zero interference the OC write lands first-try, byte-identical to
|
|
3514
|
+
// the pre-#435 behavior — guards against the precondition breaking the
|
|
3515
|
+
// common path.
|
|
3516
|
+
const { noteId } = await seedLegacyFailedMemo({ noteId: "race-0" });
|
|
3517
|
+
const res = await handleNotes(
|
|
3518
|
+
mkReq("POST", `/notes/${noteId}/retry-transcription`),
|
|
3519
|
+
store,
|
|
3520
|
+
`/${noteId}/retry-transcription`,
|
|
3521
|
+
"default",
|
|
3522
|
+
);
|
|
3523
|
+
expect(res.status).toBe(202);
|
|
3524
|
+
const after = await store.getNote(noteId);
|
|
3525
|
+
expect((after!.metadata as any)?.transcribe_stub).toBe(true);
|
|
3526
|
+
delete process.env.ASSETS_DIR;
|
|
3527
|
+
});
|
|
3528
|
+
});
|
|
3529
|
+
});
|
|
3530
|
+
|
|
3531
|
+
// ---------------------------------------------------------------------------
|
|
3532
|
+
// REST tag-scope confidentiality (security review). expand_links must not
|
|
3533
|
+
// inline out-of-scope wikilinked content; include_links must not hydrate
|
|
3534
|
+
// out-of-scope neighbor summaries; unresolved-wikilinks must not surface
|
|
3535
|
+
// out-of-scope source rows. Unscoped path stays fully functional. Each
|
|
3536
|
+
// security assertion MUST fail without the fix.
|
|
3537
|
+
// ---------------------------------------------------------------------------
|
|
3538
|
+
describe("HTTP tag-scope confidentiality (security review)", async () => {
|
|
3539
|
+
// Build a TagScopeCtx the same way routing.ts does, so handlers see the
|
|
3540
|
+
// exact shape a real tag-scoped request produces.
|
|
3541
|
+
async function scopeCtx(roots: string[]): Promise<TagScopeCtx> {
|
|
3542
|
+
return { allowed: await expandTokenTagScope(store, roots), raw: roots };
|
|
3543
|
+
}
|
|
3544
|
+
const NO_SCOPE: TagScopeCtx = { allowed: null, raw: null };
|
|
3545
|
+
|
|
3546
|
+
test("expand_links does NOT inline out-of-scope wikilinked content", async () => {
|
|
3547
|
+
await store.createNote("SECRET PERSONAL BODY", { path: "Secret", tags: ["personal"] });
|
|
3548
|
+
const work = await store.createNote("intro [[Secret]]", { path: "Work", tags: ["work"] });
|
|
3549
|
+
|
|
3550
|
+
const res = await handleNotes(
|
|
3551
|
+
mkReq("GET", `/notes?id=${work.id}&include_content=true&expand_links=true`),
|
|
3552
|
+
store,
|
|
3553
|
+
"",
|
|
3554
|
+
"v",
|
|
3555
|
+
await scopeCtx(["work"]),
|
|
3556
|
+
);
|
|
3557
|
+
const body = await res.json() as any;
|
|
3558
|
+
expect(body.content).not.toContain("SECRET PERSONAL BODY");
|
|
3559
|
+
expect(body.content).toContain("[[Secret]]"); // literal — like not-found
|
|
3560
|
+
});
|
|
3561
|
+
|
|
3562
|
+
test("UNSCOPED expand_links still inlines content (regression)", async () => {
|
|
3563
|
+
await store.createNote("PERSONAL BODY", { path: "Secret", tags: ["personal"] });
|
|
3564
|
+
const work = await store.createNote("intro [[Secret]]", { path: "Work", tags: ["work"] });
|
|
3565
|
+
|
|
3566
|
+
const res = await handleNotes(
|
|
3567
|
+
mkReq("GET", `/notes?id=${work.id}&include_content=true&expand_links=true`),
|
|
3568
|
+
store,
|
|
3569
|
+
"",
|
|
3570
|
+
"v",
|
|
3571
|
+
NO_SCOPE,
|
|
3572
|
+
);
|
|
3573
|
+
const body = await res.json() as any;
|
|
3574
|
+
expect(body.content).toContain("PERSONAL BODY");
|
|
3575
|
+
});
|
|
3576
|
+
|
|
3577
|
+
test("expand_links multi-hop (depth>1) does not leak out-of-scope content", async () => {
|
|
3578
|
+
await store.createNote("DEEP PERSONAL SECRET", { path: "Deep", tags: ["personal"] });
|
|
3579
|
+
await store.createNote("mid [[Deep]]", { path: "Mid", tags: ["work"] });
|
|
3580
|
+
const top = await store.createNote("top [[Mid]]", { path: "Top", tags: ["work"] });
|
|
3581
|
+
|
|
3582
|
+
const res = await handleNotes(
|
|
3583
|
+
mkReq("GET", `/notes?id=${top.id}&include_content=true&expand_links=true&expand_depth=3`),
|
|
3584
|
+
store,
|
|
3585
|
+
"",
|
|
3586
|
+
"v",
|
|
3587
|
+
await scopeCtx(["work"]),
|
|
3588
|
+
);
|
|
3589
|
+
const body = await res.json() as any;
|
|
3590
|
+
expect(body.content).toContain("mid");
|
|
3591
|
+
expect(body.content).not.toContain("DEEP PERSONAL SECRET");
|
|
3592
|
+
});
|
|
3593
|
+
|
|
3594
|
+
test("include_links strips out-of-scope NEIGHBOR summaries", async () => {
|
|
3595
|
+
const secret = await store.createNote("secret", { path: "Secret", tags: ["personal"] });
|
|
3596
|
+
const work = await store.createNote("work", { path: "Work", tags: ["work"] });
|
|
3597
|
+
await store.createLink(work.id, secret.id, "references");
|
|
3598
|
+
|
|
3599
|
+
const res = await handleNotes(
|
|
3600
|
+
mkReq("GET", `/notes?id=${work.id}&include_links=true`),
|
|
3601
|
+
store,
|
|
3602
|
+
"",
|
|
3603
|
+
"v",
|
|
3604
|
+
await scopeCtx(["work"]),
|
|
3605
|
+
);
|
|
3606
|
+
const body = await res.json() as any;
|
|
3607
|
+
const serialized = JSON.stringify(body.links ?? []);
|
|
3608
|
+
expect(serialized).not.toContain(secret.id);
|
|
3609
|
+
expect(serialized).not.toContain("Secret");
|
|
3610
|
+
});
|
|
3611
|
+
|
|
3612
|
+
test("UNSCOPED include_links hydrates the full neighbor (regression)", async () => {
|
|
3613
|
+
const secret = await store.createNote("secret", { path: "Secret", tags: ["personal"] });
|
|
3614
|
+
const work = await store.createNote("work", { path: "Work", tags: ["work"] });
|
|
3615
|
+
await store.createLink(work.id, secret.id, "references");
|
|
3616
|
+
|
|
3617
|
+
const res = await handleNotes(
|
|
3618
|
+
mkReq("GET", `/notes?id=${work.id}&include_links=true`),
|
|
3619
|
+
store,
|
|
3620
|
+
"",
|
|
3621
|
+
"v",
|
|
3622
|
+
NO_SCOPE,
|
|
3623
|
+
);
|
|
3624
|
+
const body = await res.json() as any;
|
|
3625
|
+
expect((body.links ?? []).length).toBe(1);
|
|
3626
|
+
expect(JSON.stringify(body.links)).toContain(secret.id);
|
|
3627
|
+
});
|
|
3628
|
+
|
|
3629
|
+
test("unresolved-wikilinks surfaces only in-scope source rows", async () => {
|
|
3630
|
+
// #personal source with a dangling wikilink → out-of-scope row.
|
|
3631
|
+
await store.createNote("p [[NoSuchPersonal]]", { path: "P", tags: ["personal"] });
|
|
3632
|
+
// #work source with a dangling wikilink → in-scope row.
|
|
3633
|
+
await store.createNote("w [[NoSuchWork]]", { path: "W", tags: ["work"] });
|
|
3634
|
+
|
|
3635
|
+
const res = handleUnresolvedWikilinks(
|
|
3636
|
+
mkReq("GET", "/unresolved-wikilinks"),
|
|
3637
|
+
store,
|
|
3638
|
+
await scopeCtx(["work"]),
|
|
3639
|
+
);
|
|
3640
|
+
const body = await res.json() as any;
|
|
3641
|
+
const targets = (body.unresolved as any[]).map((r) => r.target_path);
|
|
3642
|
+
expect(targets).toContain("NoSuchWork");
|
|
3643
|
+
expect(targets).not.toContain("NoSuchPersonal");
|
|
3644
|
+
expect(body.count).toBe(1);
|
|
3645
|
+
});
|
|
3646
|
+
|
|
3647
|
+
test("UNSCOPED unresolved-wikilinks surfaces every row (regression)", async () => {
|
|
3648
|
+
await store.createNote("p [[NoSuchPersonal]]", { path: "P", tags: ["personal"] });
|
|
3649
|
+
await store.createNote("w [[NoSuchWork]]", { path: "W", tags: ["work"] });
|
|
3650
|
+
|
|
3651
|
+
const res = handleUnresolvedWikilinks(
|
|
3652
|
+
mkReq("GET", "/unresolved-wikilinks"),
|
|
3653
|
+
store,
|
|
3654
|
+
NO_SCOPE,
|
|
3655
|
+
);
|
|
3656
|
+
const body = await res.json() as any;
|
|
3657
|
+
const targets = (body.unresolved as any[]).map((r) => r.target_path);
|
|
3658
|
+
expect(targets).toContain("NoSuchWork");
|
|
3659
|
+
expect(targets).toContain("NoSuchPersonal");
|
|
3660
|
+
});
|
|
3661
|
+
|
|
3662
|
+
});
|
|
3663
|
+
|
|
3664
|
+
describe("HTTP /notes include_link_count + order_by=link_count (vault feedback #4)", async () => {
|
|
3665
|
+
// Mirrors the MCP-surface tests in core/src/link-count.test.ts on the
|
|
3666
|
+
// same fixtures so REST and MCP agree on the degree semantics.
|
|
3667
|
+
async function seed() {
|
|
3668
|
+
await store.createNote("Hub", { id: "hub", path: "hub", tags: ["t"] });
|
|
3669
|
+
await store.createNote("Leaf", { id: "leaf", path: "leaf", tags: ["t"] });
|
|
3670
|
+
await store.createNote("Self", { id: "self", path: "self", tags: ["t"] });
|
|
3671
|
+
await store.createLink("hub", "leaf", "a"); // hub out 1, leaf in 1
|
|
3672
|
+
await store.createLink("leaf", "hub", "b"); // hub in 1, leaf out 1 => both degree 2
|
|
3673
|
+
await store.createLink("self", "self", "loop"); // self-loop => degree 2
|
|
3674
|
+
}
|
|
3675
|
+
|
|
3676
|
+
test("list mode: include_link_count injects linkCount (both directions)", async () => {
|
|
3677
|
+
await seed();
|
|
3678
|
+
const res = await handleNotes(mkReq("GET", "/notes?include_link_count=true"), store, "");
|
|
3679
|
+
const body = (await res.json()) as any[];
|
|
3680
|
+
const byId = Object.fromEntries(body.map((n) => [n.id, n]));
|
|
3681
|
+
expect(byId.hub.linkCount).toBe(2);
|
|
3682
|
+
expect(byId.leaf.linkCount).toBe(2);
|
|
3683
|
+
expect(byId.self.linkCount).toBe(2); // self-loop = 2
|
|
3684
|
+
});
|
|
3685
|
+
|
|
3686
|
+
test("absent flag → no linkCount key (no behavior change)", async () => {
|
|
3687
|
+
await seed();
|
|
3688
|
+
const res = await handleNotes(mkReq("GET", "/notes"), store, "");
|
|
3689
|
+
const body = (await res.json()) as any[];
|
|
3690
|
+
expect(body.every((n) => !("linkCount" in n))).toBe(true);
|
|
3691
|
+
});
|
|
3692
|
+
|
|
3693
|
+
test("note with 0 links → linkCount: 0", async () => {
|
|
3694
|
+
await store.createNote("Lonely", { id: "lonely", path: "lonely" });
|
|
3695
|
+
const res = await handleNotes(mkReq("GET", "/notes?include_link_count=true"), store, "");
|
|
3696
|
+
const body = (await res.json()) as any[];
|
|
3697
|
+
expect(body.find((n) => n.id === "lonely").linkCount).toBe(0);
|
|
3698
|
+
});
|
|
3699
|
+
|
|
3700
|
+
test("single-note (?id=) mode: include_link_count → correct degree", async () => {
|
|
3701
|
+
await seed();
|
|
3702
|
+
const res = await handleNotes(mkReq("GET", "/notes?id=self&include_link_count=true"), store, "");
|
|
3703
|
+
const body = (await res.json()) as any;
|
|
3704
|
+
expect(body.linkCount).toBe(2);
|
|
3705
|
+
});
|
|
3706
|
+
|
|
3707
|
+
test("single-note (/notes/:id) mode: include_link_count → correct degree", async () => {
|
|
3708
|
+
await seed();
|
|
3709
|
+
const res = await handleNotes(mkReq("GET", "/notes/self?include_link_count=true"), store, "/self");
|
|
3710
|
+
const body = (await res.json()) as any;
|
|
3711
|
+
expect(body.linkCount).toBe(2);
|
|
3712
|
+
});
|
|
3713
|
+
|
|
3714
|
+
test("link_count_direction outbound / inbound variants", async () => {
|
|
3715
|
+
await seed();
|
|
3716
|
+
const out = await handleNotes(
|
|
3717
|
+
mkReq("GET", "/notes?id=hub&include_link_count=true&link_count_direction=outbound"),
|
|
3718
|
+
store,
|
|
3719
|
+
"",
|
|
3720
|
+
);
|
|
3721
|
+
expect(((await out.json()) as any).linkCount).toBe(1); // hub→leaf
|
|
3722
|
+
const inb = await handleNotes(
|
|
3723
|
+
mkReq("GET", "/notes?id=hub&include_link_count=true&link_count_direction=inbound"),
|
|
3724
|
+
store,
|
|
3725
|
+
"",
|
|
3726
|
+
);
|
|
3727
|
+
expect(((await inb.json()) as any).linkCount).toBe(1); // leaf→hub
|
|
3728
|
+
});
|
|
3729
|
+
|
|
3730
|
+
test("unrecognized link_count_direction falls back to both (REST parseLinkCountDirection)", async () => {
|
|
3731
|
+
await seed();
|
|
3732
|
+
// hub: both=2, outbound=1, inbound=1. A bogus value must degrade to
|
|
3733
|
+
// `both` (2), distinct from either directional value (1).
|
|
3734
|
+
const res = await handleNotes(
|
|
3735
|
+
mkReq("GET", "/notes?id=hub&include_link_count=true&link_count_direction=sideways"),
|
|
3736
|
+
store,
|
|
3737
|
+
"",
|
|
3738
|
+
);
|
|
3739
|
+
expect(((await res.json()) as any).linkCount).toBe(2);
|
|
3740
|
+
});
|
|
3741
|
+
|
|
3742
|
+
test("FTS branch: search + include_link_count → results carry linkCount", async () => {
|
|
3743
|
+
// The full-text-search branch is a separate return path from the
|
|
3744
|
+
// structured query; exercise the flag there explicitly.
|
|
3745
|
+
await store.createNote("quokka sighting near the hub", { id: "fts-hub", path: "fts-hub" });
|
|
3746
|
+
await store.createNote("a quokka friend", { id: "fts-friend", path: "fts-friend" });
|
|
3747
|
+
await store.createLink("fts-hub", "fts-friend", "a"); // hub out1, friend in1
|
|
3748
|
+
await store.createLink("fts-friend", "fts-hub", "b"); // hub in1 => hub degree 2
|
|
3749
|
+
const res = await handleNotes(
|
|
3750
|
+
mkReq("GET", "/notes?search=quokka&include_link_count=true"),
|
|
3751
|
+
store,
|
|
3752
|
+
"",
|
|
3753
|
+
);
|
|
3754
|
+
const body = (await res.json()) as any[];
|
|
3755
|
+
const byId = Object.fromEntries(body.map((n) => [n.id, n]));
|
|
3756
|
+
expect(byId["fts-hub"].linkCount).toBe(2);
|
|
3757
|
+
expect(byId["fts-friend"].linkCount).toBe(2);
|
|
3758
|
+
});
|
|
3759
|
+
|
|
3760
|
+
test("FTS branch: absent flag → no linkCount key", async () => {
|
|
3761
|
+
await store.createNote("quokka sighting near the hub", { id: "fts-hub", path: "fts-hub" });
|
|
3762
|
+
await store.createLink("fts-hub", "fts-hub", "loop");
|
|
3763
|
+
const res = await handleNotes(mkReq("GET", "/notes?search=quokka"), store, "");
|
|
3764
|
+
const body = (await res.json()) as any[];
|
|
3765
|
+
expect(body.every((n) => !("linkCount" in n))).toBe(true);
|
|
3766
|
+
});
|
|
3767
|
+
|
|
3768
|
+
test("order_by=link_count desc: field value == sort key for every note", async () => {
|
|
3769
|
+
// Distinct degrees so the ordering is unambiguous: big=3, mid=2, small=0.
|
|
3770
|
+
await store.createNote("Big", { id: "big", path: "big" });
|
|
3771
|
+
await store.createNote("Mid", { id: "mid", path: "mid" });
|
|
3772
|
+
await store.createNote("Small", { id: "small", path: "small" });
|
|
3773
|
+
await store.createLink("big", "mid", "a"); // big out1, mid in1
|
|
3774
|
+
await store.createLink("big", "small", "b"); // big out2, small in1
|
|
3775
|
+
await store.createLink("mid", "big", "c"); // big in1 => big degree 3; mid out1 => mid degree 2
|
|
3776
|
+
// small degree 1 (in from big). Adjust: make small degree 0 by removing
|
|
3777
|
+
// — instead assert monotonic + field==sortkey, which is the real invariant.
|
|
3778
|
+
|
|
3779
|
+
const res = await handleNotes(
|
|
3780
|
+
mkReq("GET", "/notes?order_by=link_count&sort=desc&include_link_count=true"),
|
|
3781
|
+
store,
|
|
3782
|
+
"",
|
|
3783
|
+
);
|
|
3784
|
+
const body = (await res.json()) as any[];
|
|
3785
|
+
const seq = body.map((n) => n.linkCount as number);
|
|
3786
|
+
// The injected field equals the sort key, so the sequence is non-increasing.
|
|
3787
|
+
expect(seq).toEqual([...seq].sort((a, b) => b - a));
|
|
3788
|
+
expect(body[0].id).toBe("big"); // degree 3 — the most-connected note
|
|
3789
|
+
expect(body[0].linkCount).toBe(3);
|
|
3790
|
+
});
|
|
3791
|
+
|
|
3792
|
+
test("order_by=link_count: self-loop note ranks by its degree-2 field value", async () => {
|
|
3793
|
+
await store.createNote("Selfy", { id: "selfy", path: "selfy" });
|
|
3794
|
+
await store.createNote("Plain", { id: "plain", path: "plain" });
|
|
3795
|
+
await store.createNote("Zero", { id: "zero", path: "zero" });
|
|
3796
|
+
await store.createLink("selfy", "selfy", "loop"); // degree 2
|
|
3797
|
+
await store.createLink("zero", "plain", "ref"); // plain in1, zero out1
|
|
3798
|
+
|
|
3799
|
+
const res = await handleNotes(
|
|
3800
|
+
mkReq("GET", "/notes?order_by=link_count&sort=desc&include_link_count=true"),
|
|
3801
|
+
store,
|
|
3802
|
+
"",
|
|
3803
|
+
);
|
|
3804
|
+
const body = (await res.json()) as any[];
|
|
3805
|
+
expect(body[0].id).toBe("selfy"); // degree 2 outranks the degree-1 notes
|
|
3806
|
+
expect(body[0].linkCount).toBe(2); // field == the sort key that put it first
|
|
3807
|
+
const byId = Object.fromEntries(body.map((n) => [n.id, n]));
|
|
3808
|
+
expect(byId.plain.linkCount).toBe(1);
|
|
3809
|
+
expect(byId.zero.linkCount).toBe(1);
|
|
2900
3810
|
});
|
|
2901
3811
|
});
|
|
2902
3812
|
|
|
@@ -3026,6 +3936,78 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
|
|
|
3026
3936
|
expect(await store.getLinks("a", { direction: "outbound" })).toHaveLength(0);
|
|
3027
3937
|
});
|
|
3028
3938
|
|
|
3939
|
+
// vault feedback #8 — the update response now echoes hydrated links when
|
|
3940
|
+
// the request mutated links OR `?include_links=true` is passed, so callers
|
|
3941
|
+
// no longer have to re-GET to confirm a link they just added/removed.
|
|
3942
|
+
test("PATCH links.add echoes hydrated links on the response", async () => {
|
|
3943
|
+
await store.createNote("a", { id: "a" });
|
|
3944
|
+
await store.createNote("b", { id: "b", path: "People/Bob", tags: ["person"] });
|
|
3945
|
+
const res = await handleNotes(
|
|
3946
|
+
mkReq("PATCH", "/notes/a", { links: { add: [{ target: "b", relationship: "mentions" }] }, force: true }),
|
|
3947
|
+
store,
|
|
3948
|
+
"/a",
|
|
3949
|
+
);
|
|
3950
|
+
expect(res.status).toBe(200);
|
|
3951
|
+
const body = await res.json() as any;
|
|
3952
|
+
expect(Array.isArray(body.links)).toBe(true);
|
|
3953
|
+
expect(body.links).toHaveLength(1);
|
|
3954
|
+
const link = body.links[0];
|
|
3955
|
+
expect(link.sourceId).toBe("a");
|
|
3956
|
+
expect(link.targetId).toBe("b");
|
|
3957
|
+
expect(link.relationship).toBe("mentions");
|
|
3958
|
+
// Hydrated shape matches GET / query-notes: targetNote summary present.
|
|
3959
|
+
expect(link.targetNote.id).toBe("b");
|
|
3960
|
+
expect(link.targetNote.path).toBe("People/Bob");
|
|
3961
|
+
expect(link.targetNote.tags).toEqual(["person"]);
|
|
3962
|
+
});
|
|
3963
|
+
|
|
3964
|
+
test("PATCH links.remove echoes the post-removal link set", async () => {
|
|
3965
|
+
await store.createNote("a", { id: "a" });
|
|
3966
|
+
await store.createNote("b", { id: "b" });
|
|
3967
|
+
await store.createNote("c", { id: "c" });
|
|
3968
|
+
await store.createLink("a", "b", "mentions");
|
|
3969
|
+
await store.createLink("a", "c", "mentions");
|
|
3970
|
+
const res = await handleNotes(
|
|
3971
|
+
mkReq("PATCH", "/notes/a", { links: { remove: [{ target: "b", relationship: "mentions" }] }, force: true }),
|
|
3972
|
+
store,
|
|
3973
|
+
"/a",
|
|
3974
|
+
);
|
|
3975
|
+
const body = await res.json() as any;
|
|
3976
|
+
expect(Array.isArray(body.links)).toBe(true);
|
|
3977
|
+
expect(body.links).toHaveLength(1);
|
|
3978
|
+
expect(body.links[0].targetId).toBe("c");
|
|
3979
|
+
});
|
|
3980
|
+
|
|
3981
|
+
test("PATCH without a link mutation or flag does NOT include links", async () => {
|
|
3982
|
+
await store.createNote("a", { id: "a" });
|
|
3983
|
+
await store.createNote("b", { id: "b" });
|
|
3984
|
+
await store.createLink("a", "b", "mentions");
|
|
3985
|
+
const res = await handleNotes(
|
|
3986
|
+
mkReq("PATCH", "/notes/a", { content: "updated", force: true }),
|
|
3987
|
+
store,
|
|
3988
|
+
"/a",
|
|
3989
|
+
);
|
|
3990
|
+
const body = await res.json() as any;
|
|
3991
|
+
expect(body.content).toBe("updated");
|
|
3992
|
+
expect(body).not.toHaveProperty("links");
|
|
3993
|
+
});
|
|
3994
|
+
|
|
3995
|
+
test("PATCH ?include_links=true echoes current links even without a mutation", async () => {
|
|
3996
|
+
await store.createNote("a", { id: "a" });
|
|
3997
|
+
await store.createNote("b", { id: "b" });
|
|
3998
|
+
await store.createLink("a", "b", "mentions");
|
|
3999
|
+
const res = await handleNotes(
|
|
4000
|
+
mkReq("PATCH", "/notes/a?include_links=true", { content: "updated", force: true }),
|
|
4001
|
+
store,
|
|
4002
|
+
"/a",
|
|
4003
|
+
);
|
|
4004
|
+
const body = await res.json() as any;
|
|
4005
|
+
expect(body.content).toBe("updated");
|
|
4006
|
+
expect(Array.isArray(body.links)).toBe(true);
|
|
4007
|
+
expect(body.links).toHaveLength(1);
|
|
4008
|
+
expect(body.links[0].targetId).toBe("b");
|
|
4009
|
+
});
|
|
4010
|
+
|
|
3029
4011
|
test("PATCH resolves note by path", async () => {
|
|
3030
4012
|
await store.createNote("x", { path: "Projects/README" });
|
|
3031
4013
|
const res = await handleNotes(
|
|
@@ -3702,11 +4684,86 @@ describe("HTTP /tags", async () => {
|
|
|
3702
4684
|
expect(buildVaultProjection(db).indexed_fields.map((f) => f.name)).toContain("aspect_ratio");
|
|
3703
4685
|
});
|
|
3704
4686
|
|
|
3705
|
-
|
|
4687
|
+
// ---- relationships is an opaque vocabulary map (vault#428) ----
|
|
4688
|
+
// PUT persists the value verbatim with no inner-shape enforcement; GET
|
|
4689
|
+
// returns it byte-for-byte. Only a top-level non-map (array/primitive)
|
|
4690
|
+
// or non-serializable input is rejected with invalid_relationships.
|
|
4691
|
+
|
|
4692
|
+
test("PUT /tags/:name persists the opaque vocabulary map; GET returns it verbatim (vault#428)", async () => {
|
|
4693
|
+
const vocab = {
|
|
4694
|
+
"works-on": { from: "person", to: "project" },
|
|
4695
|
+
"member-of": { from: "person", to: "organization" },
|
|
4696
|
+
"partner-of": { from: "person", to: "person" },
|
|
4697
|
+
"based-at": { from: "project", to: "place" },
|
|
4698
|
+
};
|
|
4699
|
+
const put = await handleTags(
|
|
4700
|
+
mkReq("PUT", "/tags/person", { relationships: vocab }),
|
|
4701
|
+
store,
|
|
4702
|
+
"/person",
|
|
4703
|
+
);
|
|
4704
|
+
expect(put.status).toBe(200);
|
|
4705
|
+
|
|
4706
|
+
const get = await handleTags(mkReq("GET", "/tags/person"), store, "/person");
|
|
4707
|
+
expect(get.status).toBe(200);
|
|
4708
|
+
const body = await get.json() as any;
|
|
4709
|
+
// Byte-for-byte: serialize both sides and compare exactly.
|
|
4710
|
+
expect(JSON.stringify(body.relationships)).toBe(JSON.stringify(vocab));
|
|
4711
|
+
expect(body.relationships).toEqual(vocab);
|
|
4712
|
+
});
|
|
4713
|
+
|
|
4714
|
+
test("PUT /tags/:name still accepts the historical typed relationships shape (backwards-compat)", async () => {
|
|
4715
|
+
const typed = { owned_by: { target_tag: "person", cardinality: "one", description: "DRI" } };
|
|
4716
|
+
const put = await handleTags(
|
|
4717
|
+
mkReq("PUT", "/tags/project", { relationships: typed }),
|
|
4718
|
+
store,
|
|
4719
|
+
"/project",
|
|
4720
|
+
);
|
|
4721
|
+
expect(put.status).toBe(200);
|
|
4722
|
+
const get = await handleTags(mkReq("GET", "/tags/project"), store, "/project");
|
|
4723
|
+
const body = await get.json() as any;
|
|
4724
|
+
expect(body.relationships).toEqual(typed);
|
|
4725
|
+
});
|
|
4726
|
+
|
|
4727
|
+
test("PUT /tags/:name round-trips nested arbitrary relationship values verbatim", async () => {
|
|
4728
|
+
const vocab = { rel: { from: "a", to: "b", note: "freeform", weight: 3, tags: ["x", "y"] } };
|
|
4729
|
+
const put = await handleTags(
|
|
4730
|
+
mkReq("PUT", "/tags/thing", { relationships: vocab }),
|
|
4731
|
+
store,
|
|
4732
|
+
"/thing",
|
|
4733
|
+
);
|
|
4734
|
+
expect(put.status).toBe(200);
|
|
4735
|
+
const get = await handleTags(mkReq("GET", "/tags/thing"), store, "/thing");
|
|
4736
|
+
const body = await get.json() as any;
|
|
4737
|
+
expect(body.relationships).toEqual(vocab);
|
|
4738
|
+
});
|
|
4739
|
+
|
|
4740
|
+
test("PUT /tags/:name returns 400 invalid_relationships for a top-level array", async () => {
|
|
3706
4741
|
const res = await handleTags(
|
|
3707
|
-
mkReq("PUT", "/tags/person", {
|
|
3708
|
-
|
|
3709
|
-
|
|
4742
|
+
mkReq("PUT", "/tags/person", { relationships: ["not", "a", "map"] }),
|
|
4743
|
+
store,
|
|
4744
|
+
"/person",
|
|
4745
|
+
);
|
|
4746
|
+
expect(res.status).toBe(400);
|
|
4747
|
+
const body = await res.json() as any;
|
|
4748
|
+
expect(body.error_type).toBe("invalid_relationships");
|
|
4749
|
+
expect(typeof body.error).toBe("string");
|
|
4750
|
+
expect(body.error.length).toBeGreaterThan(0);
|
|
4751
|
+
});
|
|
4752
|
+
|
|
4753
|
+
test("PUT /tags/:name returns 400 invalid_relationships for a top-level primitive", async () => {
|
|
4754
|
+
const res = await handleTags(
|
|
4755
|
+
mkReq("PUT", "/tags/person", { relationships: "just-a-string" as unknown as Record<string, unknown> }),
|
|
4756
|
+
store,
|
|
4757
|
+
"/person",
|
|
4758
|
+
);
|
|
4759
|
+
expect(res.status).toBe(400);
|
|
4760
|
+
const body = await res.json() as any;
|
|
4761
|
+
expect(body.error_type).toBe("invalid_relationships");
|
|
4762
|
+
});
|
|
4763
|
+
|
|
4764
|
+
test("PUT /tags/:name returns 400 invalid_relationships for an empty relationship key", async () => {
|
|
4765
|
+
const res = await handleTags(
|
|
4766
|
+
mkReq("PUT", "/tags/person", { relationships: { "": { from: "a", to: "b" } } }),
|
|
3710
4767
|
store,
|
|
3711
4768
|
"/person",
|
|
3712
4769
|
);
|