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
274 changes: 274 additions & 0 deletions apps/roam/src/utils/__tests__/assetDegradation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
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 { contentTypes } from "@repo/content-model";
import type { SharedNode } from "@repo/database/lib/sharedNodes";
import { MAX_ASSET_BYTES } from "@repo/database/lib/assetLimits";
import { publishNodeAssets, summarizeAssetResults } from "../publishNodeAssets";
import { importNodeAssets } from "../importNodeAssets";
import { mirrorAssetToRoamStorage } from "../mirrorAssetToRoamStorage";

/**
* The degradation path, followed across both transfers rather than within one.
*
* The published markdown of the first half is the input to the second, so what a
* destination actually receives for an asset that never made it into shared storage is
* asserted rather than assumed: publication reports the failure and leaves the asset's
* link as written, and import leaves that same link alone because no row matches it. The
* two halves are covered separately in `publishNodeAssets.test.ts` and
* `importNodeAssets.test.ts`; what is only visible here is that they agree on what passes
* between them.
*
* The two halves name that string differently: publication's results carry `sourceRef`,
* the rewriter's carry `sourceLocator`. Same string, different owners, so neither name is
* wrong here and the assertions below use whichever side they are reading.
*/

vi.mock("../mirrorAssetToRoamStorage", () => ({
mirrorAssetToRoamStorage: vi.fn(),
}));
const mirror = vi.mocked(mirrorAssetToRoamStorage);

/** Shared by the fixture URLs and the `graph.name` stub, which must agree: publication
* recognises an asset by the graph named in its path. */
const GRAPH_NAME = "MAPLab";

const roamAsset = (name: string) =>
`https://firebasestorage.googleapis.com/v0/b/firescript-577a2.appspot.com/o/imgs%2Fapp%2F${GRAPH_NAME}%2F${name}.png?alt=media&token=9f1c07a4`;

const STORED = roamAsset("stored");
const UNREADABLE = roamAsset("unreadable");
const OVERSIZED = roamAsset("oversized");
const EXTERNAL = "https://example.org/not-an-asset.png";

const MARKDOWN = [
"# Sleep improves memory consolidation",
"",
`![](${STORED})`,
`![](${UNREADABLE})`,
`![](${OVERSIZED})`,
`[a paper](${EXTERNAL})`,
"",
"- Supported by [[EVD]] - Rasch & Born 2013",
].join("\n");

const SOURCE_LOCAL_ID = "tgWb6JozF";

const node: CrossAppNode = {
localId: SOURCE_LOCAL_ID,
nodeType: "rCLM0schema",
coreTitle: "Sleep improves memory consolidation",
content: {
direct: { value: "Sleep improves memory consolidation" },
full: { contentType: contentTypes.markdown, value: MARKDOWN },
},
createdAt: new Date("2026-06-12T14:00:00.000Z"),
modifiedAt: new Date("2026-06-12T15:00:00.000Z"),
authorId: "maparent",
};

const sharedNode = {
rid: "orn:roam.node:MAPLab/tgWb6JozF",
sourceLocalId: SOURCE_LOCAL_ID,
spaceId: 20,
spaceName: "MAPLab",
spaceUri: "roam:MAPLab",
platform: "Roam",
title: "Sleep improves memory consolidation",
created: null,
lastModified: "2026-06-12T15:00:00.000Z",
directMetadata: null,
} as unknown as SharedNode;

type Row = {
filepath: string;
filehash: string;
source_path: string | null;
};

/**
* One store standing in for Supabase across both halves: publication inserts into it and
* import reads back out of it, so the rows the destination sees are the rows publication
* actually wrote.
*/
const makeSharedStorage = () => {
const rows: Row[] = [];
const thenable = (result: unknown) => ({
then: (resolve: (value: unknown) => unknown) =>
Promise.resolve(result).then(resolve),
});
const selectChain = () => {
const chain = {
eq: () => chain,
in: () => chain,
order: () => chain,
then: (resolve: (value: unknown) => unknown) =>
Promise.resolve({ data: rows, error: null }).then(resolve),
};
return chain;
};
const client = {
rpc: vi.fn((_fn: string, { hashvalue }: { hashvalue: string }) =>
Promise.resolve({
data: rows.some((row) => row.filehash === hashvalue),
error: null,
}),
),
storage: {
from: vi.fn(() => ({
upload: vi.fn().mockResolvedValue({ error: null }),
})),
},
from: vi.fn(() => ({
select: vi.fn(() => selectChain()),
delete: vi.fn(() => {
const chain = {
eq: () => chain,
notIn: () => chain,
then: (resolve: (value: unknown) => unknown) =>
Promise.resolve({ error: null }).then(resolve),
};
return chain;
}),
insert: vi.fn((inserted: Row) => {
rows.push({
filepath: inserted.filepath,
filehash: inserted.filehash,
source_path: inserted.source_path ?? null,
});
return thenable({ error: null });
}),
})),
} as unknown as DGSupabaseClient;
return { client, rows };
};

