Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions apps/roam/src/utils/__tests__/conceptConversion.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DiscourseNode } from "~/utils/getDiscourseNodes";

const { mockedGetPageUidByPageTitle, mockedGetDiscourseNodes } = vi.hoisted(
() => ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
mockedGetPageUidByPageTitle: vi.fn((_title: string) => ""),
mockedGetDiscourseNodes: vi.fn((): DiscourseNode[] => []),
}),
);
import type { ImportedSourceIdentity } from "~/utils/importedSourceIdentity";

const {
mockedGetPageUidByPageTitle,
mockedGetDiscourseNodes,
mockedReadImportedSourceIdentity,
} = vi.hoisted(() => ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
mockedGetPageUidByPageTitle: vi.fn((_title: string) => ""),
mockedGetDiscourseNodes: vi.fn((): DiscourseNode[] => []),
mockedReadImportedSourceIdentity: vi.fn(
(): ImportedSourceIdentity | undefined => undefined,
),
}));
vi.mock("roamjs-components/queries/getPageUidByPageTitle", () => ({
default: mockedGetPageUidByPageTitle,
}));
vi.mock("~/utils/importedSourceIdentity", () => ({
readImportedSourceIdentity: mockedReadImportedSourceIdentity,
}));
vi.mock("~/utils/getDiscourseNodes", () => ({
default: mockedGetDiscourseNodes,
}));
Expand Down Expand Up @@ -65,6 +74,7 @@ beforeEach(() => {
(title: string) => PAGE_UIDS[title] ?? "",
);
mockedGetDiscourseNodes.mockReturnValue([SOURCE_TYPE]);
mockedReadImportedSourceIdentity.mockReset();
});

describe("discourseNodeSchemaToLocalConcept source slot", () => {
Expand Down Expand Up @@ -159,6 +169,20 @@ describe("discourseNodeBlockToLocalConcept source slot", () => {
});
});

it("writes the origin RID when the source page was imported from another app", () => {
mockedReadImportedSourceIdentity.mockReturnValue({
sourceModifiedAt: "2026-06-14T15:00:00.000Z",
sourceNodeRid: "orn:obsidian.note:vault-a/node-1",
});
const concept = convert(
"[[EVD]] - REM sleep aids recall - [[@sun2019direct]]",
);
expect(concept.local_reference_content).toEqual({
sourceDocument: "orn:obsidian.note:vault-a/node-1",
});
expect(mockedReadImportedSourceIdentity).toHaveBeenCalledWith("source-1");
});

