Blame · Line-by-line history
agent-marketplace.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 055ebc4 | 1 | /** |
| 2 | * Block K10 — Agent Marketplace tests. | |
| 3 | * | |
| 4 | * Pure form-parser tests are fully exercised. Route smokes only assert auth | |
| 5 | * behaviour — DB-backed flows live in the integration suite. | |
| 6 | */ | |
| 7 | ||
| 8 | import { describe, it, expect } from "bun:test"; | |
| 9 | import app from "../app"; | |
| 10 | import { | |
| 11 | parseListingForm, | |
| 12 | ALLOWED_LISTING_KINDS, | |
| 13 | SLUG_RE, | |
| 14 | TAGLINE_MAX, | |
| 15 | DESCRIPTION_MAX, | |
| 16 | PRICING_MAX_CENTS, | |
| 17 | type ListingFormInput, | |
| 18 | } from "../routes/agent-marketplace"; | |
| 19 | ||
| 20 | // --------------------------------------------------------------------------- | |
| 21 | // parseListingForm | |
| 22 | // --------------------------------------------------------------------------- | |
| 23 | ||
| 24 | describe("agent-marketplace — parseListingForm", () => { | |
| 25 | const validInput: ListingFormInput = { | |
| 26 | slug: "my-agent", | |
| 27 | name: "My Agent", | |
| 28 | tagline: "It does things.", | |
| 29 | description: "Hello world.", | |
| 30 | kind: "triage", | |
| 31 | homepage_url: "https://example.com", | |
| 32 | icon_url: "https://example.com/icon.png", | |
| 33 | pricing_cents_per_month: "500", | |
| 34 | }; | |
| 35 | ||
| 36 | it("accepts a fully valid form", () => { | |
| 37 | const r = parseListingForm(validInput); | |
| 38 | expect(r.ok).toBe(true); | |
| 39 | if (r.ok) { | |
| 40 | expect(r.data.slug).toBe("my-agent"); | |
| 41 | expect(r.data.name).toBe("My Agent"); | |
| 42 | expect(r.data.kind).toBe("triage"); | |
| 43 | expect(r.data.pricingCentsPerMonth).toBe(500); | |
| 44 | expect(r.data.homepageUrl).toBe("https://example.com"); | |
| 45 | } | |
| 46 | }); | |
| 47 | ||
| 48 | it("rejects slug shorter than 3 chars", () => { | |
| 49 | const r = parseListingForm({ ...validInput, slug: "ab" }); | |
| 50 | expect(r.ok).toBe(false); | |
| 51 | }); | |
| 52 | ||
| 53 | it("rejects slug starting with digit", () => { | |
| 54 | const r = parseListingForm({ ...validInput, slug: "1abc" }); | |
| 55 | expect(r.ok).toBe(false); | |
| 56 | }); | |
| 57 | ||
| 58 | it("rejects slug with uppercase", () => { | |
| 59 | const r = parseListingForm({ ...validInput, slug: "MyAgent" }); | |
| 60 | expect(r.ok).toBe(false); | |
| 61 | }); | |
| 62 | ||
| 63 | it("accepts slug with hyphens and digits", () => { | |
| 64 | const r = parseListingForm({ ...validInput, slug: "my-agent-v2" }); | |
| 65 | expect(r.ok).toBe(true); | |
| 66 | }); | |
| 67 | ||
| 68 | it("rejects empty name", () => { | |
| 69 | const r = parseListingForm({ ...validInput, name: " " }); | |
| 70 | expect(r.ok).toBe(false); | |
| 71 | }); | |
| 72 | ||
| 73 | it("rejects name over 100 chars", () => { | |
| 74 | const r = parseListingForm({ ...validInput, name: "x".repeat(101) }); | |
| 75 | expect(r.ok).toBe(false); | |
| 76 | }); | |
| 77 | ||
| 78 | it("rejects empty tagline", () => { | |
| 79 | const r = parseListingForm({ ...validInput, tagline: "" }); | |
| 80 | expect(r.ok).toBe(false); | |
| 81 | }); | |
| 82 | ||
| 83 | it("rejects tagline > TAGLINE_MAX", () => { | |
| 84 | const r = parseListingForm({ | |
| 85 | ...validInput, | |
| 86 | tagline: "x".repeat(TAGLINE_MAX + 1), | |
| 87 | }); | |
| 88 | expect(r.ok).toBe(false); | |
| 89 | }); | |
| 90 | ||
| 91 | it("silently truncates description to DESCRIPTION_MAX", () => { | |
| 92 | const r = parseListingForm({ | |
| 93 | ...validInput, | |
| 94 | description: "x".repeat(DESCRIPTION_MAX + 500), | |
| 95 | }); | |
| 96 | expect(r.ok).toBe(true); | |
| 97 | if (r.ok) { | |
| 98 | expect(r.data.description.length).toBe(DESCRIPTION_MAX); | |
| 99 | } | |
| 100 | }); | |
| 101 | ||
| 102 | it("rejects unknown kind", () => { | |
| 103 | const r = parseListingForm({ ...validInput, kind: "hackerman" }); | |
| 104 | expect(r.ok).toBe(false); | |
| 105 | }); | |
| 106 | ||
| 107 | it("accepts every allowed kind", () => { | |
| 108 | for (const k of ALLOWED_LISTING_KINDS) { | |
| 109 | const r = parseListingForm({ ...validInput, kind: k }); | |
| 110 | expect(r.ok).toBe(true); | |
| 111 | } | |
| 112 | }); | |
| 113 | ||
| 114 | it("rejects negative pricing", () => { | |
| 115 | const r = parseListingForm({ | |
| 116 | ...validInput, | |
| 117 | pricing_cents_per_month: "-5", | |
| 118 | }); | |
| 119 | expect(r.ok).toBe(false); | |
| 120 | }); | |
| 121 | ||
| 122 | it("rejects non-numeric pricing", () => { | |
| 123 | const r = parseListingForm({ | |
| 124 | ...validInput, | |
| 125 | pricing_cents_per_month: "abc", | |
| 126 | }); | |
| 127 | // parseInt -> NaN, isFinite false, rejected. | |
| 128 | expect(r.ok).toBe(false); | |
| 129 | }); | |
| 130 | ||
| 131 | it("caps pricing at PRICING_MAX_CENTS", () => { | |
| 132 | const r = parseListingForm({ | |
| 133 | ...validInput, | |
| 134 | pricing_cents_per_month: "999999999", | |
| 135 | }); | |
| 136 | expect(r.ok).toBe(true); | |
| 137 | if (r.ok) expect(r.data.pricingCentsPerMonth).toBe(PRICING_MAX_CENTS); | |
| 138 | }); | |
| 139 | ||
| 140 | it("nullifies invalid homepage URLs", () => { | |
| 141 | const r = parseListingForm({ | |
| 142 | ...validInput, | |
| 143 | homepage_url: "javascript:alert(1)", | |
| 144 | }); | |
| 145 | expect(r.ok).toBe(true); | |
| 146 | if (r.ok) expect(r.data.homepageUrl).toBeNull(); | |
| 147 | }); | |
| 148 | ||
| 149 | it("keeps https homepage URLs", () => { | |
| 150 | const r = parseListingForm({ | |
| 151 | ...validInput, | |
| 152 | homepage_url: "https://safe.example.com/path", | |
| 153 | }); | |
| 154 | expect(r.ok).toBe(true); | |
| 155 | if (r.ok) expect(r.data.homepageUrl).toBe("https://safe.example.com/path"); | |
| 156 | }); | |
| 157 | ||
| 158 | it("default pricing is 0 when omitted", () => { | |
| 159 | const { pricing_cents_per_month, ...rest } = validInput; | |
| 160 | void pricing_cents_per_month; | |
| 161 | const r = parseListingForm(rest as ListingFormInput); | |
| 162 | expect(r.ok).toBe(true); | |
| 163 | if (r.ok) expect(r.data.pricingCentsPerMonth).toBe(0); | |
| 164 | }); | |
| 165 | }); | |
| 166 | ||
| 167 | // --------------------------------------------------------------------------- | |
| 168 | // Constants are sane | |
| 169 | // --------------------------------------------------------------------------- | |
| 170 | ||
| 171 | describe("agent-marketplace — constants", () => { | |
| 172 | it("SLUG_RE matches typical slugs", () => { | |
| 173 | expect(SLUG_RE.test("my-agent")).toBe(true); | |
| 174 | expect(SLUG_RE.test("abc")).toBe(true); | |
| 175 | expect(SLUG_RE.test("A")).toBe(false); | |
| 176 | }); | |
| 177 | it("ALLOWED_LISTING_KINDS contains every expected kind", () => { | |
| 178 | expect(ALLOWED_LISTING_KINDS).toContain("triage"); | |
| 179 | expect(ALLOWED_LISTING_KINDS).toContain("fix"); | |
| 180 | expect(ALLOWED_LISTING_KINDS).toContain("heal_bot"); | |
| 181 | expect(ALLOWED_LISTING_KINDS).toContain("deploy_watch"); | |
| 182 | }); | |
| 183 | }); | |
| 184 | ||
| 185 | // --------------------------------------------------------------------------- | |
| 186 | // Route auth smokes | |
| 187 | // --------------------------------------------------------------------------- | |
| 188 | ||
| 189 | describe("agent-marketplace — route auth smokes", () => { | |
| 190 | it("GET /marketplace/agents (public) → 200", async () => { | |
| 191 | const res = await app.fetch(new Request("http://test/marketplace/agents")); | |
| 192 | expect(res.status).toBe(200); | |
| 193 | }); | |
| 194 | ||
| 195 | it("POST /marketplace/agents/:slug/install without session → 302 /login", async () => { | |
| 196 | const res = await app.fetch( | |
| 197 | new Request("http://test/marketplace/agents/any/install", { | |
| 198 | method: "POST", | |
| 199 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 200 | body: new URLSearchParams({ repo_id: "x" }), | |
| 201 | }) | |
| 202 | ); | |
| 203 | expect(res.status).toBe(302); | |
| 204 | expect(res.headers.get("location")).toMatch(/\/login/); | |
| 205 | }); | |
| 206 | ||
| 207 | it("POST /marketplace/agents/:slug/uninstall without session → 302 /login", async () => { | |
| 208 | const res = await app.fetch( | |
| 209 | new Request("http://test/marketplace/agents/any/uninstall", { | |
| 210 | method: "POST", | |
| 211 | }) | |
| 212 | ); | |
| 213 | expect(res.status).toBe(302); | |
| 214 | expect(res.headers.get("location")).toMatch(/\/login/); | |
| 215 | }); | |
| 216 | ||
| 217 | it("GET /settings/agent-listings without session → 302 /login", async () => { | |
| 218 | const res = await app.fetch( | |
| 219 | new Request("http://test/settings/agent-listings") | |
| 220 | ); | |
| 221 | expect(res.status).toBe(302); | |
| 222 | expect(res.headers.get("location")).toMatch(/\/login/); | |
| 223 | }); | |
| 224 | ||
| 225 | it("POST /settings/agent-listings without session → 302 /login", async () => { | |
| 226 | const res = await app.fetch( | |
| 227 | new Request("http://test/settings/agent-listings", { method: "POST" }) | |
| 228 | ); | |
| 229 | expect(res.status).toBe(302); | |
| 230 | expect(res.headers.get("location")).toMatch(/\/login/); | |
| 231 | }); | |
| 232 | ||
| 233 | it("POST /settings/agent-listings/:id/publish without session → 302 /login", async () => { | |
| 234 | const res = await app.fetch( | |
| 235 | new Request( | |
| 236 | "http://test/settings/agent-listings/00000000-0000-0000-0000-000000000000/publish", | |
| 237 | { method: "POST" } | |
| 238 | ) | |
| 239 | ); | |
| 240 | expect(res.status).toBe(302); | |
| 241 | expect(res.headers.get("location")).toMatch(/\/login/); | |
| 242 | }); | |
| 243 | ||
| 244 | it("GET /admin/marketplace/agents without session → 302 /login", async () => { | |
| 245 | const res = await app.fetch( | |
| 246 | new Request("http://test/admin/marketplace/agents") | |
| 247 | ); | |
| 248 | expect(res.status).toBe(302); | |
| 249 | expect(res.headers.get("location")).toMatch(/\/login/); | |
| 250 | }); | |
| 251 | }); |