Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.

agent-marketplace.test.tsBlame376 lines · 1 contributor
80a3c42Claude1/**
2 * Agent marketplace — listings catalog, install flow, reviews.
3 *
4 * Two layers, same shape as agent-multiplayer.test.ts:
5 * - Pure helpers (slug, price, gradient, revenue split, category guard)
6 * run unconditionally — no DB required.
7 * - DB-backed flows (listListings filters/sort, installListing wiring,
8 * review insert + rating aggregation) are gated behind `HAS_DB`.
9 */
10
11import { describe, it, expect } from "bun:test";
12import { randomBytes } from "crypto";
13import {
14 MARKETPLACE_CATEGORIES,
15 MARKETPLACE_REVENUE_SPLIT_BPS,
16 formatPrice,
17 gradientForSlug,
18 isValidCategory,
19 isValidPricingModel,
20 listingInitials,
21 slugifyListing,
22 splitRevenueCents,
23} from "../lib/agent-marketplace";
24
25const HAS_DB = Boolean(process.env.DATABASE_URL);
26
27// ---------------------------------------------------------------------------
28// Pure helpers
29// ---------------------------------------------------------------------------
30
31describe("agent-marketplace — pure helpers", () => {
32 it("slugifyListing lower-cases, dashes, caps at 60 chars", () => {
33 expect(slugifyListing("Gluecron AI Reviewer")).toBe("gluecron-ai-reviewer");
34 expect(slugifyListing(" Test Generator Bot ")).toBe(
35 "test-generator-bot"
36 );
37 expect(slugifyListing("Docs! & Drift?? Watcher 99")).toBe(
38 "docs-drift-watcher-99"
39 );
40 const long = "a".repeat(120);
41 expect(slugifyListing(long).length).toBe(60);
42 expect(slugifyListing(" --- ")).toBe("");
43 });
44
45 it("formatPrice renders 'Free' for free listings even when price > 0", () => {
46 expect(formatPrice(0, "free")).toBe("Free");
47 expect(formatPrice(500, "free")).toBe("Free");
48 expect(formatPrice(0, "per_invocation")).toBe("Free");
49 expect(formatPrice(125, "per_invocation")).toBe("$1.25/run");
50 expect(formatPrice(500, "per_repo_per_month")).toBe("$5.00/repo/mo");
51 expect(formatPrice(999, "per_invocation")).toBe("$9.99/run");
52 });
53
54 it("isValidCategory + isValidPricingModel guard the closed vocabularies", () => {
55 expect(isValidCategory("reviewer")).toBe(true);
56 expect(isValidCategory("security")).toBe(true);
57 expect(isValidCategory("CUSTOM")).toBe(false); // case-sensitive
58 expect(isValidCategory("malware")).toBe(false);
59 expect(isValidCategory(123)).toBe(false);
60
61 expect(isValidPricingModel("free")).toBe(true);
62 expect(isValidPricingModel("per_invocation")).toBe(true);
63 expect(isValidPricingModel("monthly")).toBe(false);
64 });
65
66 it("splitRevenueCents takes 30% to platform, 70% to publisher", () => {
67 expect(MARKETPLACE_REVENUE_SPLIT_BPS).toBe(3000);
68 expect(splitRevenueCents(0)).toEqual({
69 platformCents: 0,
70 publisherCents: 0,
71 });
72 expect(splitRevenueCents(100)).toEqual({
73 platformCents: 30,
74 publisherCents: 70,
75 });
76 expect(splitRevenueCents(1000)).toEqual({
77 platformCents: 300,
78 publisherCents: 700,
79 });
80 // Uneven cents — platform rounds down so publisher keeps the dust.
81 const r = splitRevenueCents(33);
82 expect(r.platformCents + r.publisherCents).toBe(33);
83 expect(r.platformCents).toBeLessThanOrEqual(10);
84 });
85
86 it("gradientForSlug is deterministic + within the palette", () => {
87 const a = gradientForSlug("foo-bar");
88 const b = gradientForSlug("foo-bar");
89 expect(a).toBe(b);
90 expect(a.startsWith("linear-gradient")).toBe(true);
91 });
92
93 it("listingInitials picks two-word initials, falls back to first 2 chars", () => {
94 expect(listingInitials("Gluecron AI Reviewer")).toBe("GA");
95 expect(listingInitials("test-generator-bot")).toBe("TG");
96 expect(listingInitials("Docs")).toBe("DO");
97 });
98
99 it("MARKETPLACE_CATEGORIES contains the six advertised values", () => {
100 expect(MARKETPLACE_CATEGORIES).toEqual([
101 "reviewer",
102 "tester",
103 "migrator",
104 "security",
105 "docs",
106 "custom",
107 ]);
108 });
109});
110
111// ---------------------------------------------------------------------------
112// DB-backed flows
113// ---------------------------------------------------------------------------
114
115describe.skipIf(!HAS_DB)("agent-marketplace — DB flows", () => {
116 it("listListings filters by category + sorts", async () => {
117 const { db } = await import("../db");
118 const { users, agentMarketplaceListings } = await import("../db/schema");
119 const { listListings, createListing, approveListing } = await import(
120 "../lib/agent-marketplace"
121 );
122
123 const stamp = randomBytes(4).toString("hex");
124 const [u] = await db
125 .insert(users)
126 .values({
127 username: `pub-${stamp}`,
128 email: `pub-${stamp}@example.com`,
129 passwordHash: "x",
130 })
131 .returning();
132 if (!u) throw new Error("user insert failed");
133
134 const a = await createListing({
135 publisherUserId: u.id,
136 name: `Reviewer ${stamp} A`,
137 category: "reviewer",
138 initialStatus: "approved",
139 });
140 const b = await createListing({
141 publisherUserId: u.id,
142 name: `Tester ${stamp} B`,
143 category: "tester",
144 initialStatus: "approved",
145 });
146 expect(a).not.toBeNull();
147 expect(b).not.toBeNull();
148
149 const reviewers = await listListings({ category: "reviewer" });
150 const reviewerSlugs = reviewers.map((r) => r.slug);
151 expect(reviewerSlugs).toContain(a!.slug);
152 expect(reviewerSlugs).not.toContain(b!.slug);
153
154 // sort=new should land the most-recently-created at the top within
155 // the current creation window — verify ordering relation only.
156 const newest = await listListings({ sort: "new" });
157 const aIdx = newest.findIndex((r) => r.slug === a!.slug);
158 const bIdx = newest.findIndex((r) => r.slug === b!.slug);
159 if (aIdx >= 0 && bIdx >= 0) {
160 // b was created after a, so b should appear at or before a.
161 expect(bIdx).toBeLessThanOrEqual(aIdx);
162 }
163
164 // search hits tagline + name (case-insensitive ilike).
165 const searched = await listListings({ search: stamp });
166 expect(searched.length).toBeGreaterThanOrEqual(2);
167
168 // status filter: pending_review listings are hidden by default.
169 const hidden = await createListing({
170 publisherUserId: u.id,
171 name: `Pending ${stamp}`,
172 category: "custom",
173 });
174 expect(hidden).not.toBeNull();
175 expect(hidden!.status).toBe("pending_review");
176 const approved = await listListings({ search: stamp });
177 expect(approved.find((r) => r.slug === hidden!.slug)).toBeUndefined();
178 const all = await listListings({ search: stamp, status: "any" });
179 expect(all.find((r) => r.slug === hidden!.slug)).toBeDefined();
180
181 // approveListing flips status
182 const flipped = await approveListing(hidden!.slug, u.id);
183 expect(flipped?.status).toBe("approved");
184
185 // cleanup
186 await db
187 .delete(agentMarketplaceListings)
188 .where(eq(agentMarketplaceListings.publisherUserId, u.id));
189 await db.delete(users).where(eq(users.id, u.id));
190 });
191
192 it("installListing creates an agent_session with the listing's namespace", async () => {
193 const { db } = await import("../db");
194 const {
195 users,
196 repositories,
197 agentSessions,
198 agentMarketplaceListings,
199 agentMarketplaceInstalls,
200 } = await import("../db/schema");
201 const { createListing, installListing, uninstallListing } = await import(
202 "../lib/agent-marketplace"
203 );
204
205 const stamp = randomBytes(4).toString("hex");
206 const [u] = await db
207 .insert(users)
208 .values({
209 username: `inst-${stamp}`,
210 email: `inst-${stamp}@example.com`,
211 passwordHash: "x",
212 })
213 .returning();
214 if (!u) throw new Error("user insert failed");
215
216 const [repo] = await db
217 .insert(repositories)
218 .values({
219 name: `r-${stamp}`,
220 ownerId: u.id,
221 diskPath: `/tmp/r-${stamp}`,
222 })
223 .returning();
224 if (!repo) throw new Error("repo insert failed");
225
226 const listing = await createListing({
227 publisherUserId: u.id,
228 name: `Installable ${stamp}`,
229 category: "reviewer",
230 initialStatus: "approved",
231 agentTemplate: {
232 branchNamespace: `agents/marketplace-${stamp}`,
233 budgetCentsPerDay: 750,
234 },
235 });
236 if (!listing) throw new Error("listing insert failed");
237
238 const result = await installListing({
239 listingId: listing.id,
240 repositoryId: repo.id,
241 installedByUserId: u.id,
242 });
243 expect(result).not.toBeNull();
244 expect(result!.agentToken.startsWith("agt_")).toBe(true);
245 expect(result!.install.agentSessionId).toBeTruthy();
246
247 const [sess] = await db
248 .select()
249 .from(agentSessions)
250 .where(eq(agentSessions.id, result!.install.agentSessionId!))
251 .limit(1);
252 expect(sess).toBeDefined();
253 expect(sess!.budgetCentsPerDay).toBe(750);
254 expect(sess!.branchNamespace).toBe(`agents/marketplace-${stamp}/`);
255 expect(sess!.repositoryId).toBe(repo.id);
256
257 // Second install on the same repo must be rejected by the unique index.
258 const dup = await installListing({
259 listingId: listing.id,
260 repositoryId: repo.id,
261 installedByUserId: u.id,
262 });
263 expect(dup).toBeNull();
264
265 // Uninstall revokes the agent_session.
266 const ok = await uninstallListing({ installId: result!.install.id });
267 expect(ok).toBe(true);
268 const [sessAfter] = await db
269 .select()
270 .from(agentSessions)
271 .where(eq(agentSessions.id, result!.install.agentSessionId!))
272 .limit(1);
273 expect(sessAfter).toBeUndefined();
274
275 // cleanup
276 await db
277 .delete(agentMarketplaceInstalls)
278 .where(eq(agentMarketplaceInstalls.listingId, listing.id));
279 await db
280 .delete(agentMarketplaceListings)
281 .where(eq(agentMarketplaceListings.id, listing.id));
282 await db.delete(repositories).where(eq(repositories.id, repo.id));
283 await db.delete(users).where(eq(users.id, u.id));
284 });
285
286 it("recordReview inserts the row and updates rating_avg / rating_count", async () => {
287 const { db } = await import("../db");
288 const { users, agentMarketplaceListings, agentMarketplaceReviews } =
289 await import("../db/schema");
290 const { createListing, recordReview, getListing } = await import(
291 "../lib/agent-marketplace"
292 );
293
294 const stamp = randomBytes(4).toString("hex");
295 const [pub] = await db
296 .insert(users)
297 .values({
298 username: `rev-pub-${stamp}`,
299 email: `rev-pub-${stamp}@example.com`,
300 passwordHash: "x",
301 })
302 .returning();
303 const [r1] = await db
304 .insert(users)
305 .values({
306 username: `rev-r1-${stamp}`,
307 email: `rev-r1-${stamp}@example.com`,
308 passwordHash: "x",
309 })
310 .returning();
311 const [r2] = await db
312 .insert(users)
313 .values({
314 username: `rev-r2-${stamp}`,
315 email: `rev-r2-${stamp}@example.com`,
316 passwordHash: "x",
317 })
318 .returning();
319 if (!pub || !r1 || !r2) throw new Error("user insert failed");
320
321 const listing = await createListing({
322 publisherUserId: pub.id,
323 name: `Reviewable ${stamp}`,
324 category: "tester",
325 initialStatus: "approved",
326 });
327 if (!listing) throw new Error("listing insert failed");
328
329 const a = await recordReview({
330 listingId: listing.id,
331 reviewerUserId: r1.id,
332 rating: 5,
333 body: "great",
334 });
335 expect(a).not.toBeNull();
336 expect(a!.rating).toBe(5);
337
338 const b = await recordReview({
339 listingId: listing.id,
340 reviewerUserId: r2.id,
341 rating: 3,
342 body: "ok",
343 });
344 expect(b).not.toBeNull();
345
346 // Out-of-range ratings clamp into [1..5].
347 const clamped = await recordReview({
348 listingId: listing.id,
349 reviewerUserId: r2.id,
350 rating: 99,
351 body: "loud",
352 });
353 expect(clamped!.rating).toBe(5);
354
355 const detail = await getListing(listing.slug);
356 expect(detail).not.toBeNull();
357 expect(detail!.listing.ratingCount).toBe(3);
358 // (5 + 3 + 5) / 3 = 4.33
359 expect(Number(detail!.listing.ratingAvg)).toBeCloseTo(4.33, 1);
360 expect(detail!.reviews.length).toBe(3);
361
362 // cleanup
363 await db
364 .delete(agentMarketplaceReviews)
365 .where(eq(agentMarketplaceReviews.listingId, listing.id));
366 await db
367 .delete(agentMarketplaceListings)
368 .where(eq(agentMarketplaceListings.id, listing.id));
369 await db.delete(users).where(eq(users.id, r1.id));
370 await db.delete(users).where(eq(users.id, r2.id));
371 await db.delete(users).where(eq(users.id, pub.id));
372 });
373});
374
375// Imported at the bottom so the no-DB pure block above doesn't pull drizzle.
376import { eq } from "drizzle-orm";