Skip to content

Commit eb21d44

Browse files
MajorTalclaude
andcommitted
chore: bump @run402/functions to v2.2.1
Fix ai.generateImage() calling wrong gateway path (/ai/v1/generate-image → /generate-image/v1). Add unit tests for translate, moderate, email, and assets that pin every gateway path so endpoint mismatches are caught immediately. Add fullstack integration test coverage for all five previously untested @run402/functions helpers. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8cfe9fe commit eb21d44

8 files changed

Lines changed: 557 additions & 6 deletions

File tree

fullstack-integration.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const API =
3333
process.env.RUN402_API_BASE ??
3434
"https://api.run402.com";
3535
const EMAIL_TO = process.env.RUN402_FULLSTACK_EMAIL_TO;
36+
const EMAIL_TEMPLATE = process.env.RUN402_FULLSTACK_EMAIL_TEMPLATE;
3637
const RUN_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
3738
const SUBDOMAIN = `fs-${RUN_ID}`;
3839
const TEST_SECRET_VALUE = `fullstack-secret-${RUN_ID}`;
@@ -662,6 +663,16 @@ describe("Run402 full-stack integration (live API, no mocks)", { timeout: 900_00
662663

663664
const options = await fetch(`${routeBase}/api/fullstack`, { method: "OPTIONS" });
664665
assert.equal(options.status, 204);
666+
667+
// Gap 3: adminDb().from() — PostgREST /admin/v1/rest/* bypass path
668+
const adminFrom = await directFunctionJson({
669+
headers: apiHeaders("service"),
670+
body: { action: "admin-db-from" },
671+
});
672+
assert.equal(adminFrom.ok, true);
673+
const adminFromRows = rowsFromSql(adminFrom.rows);
674+
assert.ok(adminFromRows.length > 0, "adminDb().from() should return seeded rows");
675+
assert.ok(adminFromRows.some((row) => row.marker === "RUN402_FULLSTACK_SEED_ALPHA"), "adminDb().from() should see rows bypassing RLS");
665676
});
666677