// Leniency on the target type: see sourceSlot.ts
it("accepts a source that is a node of another type", () => {
mockedGetDiscourseNodes.mockReturnValue([
Expand Down
159 changes: 155 additions & 4 deletions apps/roam/src/utils/__tests__/publishNodesToGroups.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CrossAppNode } from "@repo/database/crossAppContracts";
import type { DGSupabaseClient } from "@repo/database/lib/client";
import type { DiscourseNode } from "~/utils/getDiscourseNodes";
Expand Down Expand Up @@ -31,6 +31,10 @@ vi.mock("~/utils/importedSourceIdentity", () => ({
readImportedSourceIdentity: () => undefined,
}));

vi.mock("roamjs-components/queries/getPageTitleByPageUid", () => ({
default: (uid: string) => (uid === SOURCE_UID ? SOURCE_TITLE : ""),
}));

vi.mock("~/utils/roamToCrossAppConverters", () => ({
nodeUidsWithTypeToCrossApp: vi.fn(),
nodeSchemaToCrossApp: (s: DiscourseNode) => ({
Expand Down Expand Up @@ -59,6 +63,9 @@ import { publishNodesToGroups } from "~/utils/publishNodesToGroups";
const SPACE_ID = 42;
const GROUP_ID = "group-1";
const SCHEMA_UID = "schema-1";
const SOURCE_UID = "source-1";
const SOURCE_TITLE = "@sun2019direct";
const SOURCE_RID = "orn:obsidian.note:vault-a/node-1";

const claimSchema: DiscourseNode = {
type: SCHEMA_UID,
Expand All @@ -74,10 +81,12 @@ const makeCrossAppNode = ({
uid,
title,
coreTitle = title,
slots,
}: {
uid: string;
title: string;
coreTitle?: string;
slots?: CrossAppNode["slots"];
}): CrossAppNode => ({
localId: uid,
nodeType: SCHEMA_UID,
Expand All @@ -94,6 +103,7 @@ const makeCrossAppNode = ({
scale: "document",
},
},
...(slots ? { slots } : {}),
});

type RpcArgs = { v_space_id: number; data: Record<string, unknown>[] };
Expand All @@ -106,7 +116,7 @@ type SelectResponse = {
type FakeSelectBuilder = PromiseLike<SelectResponse> & {
url: { search: string };
eq: () => FakeSelectBuilder;
in: () => FakeSelectBuilder;
in: (column: string, values: string[]) => FakeSelectBuilder;
order: (column: string) => FakeSelectBuilder;
range: () => Promise<SelectResponse>;
};
Expand All @@ -119,6 +129,7 @@ const makeFakeClient = ({
rpcResponse?: { data: number[] | null; error: { message: string } | null };
}) => {
const rpcCalls: { fn: string; args: RpcArgs }[] = [];
const conceptLookups: string[][] = [];
const upsertCalls: {
table: string;
rows: Record<string, unknown>[];
Expand All @@ -136,7 +147,10 @@ const makeFakeClient = ({
const builder: FakeSelectBuilder = {
url: { search: "" },
eq: () => builder,
in: () => builder,
in: (_column, values) => {
if (table === "my_concepts") conceptLookups.push(values);
return builder;
},
order: (column) => {
builder.url.search += `&order=${column}`;
return builder;
Expand Down Expand Up @@ -165,7 +179,7 @@ const makeFakeClient = ({
);
},
} as unknown as DGSupabaseClient;
return { client, rpcCalls, upsertCalls };
return { client, rpcCalls, conceptLookups, upsertCalls };
};

describe("publishNodesToGroups", () => {
Expand Down Expand Up @@ -350,4 +364,141 @@ describe("publishNodesToGroups", () => {
expect(result.publishedNodeSchemaUids).toEqual([]);
expect(upsertCalls[0].rows).toEqual([]);
});

describe("source slot", () => {
const evidenceNode = (sourceId: string) =>
makeCrossAppNode({
uid: "node-1",
title: `[[EVD]] - finding - [[${SOURCE_TITLE}]]`,
slots: { sourceDocument: sourceId },
});
const publish = (
client: DGSupabaseClient,
nodes: CrossAppNode[] = [evidenceNode(SOURCE_UID)],
) =>
publishNodesToGroups({
client,
spaceId: SPACE_ID,
groupIds: [GROUP_ID],
nodes,
});

beforeEach(() => {
vi.spyOn(console, "warn").mockImplementation(() => {});
});

afterEach(() => {
vi.restoreAllMocks();
});

it("keeps the source slot when the source is already a concept in the space", async () => {
const { client, rpcCalls, conceptLookups } = makeFakeClient({
syncedUids: [SCHEMA_UID, SOURCE_UID],
});

await publish(client);

expect(conceptLookups[0]).toContain(SOURCE_UID);
expect(rpcCalls[0].args.data).toHaveLength(1);
expect(rpcCalls[0].args.data[0]).toMatchObject({
source_local_id: "node-1",
local_reference_content: { sourceDocument: SOURCE_UID },
});
expect(console.warn).not.toHaveBeenCalled();
});

it("looks a source up once however many nodes reference it", async () => {
const { client, conceptLookups } = makeFakeClient({
syncedUids: [SCHEMA_UID, SOURCE_UID],
});

await publish(client, [
evidenceNode(SOURCE_UID),
makeCrossAppNode({
uid: "node-2",
title: `[[EVD]] - another finding - [[${SOURCE_TITLE}]]`,
slots: { sourceDocument: SOURCE_UID },
}),
]);

expect(conceptLookups[0].filter((id) => id === SOURCE_UID)).toHaveLength(
1,
);
});

it("omits the source slot and warns when the source is not a concept in the space", async () => {
const { client, rpcCalls, upsertCalls } = makeFakeClient({
syncedUids: [SCHEMA_UID],
});

const result = await publish(client);

expect(rpcCalls[0].args.data).toHaveLength(1);
expect(rpcCalls[0].args.data[0]).toMatchObject({
source_local_id: "node-1",
});
expect(rpcCalls[0].args.data[0].local_reference_content).toBeUndefined();
expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining(`"${SOURCE_TITLE}" (${SOURCE_UID})`),
);
expect(result.publishedNodeUids).toEqual(["node-1"]);
expect(result.failedUpsertUids).toEqual([]);
expect(upsertCalls[0].rows.map((r) => r.source_local_id)).toContain(
"node-1",
);
});

it("upserts a source published in the same batch before the node referencing it", async () => {
const { client, rpcCalls } = makeFakeClient({
syncedUids: [SCHEMA_UID],
});

await publish(client, [
evidenceNode(SOURCE_UID),
makeCrossAppNode({ uid: SOURCE_UID, title: SOURCE_TITLE }),
]);

const { data } = rpcCalls[0].args;
expect(data.map((row) => row.source_local_id)).toEqual([
SOURCE_UID,
"node-1",
]);
expect(data[1].local_reference_content).toEqual({
sourceDocument: SOURCE_UID,
});
expect(console.warn).not.toHaveBeenCalled();
});

it("passes an imported source's RID through without looking it up in the space", async () => {
const { client, rpcCalls, conceptLookups } = makeFakeClient({
syncedUids: [SCHEMA_UID],
});

await publish(client, [evidenceNode(SOURCE_RID)]);

expect(conceptLookups[0]).not.toContain(SOURCE_RID);
expect(rpcCalls[0].args.data[0].local_reference_content).toEqual({
sourceDocument: SOURCE_RID,
});
expect(console.warn).not.toHaveBeenCalled();
});

it("writes the same sourceDocument value on repeated publishes", async () => {
const { client, rpcCalls } = makeFakeClient({
syncedUids: [SCHEMA_UID, SOURCE_UID],
});

await publish(client);
await publish(client);

expect(rpcCalls).toHaveLength(2);
expect(rpcCalls[1].args.data[0].local_reference_content).toEqual({
sourceDocument: SOURCE_UID,
});
expect(rpcCalls[1].args.data[0].local_reference_content).toEqual(
rpcCalls[0].args.data[0].local_reference_content,
);
});
});
});
60 changes: 56 additions & 4 deletions apps/roam/src/utils/__tests__/roamToCrossAppConverters.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Json } from "@repo/database/dbTypes";
import defaultDiscourseNodes from "~/data/defaultDiscourseNodes";
import type { ImportedSourceIdentity } from "~/utils/importedSourceIdentity";

vi.mock("roamjs-components/queries/getFullTreeByParentUid", () => ({
default: () => ({ children: [] }),
Expand All @@ -13,13 +14,20 @@ vi.mock("~/utils/getDiscourseNodes", () => ({
default: vi.fn(() => defaultDiscourseNodes),
}));

const { mockedGetPageUidByPageTitle } = vi.hoisted(() => ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
mockedGetPageUidByPageTitle: vi.fn((_title: string) => ""),
}));
const { mockedGetPageUidByPageTitle, mockedReadImportedSourceIdentity } =
vi.hoisted(() => ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
mockedGetPageUidByPageTitle: vi.fn((_title: string) => ""),
mockedReadImportedSourceIdentity: vi.fn(
(): ImportedSourceIdentity | undefined => undefined,
),
}));
vi.mock("roamjs-components/queries/getPageUidByPageTitle", () => ({
default: mockedGetPageUidByPageTitle,
}));
vi.mock("~/utils/importedSourceIdentity", () => ({
readImportedSourceIdentity: mockedReadImportedSourceIdentity,
}));

// Runs before the imports below: getDiscourseNodes calls generateUID at module load.
vi.hoisted(() => {
Expand Down Expand Up @@ -251,6 +259,7 @@ describe("nodeUidsWithTypeToCrossApp source slot", () => {
mockedGetPageUidByPageTitle.mockImplementation(
(title: string) => PAGE_UIDS[title] ?? "",
);
mockedReadImportedSourceIdentity.mockReset();
});

it("resolves the source page from the title into a sourceDocument slot", async () => {
Expand All @@ -262,6 +271,49 @@ describe("nodeUidsWithTypeToCrossApp source slot", () => {
expect(node.slots).toEqual({ sourceDocument: "source-1" });
});

it.each([
"orn:obsidian.note:vault-a/node-1",
"orn:obsidian:vault-a/node-1",
"https://roamresearch.com/#/app/graph-b/node-1",
])(
"writes the origin RID %j when the source page was imported from another app",
async (sourceNodeRid) => {
mockedGetDiscourseNodes.mockReturnValue([EVIDENCE_SCHEMA, SOURCE_SCHEMA]);
mockedReadImportedSourceIdentity.mockReturnValue({
sourceModifiedAt: "2026-06-14T15:00:00.000Z",
sourceNodeRid,
});
const node = await convertRow({
...baseRow,
":node/title": "[[EVD]] - REM sleep aids recall - [[@sun2019direct]]",
});
expect(node.slots).toEqual({ sourceDocument: sourceNodeRid });
expect(mockedReadImportedSourceIdentity).toHaveBeenCalledWith("source-1");
},
);

it.each([
"not a rid",
"orn:bad",
"orn:obsidian.note:vault-a/",
"orn:broken/node-1",
"https:///node-1",
])(
"keeps the page uid when the imported identity %j is not a well-formed RID",
async (sourceNodeRid) => {
mockedGetDiscourseNodes.mockReturnValue([EVIDENCE_SCHEMA, SOURCE_SCHEMA]);
mockedReadImportedSourceIdentity.mockReturnValue({
sourceModifiedAt: "2026-06-14T15:00:00.000Z",
sourceNodeRid,
});
const node = await convertRow({
...baseRow,
":node/title": "[[EVD]] - REM sleep aids recall - [[@sun2019direct]]",
});
expect(node.slots).toEqual({ sourceDocument: "source-1" });
},
);

// Leniency on the target type: see sourceSlot.ts
it("accepts a source that is a node of another type", async () => {
mockedGetDiscourseNodes.mockReturnValue([
Expand Down
Loading