/**
* Roam's storage: one asset readable, one unreadable, one past the publish cap.
*
* Both reads an asset takes are stubbed: the descriptor over `fetch`, the bytes through
* `file.get`. The unreadable one fails on either, so the test does not depend on which of
* the two happens to reach it first.
*/
const stubRoamStorage = () => {
const objectPath = (url: string) => url.split("?")[0] ?? url;
const isUnreadable = (url: string) =>
objectPath(url) === objectPath(UNREADABLE);

vi.stubGlobal(
"fetch",
vi.fn((input: string) => {
if (isUnreadable(input))
return Promise.resolve({
ok: false,
status: 500,
} as unknown as Response);
const size =
objectPath(input) === objectPath(OVERSIZED) ? MAX_ASSET_BYTES + 1 : 7;
return Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
name: "imgs/app/MAPLab/stored.png",
contentType: "image/png",
size: String(size),
metadata: { "file-name": "diagram.png" },
}),
} as unknown as Response);
}),
);

const get = vi.fn(({ url }: { url: string }) =>
isUnreadable(url)
? Promise.reject(new Error("Roam could not read the file"))
: Promise.resolve(
new File(["PNGDATA"], "diagram.png", { type: "image/png" }),
),
);
vi.stubGlobal("window", {
roamAlphaAPI: {
file: { get },
graph: { name: GRAPH_NAME, isEncrypted: false },
},
});
return { get };
};

/** What `file.upload` returns for a mirrored asset: a fresh uid in this graph's folder. */
const THIS_GRAPHS_COPY = roamAsset("aB3dEf");

describe("asset degradation across both transfers", () => {
let storage: ReturnType<typeof makeSharedStorage>;

beforeEach(() => {
vi.clearAllMocks();
storage = makeSharedStorage();
stubRoamStorage();
});

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

const publish = () =>
publishNodeAssets({
client: storage.client,
spaceId: 20,
nodes: [node],
});

it("leaves the link of every asset it could not store in the published markdown, and reports each one", async () => {
const summary = summarizeAssetResults(await publish());

expect(node.content.full?.value).toBe(MARKDOWN);
expect(summary.failed.map((f) => f.sourceRef)).toEqual([UNREADABLE]);
expect(summary.tooLarge.map((s) => s.sourceRef)).toEqual([OVERSIZED]);
expect(summary.copied).toBe(1);
expect(storage.rows.map((r) => r.filepath)).toEqual([STORED]);
});

it("imports the published markdown with its body intact, rewriting only what was stored", async () => {
await publish();
mirror.mockResolvedValue({
status: "mirrored",
contentHash: storage.rows[0].filehash,
url: THIS_GRAPHS_COPY,
});

const { markdown, report } = await importNodeAssets({
client: storage.client,
sharedNode,
markdown: MARKDOWN,
});

// Only the asset that reached shared storage was mirrored, so only its locator moved.
expect(mirror).toHaveBeenCalledTimes(1);
expect(markdown).toContain(`![](${THIS_GRAPHS_COPY})`);
// The rest of the node arrives exactly as published: the two locators that never
// became rows still point at Roam's world-readable originals, which is what makes
// them render, and the external link was never ours to touch.
expect(markdown).toContain(`![](${UNREADABLE})`);
expect(markdown).toContain(`![](${OVERSIZED})`);
expect(markdown).toContain(`[a paper](${EXTERNAL})`);
expect(markdown).toContain("# Sleep improves memory consolidation");
expect(markdown).toContain("- Supported by [[EVD]] - Rasch & Born 2013");
expect(report).toMatchObject({ mirrored: 1, reused: 0, skipped: [] });
});

it("reports an asset that fails on the way in, leaving its locator and the node body untouched", async () => {
await publish();
mirror.mockRejectedValue(new Error("upload refused"));

const { markdown, report } = await importNodeAssets({
client: storage.client,
sharedNode,
markdown: MARKDOWN,
});

expect(markdown).toBe(MARKDOWN);
expect(report.failed).toEqual([
{ sourceLocator: STORED, message: "upload refused" },
]);
});
});
Loading