///
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { api, internal } from "./_generated/api.js";
import { initConvexTest } from "./setup.test.js";
describe("component lib", () => {
beforeEach(async () => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test("generates upload URLs", async () => {
const t = initConvexTest();
const uploadUrl = await t.mutation(internal.lib.generateUploadUrl, {});
expect(typeof uploadUrl).toBe("string");
expect(uploadUrl.length).toBeGreaterThan(0);
const urls = await t.mutation(internal.lib.generateUploadUrls, {
count: 3,
});
expect(urls).toHaveLength(3);
for (const u of urls) {
expect(typeof u).toBe("string");
}
await expect(
t.mutation(internal.lib.generateUploadUrls, { count: 101 }),
).rejects.toThrow("integer from 1 to 100");
});
test("getByPath returns null when absent", async () => {
const t = initConvexTest();
const asset = await t.query(internal.lib.getByPath, {
path: "/index.html",
});
expect(asset).toBeNull();
});
test("listAssets is empty by default", async () => {
const t = initConvexTest();
const assets = await t.query(internal.lib.listAssets, {});
expect(assets).toHaveLength(0);
});
test("commitDeployment on empty db returns zero", async () => {
const t = initConvexTest();
const result = await t.mutation(internal.lib.commitDeployment, {
currentDeploymentId: "deploy-1",
});
expect(result.deleted).toBe(0);
expect(result.blobIds).toHaveLength(0);
});
test("recordAsset stores and replaces blob assets", async () => {
const t = initConvexTest();
await t.mutation(internal.lib.recordAsset, {
path: "/test.js",
blobId: "blob-123",
contentType: "application/javascript; charset=utf-8",
deploymentId: "deploy-1",
});
const first = await t.query(internal.lib.getByPath, { path: "/test.js" });
expect(first?.blobId).toBe("blob-123");
await t.mutation(internal.lib.recordAsset, {
path: "/test.js",
blobId: "blob-456",
contentType: "application/javascript; charset=utf-8",
deploymentId: "deploy-2",
});
const second = await t.query(internal.lib.getByPath, { path: "/test.js" });
expect(second?.blobId).toBe("blob-456");
});
test("replaces a legacy or already-deleted storage reference", async () => {
const t = initConvexTest();
const storageId = await t.run(async (ctx) => {
return await ctx.storage.store(new Blob(["old"]));
});
await t.mutation(internal.lib.recordAsset, {
path: "/index.html",
storageId,
contentType: "text/html; charset=utf-8",
deploymentId: "deploy-old",
});
await t.run(async (ctx) => {
await ctx.storage.delete(storageId);
});
await expect(
t.mutation(internal.lib.recordAsset, {
path: "/index.html",
blobId: "replacement",
contentType: "text/html; charset=utf-8",
deploymentId: "deploy-new",
}),
).resolves.toBeNull();
});
test("commitDeployment returns blobIds for old CDN assets and bumps deployment", async () => {
const t = initConvexTest();
await t.mutation(internal.lib.recordAsset, {
path: "/assets/main.js",
blobId: "blob-abc",
contentType: "application/javascript; charset=utf-8",
deploymentId: "deploy-old",
});
const result = await t.mutation(internal.lib.commitDeployment, {
currentDeploymentId: "deploy-new",
});
expect(result.deleted).toBe(0);
expect(result.blobIds).toEqual(["blob-abc"]);
const deployment = await t.query(api.lib.getCurrentDeployment, {});
expect(deployment?.currentDeploymentId).toBe("deploy-new");
});
test("commitDeployment skips a legacy or already-deleted storage ID", async () => {
const t = initConvexTest();
const storageId = await t.run(async (ctx) => {
return await ctx.storage.store(new Blob(["legacy"]));
});
await t.mutation(internal.lib.recordAsset, {
path: "/legacy.css",
storageId,
contentType: "text/css; charset=utf-8",
deploymentId: "deploy-old",
});
await t.run(async (ctx) => {
await ctx.storage.delete(storageId);
});
const result = await t.mutation(internal.lib.commitDeployment, {
currentDeploymentId: "deploy-new",
});
expect(result).toEqual({ deleted: 0, blobIds: [] });
expect(await t.query(internal.lib.listAssets, {})).toEqual([]);
});
test("deletes newly uploaded files after a failed publish", async () => {
const t = initConvexTest();
const [first, alreadyMissing] = await t.run(async (ctx) => {
return await Promise.all([
ctx.storage.store(new Blob(["first"])),
ctx.storage.store(new Blob(["missing"])),
]);
});
await t.run(async (ctx) => {
await ctx.storage.delete(alreadyMissing);
});
const result = await t.mutation(internal.lib.deleteUploadedFiles, {
storageIds: [first, alreadyMissing],
});
expect(result).toEqual({
deleted: 1,
alreadyMissing: 1,
stillReferenced: 0,
});
await t.run(async (ctx) => {
expect(await ctx.storage.get(first)).toBeNull();
});
});
test("failed-upload cleanup never deletes a file referenced by the live manifest", async () => {
const t = initConvexTest();
const storageId = await t.run(async (ctx) => {
return await ctx.storage.store(new Blob(["live"]));
});
await t.mutation(internal.lib.recordAsset, {
path: "/index.html",
storageId,
contentType: "text/html; charset=utf-8",
deploymentId: "deploy-live",
});
const result = await t.mutation(internal.lib.deleteUploadedFiles, {
storageIds: [storageId],
});
expect(result).toEqual({
deleted: 0,
alreadyMissing: 0,
stillReferenced: 1,
});
await t.run(async (ctx) => {
expect(await ctx.storage.get(storageId)).not.toBeNull();
});
});
test("cleanup after a lost publish response keeps the newly live file", async () => {
const t = initConvexTest();
const storageId = await t.run(async (ctx) => {
return await ctx.storage.store(new Blob(["new live deployment"]));
});
await t.mutation(internal.lib.stageAssets, {
assets: [
{
path: "/index.html",
storageId,
contentType: "text/html; charset=utf-8",
deploymentId: "deploy-ambiguous",
},
],
});
await t.mutation(internal.lib.publishDeployment, {
currentDeploymentId: "deploy-ambiguous",
expectedAssetCount: 1,
});
// This is the sequence the CLI runs when the publish mutation committed
// but its response was lost.
expect(
await t.mutation(internal.lib.discardStagedDeployment, {
deploymentId: "deploy-ambiguous",
}),
).toEqual({ discarded: 0 });
expect(
await t.mutation(internal.lib.deleteUploadedFiles, {
storageIds: [storageId],
}),
).toEqual({ deleted: 0, alreadyMissing: 0, stillReferenced: 1 });
await t.run(async (ctx) => {
expect(await ctx.storage.get(storageId)).not.toBeNull();
});
});
test("publishes the manifest, deployment info, and cleanup atomically", async () => {
const t = initConvexTest();
const oldStorageId = await t.run(async (ctx) => {
return await ctx.storage.store(new Blob(["old"]));
});
await t.mutation(internal.lib.recordAssets, {
assets: [
{
path: "/old.css",
storageId: oldStorageId,
contentType: "text/css; charset=utf-8",
deploymentId: "deploy-old",
},
{
path: "/old.js",
blobId: "old-cdn-blob",
contentType: "application/javascript; charset=utf-8",
deploymentId: "deploy-old",
},
],
});
const newStorageId = await t.run(async (ctx) => {
return await ctx.storage.store(new Blob(["new"]));
});
await t.mutation(internal.lib.stageAssets, {
assets: [
{
path: "/index.html",
storageId: newStorageId,
contentType: "text/html; charset=utf-8",
deploymentId: "deploy-new",
},
],
});
const result = await t.mutation(internal.lib.publishDeployment, {
currentDeploymentId: "deploy-new",
expectedAssetCount: 1,
spaFallback: false,
});
expect(result).toEqual({ deleted: 0, pendingBlobCleanup: 1 });
expect(await t.query(internal.lib.listPendingBlobCleanup, {})).toEqual([
"old-cdn-blob",
]);
expect(await t.query(internal.lib.listAssets, {})).toMatchObject([
{
path: "/index.html",
storageId: newStorageId,
deploymentId: "deploy-new",
},
]);
expect(await t.query(api.lib.getCurrentDeployment, {})).toMatchObject({
currentDeploymentId: "deploy-new",
spaFallback: false,
pendingBlobCleanupCount: 1,
});
const concurrentStorageId = await t.run(async (ctx) => {
return await ctx.storage.store(new Blob(["concurrent upload"]));
});
await t.run(async (ctx) => {
expect(await ctx.storage.get(oldStorageId)).not.toBeNull();
expect(await ctx.storage.get(newStorageId)).not.toBeNull();
});
expect(await t.mutation(internal.lib.cleanupPendingStorage, {})).toEqual({
processed: 1,
deleted: 1,
needsAnotherPass: false,
});
await t.run(async (ctx) => {
expect(await ctx.storage.get(oldStorageId)).toBeNull();
expect(await ctx.storage.get(newStorageId)).not.toBeNull();
expect(await ctx.storage.get(concurrentStorageId)).not.toBeNull();
});
expect(
await t.mutation(internal.lib.acknowledgeBlobCleanup, {
blobIds: ["old-cdn-blob"],
}),
).toEqual({ acknowledged: 1 });
expect(await t.query(internal.lib.listPendingBlobCleanup, {})).toEqual([]);
expect(await t.query(api.lib.getCurrentDeployment, {})).toMatchObject({
pendingBlobCleanupCount: 0,
});
expect(
await t.mutation(internal.lib.queueBlobCleanup, {
blobIds: ["retry-cdn-blob", "retry-cdn-blob"],
}),
).toEqual({ queued: 1 });
expect(await t.query(internal.lib.listPendingBlobCleanup, {})).toEqual([
"retry-cdn-blob",
]);
expect(await t.query(api.lib.getCurrentDeployment, {})).toMatchObject({
pendingBlobCleanupCount: 1,
});
});
test("rejects a staged manifest without index.html and keeps the live manifest", async () => {
const t = initConvexTest();
await t.mutation(internal.lib.recordAsset, {
path: "/index.html",
blobId: "still-live",
contentType: "text/html; charset=utf-8",
deploymentId: "deploy-old",
});
await t.mutation(internal.lib.stageAssets, {
assets: [
{
path: "/only.css",
blobId: "not-published",
contentType: "text/css; charset=utf-8",
deploymentId: "deploy-new",
},
],
});
await expect(
t.mutation(internal.lib.publishDeployment, {
currentDeploymentId: "deploy-new",
expectedAssetCount: 1,
}),
).rejects.toThrow("must include /index.html");
expect(
await t.query(internal.lib.getByPath, { path: "/index.html" }),
).toMatchObject({ blobId: "still-live", deploymentId: "deploy-old" });
});
test("rejects a zero expected asset count without changing the live manifest", async () => {
const t = initConvexTest();
await t.mutation(internal.lib.recordAsset, {
path: "/index.html",
blobId: "still-live",
contentType: "text/html; charset=utf-8",
deploymentId: "deploy-old",
});
await expect(
t.mutation(internal.lib.publishDeployment, {
currentDeploymentId: "deploy-empty",
expectedAssetCount: 0,
}),
).rejects.toThrow("integer from 1 to 1800");
expect(
await t.query(internal.lib.getByPath, { path: "/index.html" }),
).toMatchObject({ blobId: "still-live", deploymentId: "deploy-old" });
});
test("keeps CDN cleanup accounting queued before the first deployment", async () => {
const t = initConvexTest();
expect(
await t.mutation(internal.lib.queueBlobCleanup, {
blobIds: ["failed-first-upload"],
}),
).toEqual({ queued: 1 });
await t.mutation(internal.lib.stageAssets, {
assets: [
{
path: "/index.html",
blobId: "first-live-index",
contentType: "text/html; charset=utf-8",
deploymentId: "deploy-first-success",
},
],
});
await expect(
t.mutation(internal.lib.publishDeployment, {
currentDeploymentId: "deploy-first-success",
expectedAssetCount: 1,
}),
).resolves.toEqual({ deleted: 0, pendingBlobCleanup: 1 });
expect(await t.query(api.lib.getCurrentDeployment, {})).toMatchObject({
currentDeploymentId: "deploy-first-success",
pendingBlobCleanupCount: 1,
});
});
test("rejects a staged manifest whose storage file disappeared", async () => {
const t = initConvexTest();
await t.mutation(internal.lib.recordAsset, {
path: "/index.html",
blobId: "still-live",
contentType: "text/html; charset=utf-8",
deploymentId: "deploy-old",
});
const missingStorageId = await t.run(async (ctx) => {
const storageId = await ctx.storage.store(new Blob(["temporary"]));
await ctx.storage.delete(storageId);
return storageId;
});
await expect(
t.mutation(internal.lib.stageAssets, {
assets: [
{
path: "/index.html",
storageId: missingStorageId,
contentType: "text/html; charset=utf-8",
deploymentId: "deploy-missing",
},
],
}),
).rejects.toThrow("Staged storage file is missing");
expect(
await t.query(internal.lib.getByPath, { path: "/index.html" }),
).toMatchObject({ blobId: "still-live", deploymentId: "deploy-old" });
});
test("publishes a manifest staged in multiple chunks", async () => {
const t = initConvexTest();
await t.mutation(internal.lib.stageAssets, {
assets: [
{
path: "/index.html",
blobId: "index",
contentType: "text/html; charset=utf-8",
deploymentId: "deploy-chunked",
},
],
});
await t.mutation(internal.lib.stageAssets, {
assets: [
{
path: "/app.js",
blobId: "app",
contentType: "application/javascript; charset=utf-8",
deploymentId: "deploy-chunked",
},
],
});
await t.mutation(internal.lib.publishDeployment, {
currentDeploymentId: "deploy-chunked",
expectedAssetCount: 2,
});
expect(await t.query(internal.lib.listAssets, {})).toMatchObject([
{ path: "/index.html", deploymentId: "deploy-chunked" },
{ path: "/app.js", deploymentId: "deploy-chunked" },
]);
expect(
await t.mutation(internal.lib.discardStagedDeployment, {
deploymentId: "deploy-chunked",
}),
).toEqual({ discarded: 0 });
});
test("rejects manifests above the safe transaction-sized file limit", async () => {
const t = initConvexTest();
await t.mutation(internal.lib.stageAssets, {
assets: Array.from({ length: 1801 }, (_, index) => ({
path: index === 0 ? "/index.html" : `/asset-${index}.js`,
blobId: `blob-${index}`,
contentType:
index === 0
? "text/html; charset=utf-8"
: "application/javascript; charset=utf-8",
deploymentId: "deploy-too-large",
})),
});
await expect(
t.mutation(internal.lib.publishDeployment, {
currentDeploymentId: "deploy-too-large",
expectedAssetCount: 1801,
}),
).rejects.toThrow("integer from 1 to 1800");
await expect(
t.mutation(internal.lib.publishDeployment, {
currentDeploymentId: "deploy-too-large",
expectedAssetCount: 1800,
}),
).rejects.toThrow("Staged manifest has 1801 files; expected 1800");
});
test("publishes large inherited rows before bounded storage cleanup", async () => {
const t = initConvexTest();
const sharedStorageId = await t.run(async (ctx) => {
const storageId = await ctx.storage.store(new Blob(["legacy"]));
await Promise.all(
Array.from({ length: 1800 }, (_, index) =>
ctx.db.insert("staticAssets", {
path: `/legacy-${index}`,
storageId,
blobId: `legacy-blob-${index}`,
contentType: "application/octet-stream",
deploymentId: "deploy-legacy",
}),
),
);
return storageId;
});
expect(sharedStorageId).toBeTruthy();
await t.mutation(internal.lib.stageAssets, {
assets: Array.from({ length: 1800 }, (_, index) => ({
path: index === 0 ? "/index.html" : `/new-${index}.js`,
blobId: `new-blob-${index}`,
contentType:
index === 0
? "text/html; charset=utf-8"
: "application/javascript; charset=utf-8",
deploymentId: "deploy-new",
})),
});
await expect(
t.mutation(internal.lib.publishDeployment, {
currentDeploymentId: "deploy-new",
expectedAssetCount: 1800,
}),
).resolves.toEqual({ deleted: 0, pendingBlobCleanup: 1800 });
expect(await t.mutation(internal.lib.cleanupPendingStorage, {})).toEqual({
processed: 1,
deleted: 1,
needsAnotherPass: false,
});
await t.run(async (ctx) => {
expect(await ctx.storage.get(sharedStorageId)).toBeNull();
});
});
test("rejects manifests above the serialized metadata budget", async () => {
const t = initConvexTest();
const assets = Array.from({ length: 1800 }, (_, index) => ({
path:
index === 0
? "/index.html"
: `/${index}-${"nested-path/".repeat(100)}asset.js`,
blobId: `blob-${index}`,
contentType:
index === 0
? "text/html; charset=utf-8"
: "application/javascript; charset=utf-8",
deploymentId: "deploy-too-many-bytes",
}));
await t.mutation(internal.lib.stageAssets, { assets });
await expect(
t.mutation(internal.lib.publishDeployment, {
currentDeploymentId: "deploy-too-many-bytes",
expectedAssetCount: assets.length,
}),
).rejects.toThrow("above the safe limit");
});
test("rejects an oversized combined legacy and new manifest safely", async () => {
const t = initConvexTest();
await t.run(async (ctx) => {
await Promise.all(
Array.from({ length: 2001 }, (_, index) =>
ctx.db.insert("staticAssets", {
path: `/legacy-${index}.js`,
blobId: `legacy-${index}`,
contentType: "application/javascript; charset=utf-8",
deploymentId: "deploy-legacy",
}),
),
);
});
const assets = Array.from({ length: 1800 }, (_, index) => ({
path: index === 0 ? "/index.html" : `/new-${index}.js`,
blobId: `new-${index}`,
contentType:
index === 0
? "text/html; charset=utf-8"
: "application/javascript; charset=utf-8",
deploymentId: "deploy-new",
}));
await t.mutation(internal.lib.stageAssets, { assets });
await expect(
t.mutation(internal.lib.publishDeployment, {
currentDeploymentId: "deploy-new",
expectedAssetCount: assets.length,
}),
).rejects.toThrow("more than 3800 rows combined");
});
test("rejects a truncated staged manifest even when index.html remains", async () => {
const t = initConvexTest();
await t.mutation(internal.lib.recordAsset, {
path: "/index.html",
blobId: "still-live",
contentType: "text/html; charset=utf-8",
deploymentId: "deploy-old",
});
await t.mutation(internal.lib.stageAssets, {
assets: [
{
path: "/app.js",
blobId: "app",
contentType: "application/javascript; charset=utf-8",
deploymentId: "deploy-truncated",
},
{
path: "/index.html",
blobId: "index",
contentType: "text/html; charset=utf-8",
deploymentId: "deploy-truncated",
},
],
});
await t.run(async (ctx) => {
const stagedAssets = await ctx.db
.query("stagedAssets")
.withIndex("by_deploymentId", (q) =>
q.eq("deploymentId", "deploy-truncated"),
)
.collect();
const appAsset = stagedAssets.find((asset) => asset.path === "/app.js");
if (!appAsset) throw new Error("Expected staged app asset");
await ctx.db.delete("stagedAssets", appAsset._id);
});
await expect(
t.mutation(internal.lib.publishDeployment, {
currentDeploymentId: "deploy-truncated",
expectedAssetCount: 2,
}),
).rejects.toThrow("Staged manifest has 1 files; expected 2");
expect(
await t.query(internal.lib.getByPath, { path: "/index.html" }),
).toMatchObject({ blobId: "still-live", deploymentId: "deploy-old" });
});
test("removes old unreferenced component storage after an interrupted upload", async () => {
const t = initConvexTest();
const orphan = await t.run(async (ctx) => {
return await ctx.storage.store(new Blob(["orphan"]));
});
const result = await t.mutation(internal.lib.cleanupUnreferencedStorage, {
before: Date.now() + 1,
limit: 10,
});
expect(result.deleted).toBe(1);
await t.run(async (ctx) => {
expect(await ctx.storage.get(orphan)).toBeNull();
});
});
test("recovers an abandoned staged manifest and queues its CDN blob", async () => {
const t = initConvexTest();
const storageId = await t.run(async (ctx) => {
return await ctx.storage.store(new Blob(["abandoned"]));
});
await t.mutation(internal.lib.stageAssets, {
assets: [
{
path: "/index.html",
storageId,
contentType: "text/html; charset=utf-8",
deploymentId: "deploy-abandoned",
},
{
path: "/app.js",
blobId: "abandoned-cdn-blob",
contentType: "application/javascript; charset=utf-8",
deploymentId: "deploy-abandoned",
},
],
});
expect(
await t.mutation(internal.lib.cleanupAbandonedStaging, {
before: Date.now() + 1,
}),
).toEqual({ discarded: 2, deletedFiles: 1, queuedBlobIds: 1 });
expect(await t.query(internal.lib.listPendingBlobCleanup, {})).toEqual([
"abandoned-cdn-blob",
]);
expect(
await t.mutation(internal.lib.discardStagedDeployment, {
deploymentId: "deploy-abandoned",
}),
).toEqual({ discarded: 0 });
await t.run(async (ctx) => {
expect(await ctx.storage.get(storageId)).toBeNull();
});
});
test("recordAssets batches multiple inserts", async () => {
const t = initConvexTest();
const result = await t.mutation(internal.lib.recordAssets, {
assets: [
{
path: "/a.js",
blobId: "blob-a",
contentType: "application/javascript; charset=utf-8",
deploymentId: "deploy-1",
},
{
path: "/b.css",
blobId: "blob-b",
contentType: "text/css; charset=utf-8",
deploymentId: "deploy-1",
},
],
});
expect(result).toEqual({ blobIds: [] });
const all = await t.query(internal.lib.listAssets, {});
expect(all).toHaveLength(2);
});
test("recordAssets returns replaced CDN blobs for cleanup", async () => {
const t = initConvexTest();
await t.mutation(internal.lib.recordAsset, {
path: "/app.js",
blobId: "old-blob",
contentType: "application/javascript; charset=utf-8",
deploymentId: "deploy-old",
});
const storageId = await t.run(async (ctx) => {
return await ctx.storage.store(new Blob(["new"]));
});
const result = await t.mutation(internal.lib.recordAssets, {
assets: [
{
path: "/app.js",
storageId,
contentType: "application/javascript; charset=utf-8",
deploymentId: "deploy-new",
},
],
});
expect(result).toEqual({ blobIds: ["old-blob"] });
const current = await t.query(internal.lib.getByPath, { path: "/app.js" });
expect(current?.storageId).toBe(storageId);
expect(current?.blobId).toBeUndefined();
});
test("recordAssets rejects ambiguous locations and duplicate paths", async () => {
const t = initConvexTest();
await expect(
t.mutation(internal.lib.recordAssets, {
assets: [
{
path: "/missing-location.js",
contentType: "application/javascript; charset=utf-8",
deploymentId: "deploy-1",
},
],
}),
).rejects.toThrow("exactly one of storageId or blobId");
await expect(
t.mutation(internal.lib.recordAssets, {
assets: [
{
path: "/duplicate.js",
blobId: "blob-a",
contentType: "application/javascript; charset=utf-8",
deploymentId: "deploy-1",
},
{
path: "/duplicate.js",
blobId: "blob-b",
contentType: "application/javascript; charset=utf-8",
deploymentId: "deploy-1",
},
],
}),
).rejects.toThrow("Duplicate asset path");
});
describe("resolveAsset", () => {
async function seedIndex(t: ReturnType) {
await t.mutation(internal.lib.recordAsset, {
path: "/index.html",
blobId: "blob-index",
contentType: "text/html; charset=utf-8",
deploymentId: "deploy-1",
});
}
test("returns the exact match when present", async () => {
const t = initConvexTest();
await t.mutation(internal.lib.recordAsset, {
path: "/assets/app-B71cUw87.js",
blobId: "blob-app",
contentType: "application/javascript; charset=utf-8",
deploymentId: "deploy-1",
});
const asset = await t.query(internal.lib.resolveAsset, {
path: "/assets/app-B71cUw87.js",
});
expect(asset?.path).toBe("/assets/app-B71cUw87.js");
});
test("falls back to index.html for extension-less misses by default", async () => {
const t = initConvexTest();
await seedIndex(t);
const asset = await t.query(internal.lib.resolveAsset, {
path: "/dashboard/settings",
});
expect(asset?.path).toBe("/index.html");
});
test("does not fall back for paths with an extension", async () => {
const t = initConvexTest();
await seedIndex(t);
const asset = await t.query(internal.lib.resolveAsset, {
path: "/missing.js",
});
expect(asset).toBeNull();
});
test("does not fall back when the deployment disables it", async () => {
const t = initConvexTest();
await seedIndex(t);
await t.mutation(internal.lib.commitDeployment, {
currentDeploymentId: "deploy-1",
spaFallback: false,
});
const asset = await t.query(internal.lib.resolveAsset, {
path: "/dashboard",
});
expect(asset).toBeNull();
});
test("returns a storage URL for app-owned HTTP routes", async () => {
const t = initConvexTest();
const storageId = await t.run(async (ctx) => {
return await ctx.storage.store(
new Blob(["Hello
"], { type: "text/html" }),
);
});
await t.mutation(internal.lib.recordAsset, {
path: "/index.html",
storageId,
contentType: "text/html; charset=utf-8",
deploymentId: "deploy-1",
});
const asset = await t.query(api.lib.resolveAssetForHttp, {
path: "/index.html",
});
expect(asset?.storageUrl).toContain("http");
expect(asset?.etag).toBe(`"${storageId}"`);
expect(asset?.contentType).toBe("text/html; charset=utf-8");
});
test("surfaces the app-owned storage id for inherited v1 rows", async () => {
// A same-name v1→v2 migration inherits rows whose files live in the app's
// storage; the component can't resolve a URL for them. convex-test can't
// distinguish that from a deleted file, so deleting one reproduces the
// condition: resolveAssetForHttp surfaces appStorageId (no storageUrl) so
// the app-side handler can serve it from its own storage during migration.
const t = initConvexTest();
const storageId = await t.run(async (ctx) => {
return await ctx.storage.store(new Blob(["legacy"]));
});
await t.mutation(internal.lib.recordAsset, {
path: "/index.html",
storageId,
contentType: "text/html; charset=utf-8",
deploymentId: "deploy-old",
});
await t.run(async (ctx) => {
await ctx.storage.delete(storageId);
});
const asset = await t.query(api.lib.resolveAssetForHttp, {
path: "/index.html",
});
expect(asset?.appStorageId).toBe(storageId);
expect(asset?.storageUrl).toBeUndefined();
expect(asset?.etag).toBe(`"${storageId}"`);
});
test("allows compatibility routes to override SPA fallback", async () => {
const t = initConvexTest();
await seedIndex(t);
const asset = await t.query(api.lib.resolveAssetForHttp, {
path: "/dashboard",
spaFallback: false,
});
expect(asset).toBeNull();
});
});
});