667678
it("uploads blobs, diagnoses CDN state, and exercises in-function storage", async () => {
@@ -747,6 +758,58 @@ describe("Run402 full-stack integration (live API, no mocks)", { timeout: 900_00
747758
} else {
748759
assert.ok(aiResult.reason);
749760
}
761+
762+
// Gap 1: ai.translate() — /ai/v1/translate
763+
const translateResult = await directFunctionJson({
764+
headers: apiHeaders("service"),
765+
body: { action: "ai-translate" },
766+
});
767+
assert.ok(["ok", "skipped"].includes(String(translateResult.status)));
768+
if (translateResult.status === "ok") {
769+
assert.equal(translateResult.to, "es");
770+
assert.equal(translateResult.has_text, true);
771+
} else {
772+
assert.ok(translateResult.reason);
773+
}
774+
775+
// Gap 2: ai.generateImage() — /generate-image/v1
776+
const generateImageResult = await directFunctionJson({
777+
headers: apiHeaders("service"),
778+
body: { action: "ai-generate-image" },
779+
});
780+
assert.ok(["ok", "skipped"].includes(String(generateImageResult.status)));
781+
if (generateImageResult.status === "ok") {
782+
assert.equal(generateImageResult.aspect, "square");
783+
assert.equal(generateImageResult.has_image, true);
784+
assert.ok(String(generateImageResult.content_type).startsWith("image/"));
785+
} else {
786+
assert.ok(generateImageResult.reason);
787+
}
788+
789+
// Gap 4: email.send() with template — /mailboxes/v1/:id/messages with template body
790+
const emailTemplateResult = await directFunctionJson({
791+
headers: apiHeaders("service"),
792+
body: { action: "email-template" },
793+
});
794+
if (EMAIL_TO && EMAIL_TEMPLATE) {
795+
assert.equal(emailTemplateResult.status, "sent");
796+
assert.ok(emailTemplateResult.id);
797+
} else {
798+
assert.equal(emailTemplateResult.status, "skipped");
799+
}
800+
801+
// Gap 5: routedHttp helpers — callable in function runtime, produce correct RoutedHttpResponseV1 shape
802+
const routedHttpResult = await directFunctionJson({
803+
headers: apiHeaders("service"),
804+
body: { action: "routedhttp" },
805+
});
806+
assert.equal(routedHttpResult.ok, true);
807+
assert.equal((routedHttpResult.json as any)?.contentType, "application/json; charset=utf-8");
808+
assert.equal((routedHttpResult.json as any)?.status, 200);
809+
assert.ok(((routedHttpResult.json as any)?.bodySize ?? 0) > 0);
810+
assert.equal((routedHttpResult.text as any)?.contentType, "text/plain; charset=utf-8");
811+
assert.equal((routedHttpResult.text as any)?.status, 200);
812+
assert.equal((routedHttpResult.bytes as any)?.bodySize, 3);
750813
});
751814

752815
it("fetches active and by-id release inventories with full-stack resource metadata", async () => {

functions/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@run402/functions",
3-
"version": "2.2.0",
3+
"version": "2.2.1",
44
"description": "In-function helper library for Run402 serverless functions — db, adminDb, getUser, email, ai, assets. Auto-bundled into deployed functions; also installable for local TypeScript autocomplete.",
55
"type": "module",
66
"main": "dist/index.js",
@@ -18,7 +18,7 @@
1818
],
1919
"scripts": {
2020
"build": "tsc",
21-
"test": "node --experimental-test-module-mocks --test --import tsx src/db.test.ts src/auth.test.ts src/ai.test.ts src/routed-http.test.ts"
21+
"test": "node --experimental-test-module-mocks --test --import tsx src/db.test.ts src/auth.test.ts src/ai.test.ts src/email.test.ts src/assets.test.ts src/routed-http.test.ts"
2222
},
2323
"engines": {
2424
"node": ">=18"

functions/src/ai.test.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,91 @@ mock.module("./config.js", {
1313

1414
const { ai } = await import("./ai.js");
1515

16+
describe("ai.translate", () => {
17+
let lastFetchUrl = "";
18+
let lastFetchOpts: RequestInit = {};
19+
20+
beforeEach(() => {
21+
lastFetchUrl = "";
22+
lastFetchOpts = {};
23+
mock.method(globalThis, "fetch", async (url: string, opts: RequestInit) => {
24+
lastFetchUrl = url;
25+
lastFetchOpts = opts;
26+
return new Response(JSON.stringify({ text: "Hola mundo", from: "en", to: "es" }), {
27+
status: 200,
28+
headers: { "Content-Type": "application/json" },
29+
});
30+
});
31+
});
32+
33+
it("posts to /ai/v1/translate with service credentials", async () => {
34+
const result = await ai.translate("Hello world", "es", { from: "en", context: "greeting" });
35+
36+
assert.equal(lastFetchUrl, "https://test.run402.com/ai/v1/translate");
37+
assert.equal(lastFetchOpts.method, "POST");
38+
const headers = lastFetchOpts.headers as Record<string, string>;
39+
assert.equal(headers.Authorization, "Bearer sk_test");
40+
assert.equal(headers["Content-Type"], "application/json");
41+
assert.deepEqual(JSON.parse(lastFetchOpts.body as string), {
42+
text: "Hello world",
43+
to: "es",
44+
from: "en",
45+
context: "greeting",
46+
});
47+
assert.deepEqual(result, { text: "Hola mundo", from: "en", to: "es" });
48+
});
49+
50+
it("omits optional from/context when not provided", async () => {
51+
await ai.translate("Hello", "fr");
52+
assert.deepEqual(JSON.parse(lastFetchOpts.body as string), { text: "Hello", to: "fr" });
53+
});
54+
55+
it("throws on non-ok response", async () => {
56+
mock.method(globalThis, "fetch", async () =>
57+
new Response(JSON.stringify({ error: "quota exceeded" }), { status: 429 }),
58+
);
59+
await assert.rejects(
60+
async () => { await ai.translate("x", "es"); },
61+
/Translation failed \(429\)/,
62+
);
63+
});
64+
});
65+
66+
describe("ai.moderate", () => {
67+
it("posts to /ai/v1/moderate with service credentials", async () => {
68+
let lastFetchUrl = "";
69+
let lastFetchOpts: RequestInit = {};
70+
mock.method(globalThis, "fetch", async (url: string, opts: RequestInit) => {
71+
lastFetchUrl = url;
72+
lastFetchOpts = opts;
73+
return new Response(JSON.stringify({ flagged: false, categories: {} }), {
74+
status: 200,
75+
headers: { "Content-Type": "application/json" },
76+
});
77+
});
78+
79+
const result = await ai.moderate("some text to check");
80+
81+
assert.equal(lastFetchUrl, "https://test.run402.com/ai/v1/moderate");
82+
assert.equal(lastFetchOpts.method, "POST");
83+
const headers = lastFetchOpts.headers as Record<string, string>;
84+
assert.equal(headers.Authorization, "Bearer sk_test");
85+
assert.equal(headers["Content-Type"], "application/json");
86+
assert.deepEqual(JSON.parse(lastFetchOpts.body as string), { text: "some text to check" });
87+
assert.deepEqual(result, { flagged: false, categories: {} });
88+
});
89+
90+
it("throws on non-ok response", async () => {
91+
mock.method(globalThis, "fetch", async () =>
92+
new Response(JSON.stringify({ error: "rate limited" }), { status: 429 }),
93+
);
94+
await assert.rejects(
95+
async () => { await ai.moderate("x"); },
96+
/Moderation failed \(429\)/,
97+
);
98+
});
99+
});
100+
16101
describe("ai.generateImage", () => {
17102
let lastFetchUrl = "";
18103
let lastFetchOpts: RequestInit = {};
@@ -40,7 +125,7 @@ describe("ai.generateImage", () => {
40125
aspect: "landscape",
41126
});
42127

43-
assert.equal(lastFetchUrl, "https://test.run402.com/ai/v1/generate-image");
128+
assert.equal(lastFetchUrl, "https://test.run402.com/generate-image/v1");
44129
assert.equal(lastFetchOpts.method, "POST");
45130
const headers = lastFetchOpts.headers as Record<string, string>;
46131
assert.equal(headers.Authorization, "Bearer sk_test");

functions/src/ai.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export const ai = {
113113
throw new Error("Invalid image aspect: must be square, landscape, or portrait");
114114
}
115115

116-
const res = await fetch(config.API_BASE + "/ai/v1/generate-image", {
116+
const res = await fetch(config.API_BASE + "/generate-image/v1", {
117117
method: "POST",
118118
headers: {
119119
Authorization: "Bearer " + config.SERVICE_KEY,

functions/src/assets.test.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { describe, it, mock, beforeEach } from "node:test";
2+
import assert from "node:assert/strict";
3+
4+
mock.module("./config.js", {
5+
namedExports: {
6+
config: {
7+
API_BASE: "https://test.run402.com",
8+
PROJECT_ID: "prj_test",
9+
SERVICE_KEY: "sk_test",
10+
},
11+
},
12+
});
13+
14+
const { assets } = await import("./assets.js");
15+
16+
const STUB_REF = {
17+
key: "images/avatar.png",
18+
sha256: "abc123",
19+
size_bytes: 4,
20+
content_type: "image/png",
21+
visibility: "public",
22+
immutable: true,
23+
url: "https://cdn.run402.com/images/avatar.png",
24+
immutable_url: "https://cdn.run402.com/images/avatar.png@abc123",
25+
cdn_url: null,
26+
cdn_immutable_url: null,
27+
sri: null,
28+
etag: "abc123",
29+
content_digest: "sha256-abc123",
30+
};
31+
32+
describe("assets.put — gateway path and auth", () => {
33+
let capturedUrl = "";
34+
let capturedOpts: RequestInit = {};
35+
36+
beforeEach(() => {
37+
capturedUrl = "";
38+
capturedOpts = {};
39+
mock.method(globalThis, "fetch", async (url: string, opts: RequestInit) => {
40+
capturedUrl = url;
41+
capturedOpts = opts;
42+
return new Response(JSON.stringify(STUB_REF), {
43+
status: 200,
44+
headers: { "Content-Type": "application/json" },
45+
});
46+
});
47+
});
48+
49+
it("posts to /apply/v1/service-asset-put with service credentials", async () => {
50+
await assets.put("images/avatar.png", new Uint8Array([1, 2, 3, 4]));
51+
52+
assert.equal(capturedUrl, "https://test.run402.com/apply/v1/service-asset-put");
53+
assert.equal(capturedOpts.method, "POST");
54+
const headers = capturedOpts.headers as Record<string, string>;
55+
assert.equal(headers.Authorization, "Bearer sk_test");
56+
});
57+
58+
it("sets x-run402-asset-key header to the key argument", async () => {
59+
await assets.put("images/avatar.png", new Uint8Array([1, 2, 3, 4]));
60+
61+
const headers = capturedOpts.headers as Record<string, string>;
62+
assert.equal(headers["x-run402-asset-key"], "images/avatar.png");
63+
});
64+
65+
it("defaults visibility to public and immutable to true", async () => {
66+
await assets.put("images/avatar.png", new Uint8Array([1, 2, 3, 4]));
67+
68+
const headers = capturedOpts.headers as Record<string, string>;
69+
assert.equal(headers["x-run402-asset-visibility"], "public");
70+
assert.equal(headers["x-run402-asset-immutable"], "true");
71+
});
72+
73+
it("forwards explicit visibility and immutable options", async () => {
74+
await assets.put("data/secret.bin", new Uint8Array([1, 2, 3, 4]), {
75+
visibility: "private",
76+
immutable: false,
77+
});
78+
79+
const headers = capturedOpts.headers as Record<string, string>;
80+
assert.equal(headers["x-run402-asset-visibility"], "private");
81+
assert.equal(headers["x-run402-asset-immutable"], "false");
82+
});
83+
84+
it("guesses Content-Type from key extension", async () => {
85+
await assets.put("styles/main.css", new Uint8Array([46, 99, 108, 115]));
86+
87+
const headers = capturedOpts.headers as Record<string, string>;
88+
assert.equal(headers["Content-Type"], "text/css; charset=utf-8");
89+
});
90+
91+
it("uses explicit contentType option over guessed extension", async () => {
92+
await assets.put("data/blob", new Uint8Array([1, 2, 3, 4]), {
93+
contentType: "application/msgpack",
94+
});
95+
96+
const headers = capturedOpts.headers as Record<string, string>;
97+
assert.equal(headers["Content-Type"], "application/msgpack");
98+
});
99+
100+
it("sends raw binary body (not JSON)", async () => {
101+
const bytes = new Uint8Array([10, 20, 30]);
102+
await assets.put("file.bin", bytes);
103+
104+
assert.ok(capturedOpts.body instanceof ArrayBuffer, "body must be ArrayBuffer (binary)");
105+
assert.notEqual(typeof capturedOpts.body, "string", "body must not be stringified");
106+
});
107+
108+
it("accepts a string source — encodes to UTF-8 bytes", async () => {
109+
await assets.put("hello.txt", "hello");
110+
111+
assert.ok(capturedOpts.body instanceof ArrayBuffer);
112+
const decoded = new TextDecoder().decode(capturedOpts.body as ArrayBuffer);
113+
assert.equal(decoded, "hello");
114+
});
115+
116+
it("returns a widened AssetRef with camelCase aliases", async () => {
117+
const ref = await assets.put("images/avatar.png", new Uint8Array([1, 2, 3, 4]));
118+
119+
assert.equal(ref.key, "images/avatar.png");
120+
assert.equal(ref.immutableUrl, STUB_REF.immutable_url);
121+
assert.equal(ref.contentType, STUB_REF.content_type);
122+
assert.equal(ref.size, STUB_REF.size_bytes);
123+
assert.equal(ref.contentSha256, STUB_REF.sha256);
124+
});
125+
});
126+
127+
describe("assets.put — validation", () => {
128+
it("throws for empty key", async () => {
129+
await assert.rejects(
130+
async () => { await assets.put("", new Uint8Array([1])); },
131+
/key must be a non-empty string/,
132+
);
133+
});
134+
135+
it("throws for empty source bytes", async () => {
136+
await assert.rejects(
137+
async () => { await assets.put("file.bin", new Uint8Array([])); },
138+
/bytes must be non-empty/,
139+
);
140+
});
141+
142+
it("throws for object source with both content and bytes", async () => {
143+
await assert.rejects(
144+
async () => {
145+
await assets.put("f.bin", { content: "hi", bytes: new Uint8Array([1]) });
146+
},
147+
/provide exactly one of/,
148+
);
149+
});
150+
151+
it("throws on non-ok response and preserves error detail", async () => {
152+
mock.method(globalThis, "fetch", async () =>
153+
new Response(JSON.stringify({ code: "STORAGE_QUOTA_EXCEEDED", message: "over limit" }), {
154+
status: 402,
155+
}),
156+
);
157+
await assert.rejects(
158+
async () => { await assets.put("f.bin", new Uint8Array([1])); },
159+
/Asset put failed \(402\): STORAGE_QUOTA_EXCEEDED: over limit/,
160+
);
161+
});
162+
});

0 commit comments

Comments
 (0)