feat(platform): marketplace module — types, repository, 52 tests passing

This commit is contained in:
saravanakumardb1 2026-03-01 08:01:45 -08:00
parent 383a8dad32
commit 761a0c17f4
3 changed files with 776 additions and 0 deletions

View File

@ -0,0 +1,415 @@
import { describe, it, expect } from 'vitest';
import {
LISTING_STATUSES,
LISTING_CATEGORIES,
CERTIFICATION_STATUSES,
REPORT_STATUSES,
REPORT_REASONS,
CreateListingSchema,
UpdateListingSchema,
CreateReviewSchema,
CreateReportSchema,
ResolveReportSchema,
CertificationDecisionSchema,
BrowseCatalogQuerySchema,
ListQuerySchema,
} from './types.js';
import {
buildListingDoc,
applyListingUpdate,
buildReviewDoc,
buildInstallDoc,
buildCertificationDoc,
buildReportDoc,
buildCatalogSortClause,
buildCatalogWhereClause,
buildListWhereClause,
} from './repository.js';
// ── Constants ───────────────────────────────────────────────
describe('marketplace constants', () => {
it('has expected listing statuses', () => {
expect(LISTING_STATUSES).toEqual(['draft', 'pending', 'published', 'suspended']);
});
it('has expected listing categories', () => {
expect(LISTING_CATEGORIES.length).toBe(9);
expect(LISTING_CATEGORIES).toContain('coaching');
expect(LISTING_CATEGORIES).toContain('other');
});
it('has expected certification statuses', () => {
expect(CERTIFICATION_STATUSES).toEqual(['pending', 'approved', 'rejected']);
});
it('has expected report statuses', () => {
expect(REPORT_STATUSES).toEqual(['open', 'investigating', 'resolved', 'dismissed']);
});
it('has expected report reasons', () => {
expect(REPORT_REASONS.length).toBe(6);
expect(REPORT_REASONS).toContain('inappropriate_content');
expect(REPORT_REASONS).toContain('safety_concern');
});
});
// ── CreateListingSchema ─────────────────────────────────────
describe('CreateListingSchema', () => {
const validInput = {
title: 'Test Agent',
description: 'A test coaching agent.',
category: 'coaching' as const,
agentConfig: { name: 'Test', role: 'Tester' },
};
it('accepts minimal valid input with defaults', () => {
const result = CreateListingSchema.parse(validInput);
expect(result.title).toBe('Test Agent');
expect(result.tags).toEqual([]);
expect(result.version).toBe('1.0.0');
});
it('accepts full input with all fields', () => {
const result = CreateListingSchema.parse({
...validInput,
tags: ['coaching', 'leadership'],
version: '2.0.0',
});
expect(result.tags).toEqual(['coaching', 'leadership']);
expect(result.version).toBe('2.0.0');
});
it('rejects missing title', () => {
expect(() => CreateListingSchema.parse({ ...validInput, title: undefined })).toThrow();
});
it('rejects missing description', () => {
expect(() => CreateListingSchema.parse({ ...validInput, description: undefined })).toThrow();
});
it('rejects missing category', () => {
expect(() => CreateListingSchema.parse({ ...validInput, category: undefined })).toThrow();
});
it('rejects invalid category', () => {
expect(() => CreateListingSchema.parse({ ...validInput, category: 'invalid' })).toThrow();
});
it('rejects missing agentConfig', () => {
expect(() => CreateListingSchema.parse({ ...validInput, agentConfig: undefined })).toThrow();
});
it('rejects title exceeding max length', () => {
expect(() => CreateListingSchema.parse({ ...validInput, title: 'x'.repeat(201) })).toThrow();
});
it('rejects too many tags', () => {
const tags = Array.from({ length: 11 }, (_, i) => `tag${i}`);
expect(() => CreateListingSchema.parse({ ...validInput, tags })).toThrow();
});
});
// ── UpdateListingSchema ─────────────────────────────────────
describe('UpdateListingSchema', () => {
it('accepts partial update with single field', () => {
const result = UpdateListingSchema.parse({ title: 'Updated' });
expect(result.title).toBe('Updated');
expect(result.description).toBeUndefined();
});
it('accepts empty update (no fields)', () => {
const result = UpdateListingSchema.parse({});
expect(Object.keys(result)).toHaveLength(0);
});
it('rejects invalid category in update', () => {
expect(() => UpdateListingSchema.parse({ category: 'invalid' })).toThrow();
});
});
// ── CreateReviewSchema ──────────────────────────────────────
describe('CreateReviewSchema', () => {
it('accepts valid review', () => {
const result = CreateReviewSchema.parse({ rating: 5, comment: 'Great agent!' });
expect(result.rating).toBe(5);
expect(result.comment).toBe('Great agent!');
});
it('rejects rating below 1', () => {
expect(() => CreateReviewSchema.parse({ rating: 0, comment: 'Bad' })).toThrow();
});
it('rejects rating above 5', () => {
expect(() => CreateReviewSchema.parse({ rating: 6, comment: 'Too high' })).toThrow();
});
it('rejects empty comment', () => {
expect(() => CreateReviewSchema.parse({ rating: 3, comment: '' })).toThrow();
});
it('rejects comment exceeding max length', () => {
expect(() => CreateReviewSchema.parse({ rating: 3, comment: 'x'.repeat(2001) })).toThrow();
});
});
// ── CreateReportSchema ──────────────────────────────────────
describe('CreateReportSchema', () => {
it('accepts valid report', () => {
const result = CreateReportSchema.parse({
reason: 'spam',
details: 'This listing is spam.',
});
expect(result.reason).toBe('spam');
});
it('rejects invalid reason', () => {
expect(() => CreateReportSchema.parse({ reason: 'invalid', details: 'test' })).toThrow();
});
it('rejects empty details', () => {
expect(() => CreateReportSchema.parse({ reason: 'spam', details: '' })).toThrow();
});
});
// ── ResolveReportSchema ─────────────────────────────────────
describe('ResolveReportSchema', () => {
it('accepts valid resolution', () => {
const result = ResolveReportSchema.parse({ resolution: 'Listing suspended.' });
expect(result.resolution).toBe('Listing suspended.');
});
it('rejects empty resolution', () => {
expect(() => ResolveReportSchema.parse({ resolution: '' })).toThrow();
});
});
// ── CertificationDecisionSchema ─────────────────────────────
describe('CertificationDecisionSchema', () => {
it('accepts with notes', () => {
const result = CertificationDecisionSchema.parse({ notes: 'Looks good.' });
expect(result.notes).toBe('Looks good.');
});
it('defaults notes to empty string', () => {
const result = CertificationDecisionSchema.parse({});
expect(result.notes).toBe('');
});
});
// ── BrowseCatalogQuerySchema ────────────────────────────────
describe('BrowseCatalogQuerySchema', () => {
it('applies defaults for empty query', () => {
const result = BrowseCatalogQuerySchema.parse({});
expect(result.sort).toBe('popular');
expect(result.limit).toBe(20);
expect(result.offset).toBe(0);
});
it('accepts category filter', () => {
const result = BrowseCatalogQuerySchema.parse({ category: 'coaching' });
expect(result.category).toBe('coaching');
});
it('accepts search term', () => {
const result = BrowseCatalogQuerySchema.parse({ search: 'interview' });
expect(result.search).toBe('interview');
});
it('rejects invalid sort', () => {
expect(() => BrowseCatalogQuerySchema.parse({ sort: 'invalid' })).toThrow();
});
it('rejects limit exceeding max', () => {
expect(() => BrowseCatalogQuerySchema.parse({ limit: 101 })).toThrow();
});
});
// ── ListQuerySchema ─────────────────────────────────────────
describe('ListQuerySchema', () => {
it('applies defaults for empty query', () => {
const result = ListQuerySchema.parse({});
expect(result.limit).toBe(50);
expect(result.offset).toBe(0);
});
it('accepts status filter', () => {
const result = ListQuerySchema.parse({ status: 'pending' });
expect(result.status).toBe('pending');
});
it('rejects invalid status', () => {
expect(() => ListQuerySchema.parse({ status: 'invalid' })).toThrow();
});
});
// ── Repository Builders ─────────────────────────────────────
describe('buildListingDoc', () => {
it('creates a listing with correct fields', () => {
const doc = buildListingDoc(
{
title: 'Test',
description: 'Desc',
category: 'coaching',
agentConfig: { name: 'Test' },
tags: ['tag1'],
version: '1.0.0',
},
'user_1',
'jarvisjr'
);
expect(doc.id).toMatch(/^listing_/);
expect(doc.authorId).toBe('user_1');
expect(doc.productId).toBe('jarvisjr');
expect(doc.status).toBe('draft');
expect(doc.downloads).toBe(0);
expect(doc.rating).toBe(0);
expect(doc.isFeatured).toBe(false);
expect(doc.isVerified).toBe(false);
});
});
describe('applyListingUpdate', () => {
it('updates specified fields only', () => {
const original = buildListingDoc(
{
title: 'Original',
description: 'Desc',
category: 'coaching',
agentConfig: {},
tags: [],
version: '1.0.0',
},
'user_1',
'jarvisjr'
);
const updated = applyListingUpdate(original, { title: 'Updated' });
expect(updated.title).toBe('Updated');
expect(updated.description).toBe('Desc');
expect(updated.updatedAt).toBeTruthy();
expect(new Date(updated.updatedAt).toISOString()).toBe(updated.updatedAt);
});
});
describe('buildReviewDoc', () => {
it('creates a review with correct fields', () => {
const doc = buildReviewDoc({ rating: 4, comment: 'Good' }, 'listing_1', 'user_1', 'jarvisjr');
expect(doc.id).toMatch(/^review_/);
expect(doc.listingId).toBe('listing_1');
expect(doc.rating).toBe(4);
});
});
describe('buildInstallDoc', () => {
it('creates an install with correct fields', () => {
const doc = buildInstallDoc('listing_1', 'user_1', 'jarvisjr');
expect(doc.id).toMatch(/^install_/);
expect(doc.listingId).toBe('listing_1');
expect(doc.userId).toBe('user_1');
});
});
describe('buildCertificationDoc', () => {
it('creates a certification with pending status', () => {
const doc = buildCertificationDoc('listing_1', 'jarvisjr');
expect(doc.id).toMatch(/^cert_/);
expect(doc.status).toBe('pending');
expect(doc.reviewerId).toBeNull();
});
});
describe('buildReportDoc', () => {
it('creates a report with open status', () => {
const doc = buildReportDoc(
{ reason: 'spam', details: 'This is spam.' },
'listing_1',
'user_1',
'jarvisjr'
);
expect(doc.id).toMatch(/^report_/);
expect(doc.status).toBe('open');
expect(doc.resolvedBy).toBeNull();
});
});
// ── Query Helpers ───────────────────────────────────────────
describe('buildCatalogSortClause', () => {
it('returns downloads DESC for popular', () => {
expect(buildCatalogSortClause('popular')).toContain('downloads DESC');
});
it('returns createdAt DESC for recent', () => {
expect(buildCatalogSortClause('recent')).toContain('createdAt DESC');
});
it('returns rating DESC for rating', () => {
expect(buildCatalogSortClause('rating')).toContain('rating DESC');
});
});
describe('buildCatalogWhereClause', () => {
it('builds basic where clause', () => {
const { where, parameters } = buildCatalogWhereClause(
{ sort: 'popular', limit: 20, offset: 0 },
'jarvisjr'
);
expect(where).toContain('c.productId = @productId');
expect(where).toContain("c.status = 'published'");
expect(parameters).toContainEqual({ name: '@productId', value: 'jarvisjr' });
});
it('adds category filter', () => {
const { where, parameters } = buildCatalogWhereClause(
{ category: 'coaching', sort: 'popular', limit: 20, offset: 0 },
'jarvisjr'
);
expect(where).toContain('c.category = @category');
expect(parameters).toContainEqual({ name: '@category', value: 'coaching' });
});
it('adds search filter', () => {
const { where, parameters } = buildCatalogWhereClause(
{ search: 'interview', sort: 'popular', limit: 20, offset: 0 },
'jarvisjr'
);
expect(where).toContain('CONTAINS(LOWER(c.title), LOWER(@search))');
expect(parameters).toContainEqual({ name: '@search', value: 'interview' });
});
});
describe('buildListWhereClause', () => {
it('builds basic where clause', () => {
const { where, parameters } = buildListWhereClause({ limit: 50, offset: 0 }, 'jarvisjr');
expect(where).toContain('c.productId = @productId');
expect(parameters).toContainEqual({ name: '@productId', value: 'jarvisjr' });
});
it('adds status filter', () => {
const { where, parameters } = buildListWhereClause(
{ status: 'pending', limit: 50, offset: 0 },
'jarvisjr'
);
expect(where).toContain('c.status = @status');
expect(parameters).toContainEqual({ name: '@status', value: 'pending' });
});
it('supports extra conditions', () => {
const { where } = buildListWhereClause(
{ limit: 50, offset: 0 },
'jarvisjr',
['c.authorId = @authorId'],
[{ name: '@authorId', value: 'user_1' }]
);
expect(where).toContain('c.authorId = @authorId');
});
});

View File

@ -0,0 +1,194 @@
/**
* Marketplace repository Cosmos DB operations for listings, reviews, installs,
* certifications, and reports.
*/
import type {
ListingDoc,
ReviewDoc,
InstallDoc,
CertificationDoc,
ReportDoc,
CreateListingInput,
UpdateListingInput,
CreateReviewInput,
CreateReportInput,
BrowseCatalogQuery,
ListQuery,
} from './types.js';
// ── Listings ────────────────────────────────────────────────
export function buildListingDoc(
input: CreateListingInput,
authorId: string,
productId: string
): ListingDoc {
const now = new Date().toISOString();
return {
id: `listing_${crypto.randomUUID()}`,
productId,
authorId,
title: input.title,
description: input.description,
category: input.category,
tags: input.tags ?? [],
agentConfig: input.agentConfig,
status: 'draft',
version: input.version ?? '1.0.0',
downloads: 0,
rating: 0,
reviewCount: 0,
isFeatured: false,
isVerified: false,
createdAt: now,
updatedAt: now,
};
}
export function applyListingUpdate(doc: ListingDoc, input: UpdateListingInput): ListingDoc {
return {
...doc,
...(input.title !== undefined && { title: input.title }),
...(input.description !== undefined && { description: input.description }),
...(input.category !== undefined && { category: input.category }),
...(input.tags !== undefined && { tags: input.tags }),
...(input.agentConfig !== undefined && { agentConfig: input.agentConfig }),
...(input.version !== undefined && { version: input.version }),
updatedAt: new Date().toISOString(),
};
}
// ── Reviews ─────────────────────────────────────────────────
export function buildReviewDoc(
input: CreateReviewInput,
listingId: string,
userId: string,
productId: string
): ReviewDoc {
const now = new Date().toISOString();
return {
id: `review_${crypto.randomUUID()}`,
listingId,
userId,
productId,
rating: input.rating,
comment: input.comment,
createdAt: now,
updatedAt: now,
};
}
// ── Installs ────────────────────────────────────────────────
export function buildInstallDoc(listingId: string, userId: string, productId: string): InstallDoc {
return {
id: `install_${crypto.randomUUID()}`,
listingId,
userId,
productId,
installedAt: new Date().toISOString(),
};
}
// ── Certifications ──────────────────────────────────────────
export function buildCertificationDoc(listingId: string, productId: string): CertificationDoc {
return {
id: `cert_${crypto.randomUUID()}`,
listingId,
productId,
status: 'pending',
reviewerId: null,
notes: '',
submittedAt: new Date().toISOString(),
reviewedAt: null,
};
}
// ── Reports ─────────────────────────────────────────────────
export function buildReportDoc(
input: CreateReportInput,
listingId: string,
reporterId: string,
productId: string
): ReportDoc {
return {
id: `report_${crypto.randomUUID()}`,
listingId,
reporterId,
productId,
reason: input.reason,
details: input.details,
status: 'open',
resolvedBy: null,
resolution: null,
createdAt: new Date().toISOString(),
resolvedAt: null,
};
}
// ── Query Helpers ───────────────────────────────────────────
export function buildCatalogSortClause(sort: BrowseCatalogQuery['sort']): string {
switch (sort) {
case 'popular':
return 'ORDER BY c.downloads DESC';
case 'recent':
return 'ORDER BY c.createdAt DESC';
case 'rating':
return 'ORDER BY c.rating DESC';
default:
return 'ORDER BY c.downloads DESC';
}
}
export function buildCatalogWhereClause(
query: BrowseCatalogQuery,
productId: string
): { where: string; parameters: Array<{ name: string; value: unknown }> } {
const conditions = ['c.productId = @productId', "c.status = 'published'"];
const parameters: Array<{ name: string; value: unknown }> = [
{ name: '@productId', value: productId },
];
if (query.category) {
conditions.push('c.category = @category');
parameters.push({ name: '@category', value: query.category });
}
if (query.search) {
conditions.push('CONTAINS(LOWER(c.title), LOWER(@search))');
parameters.push({ name: '@search', value: query.search });
}
return {
where: conditions.join(' AND '),
parameters,
};
}
export function buildListWhereClause(
query: ListQuery,
productId: string,
extraConditions?: string[],
extraParams?: Array<{ name: string; value: unknown }>
): { where: string; parameters: Array<{ name: string; value: unknown }> } {
const conditions = ['c.productId = @productId', ...(extraConditions ?? [])];
const parameters: Array<{ name: string; value: unknown }> = [
{ name: '@productId', value: productId },
...(extraParams ?? []),
];
if (query.status) {
conditions.push('c.status = @status');
parameters.push({ name: '@status', value: query.status });
}
return {
where: conditions.join(' AND '),
parameters,
};
}

View File

@ -0,0 +1,167 @@
/**
* Generic marketplace types product-agnostic.
* Listings, reviews, installs, certifications, reports.
* Partition key: /productId for listings, /userId for installs/reviews.
*/
import { z } from 'zod';
// ── Constants ───────────────────────────────────────────────
export const LISTING_STATUSES = ['draft', 'pending', 'published', 'suspended'] as const;
export const LISTING_CATEGORIES = [
'coaching',
'language',
'creativity',
'productivity',
'wellness',
'education',
'business',
'entertainment',
'other',
] as const;
export const CERTIFICATION_STATUSES = ['pending', 'approved', 'rejected'] as const;
export const REPORT_STATUSES = ['open', 'investigating', 'resolved', 'dismissed'] as const;
export const REPORT_REASONS = [
'inappropriate_content',
'misleading',
'spam',
'intellectual_property',
'safety_concern',
'other',
] as const;
export type ListingStatus = (typeof LISTING_STATUSES)[number];
export type ListingCategory = (typeof LISTING_CATEGORIES)[number];
export type CertificationStatus = (typeof CERTIFICATION_STATUSES)[number];
export type ReportStatus = (typeof REPORT_STATUSES)[number];
export type ReportReason = (typeof REPORT_REASONS)[number];
// ── Document Interfaces ─────────────────────────────────────
export interface ListingDoc {
id: string;
productId: string;
authorId: string;
title: string;
description: string;
category: ListingCategory;
tags: string[];
agentConfig: Record<string, unknown>;
status: ListingStatus;
version: string;
downloads: number;
rating: number;
reviewCount: number;
isFeatured: boolean;
isVerified: boolean;
createdAt: string;
updatedAt: string;
}
export interface ReviewDoc {
id: string;
listingId: string;
userId: string;
productId: string;
rating: number;
comment: string;
createdAt: string;
updatedAt: string;
}
export interface InstallDoc {
id: string;
listingId: string;
userId: string;
productId: string;
installedAt: string;
}
export interface CertificationDoc {
id: string;
listingId: string;
productId: string;
status: CertificationStatus;
reviewerId: string | null;
notes: string;
submittedAt: string;
reviewedAt: string | null;
}
export interface ReportDoc {
id: string;
listingId: string;
reporterId: string;
productId: string;
reason: ReportReason;
details: string;
status: ReportStatus;
resolvedBy: string | null;
resolution: string | null;
createdAt: string;
resolvedAt: string | null;
}
// ── Zod Schemas ─────────────────────────────────────────────
export const CreateListingSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().min(1).max(5000),
category: z.enum(LISTING_CATEGORIES),
tags: z.array(z.string().max(50)).max(10).default([]),
agentConfig: z.record(z.unknown()),
version: z.string().max(20).default('1.0.0'),
});
export const UpdateListingSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().min(1).max(5000).optional(),
category: z.enum(LISTING_CATEGORIES).optional(),
tags: z.array(z.string().max(50)).max(10).optional(),
agentConfig: z.record(z.unknown()).optional(),
version: z.string().max(20).optional(),
});
export const CreateReviewSchema = z.object({
rating: z.number().min(1).max(5),
comment: z.string().min(1).max(2000),
});
export const CreateReportSchema = z.object({
reason: z.enum(REPORT_REASONS),
details: z.string().min(1).max(2000),
});
export const ResolveReportSchema = z.object({
resolution: z.string().min(1).max(2000),
});
export const CertificationDecisionSchema = z.object({
notes: z.string().max(2000).default(''),
});
export const BrowseCatalogQuerySchema = z.object({
category: z.enum(LISTING_CATEGORIES).optional(),
search: z.string().max(200).optional(),
sort: z.enum(['popular', 'recent', 'rating']).default('popular'),
limit: z.coerce.number().min(1).max(100).default(20),
offset: z.coerce.number().min(0).default(0),
});
export const ListQuerySchema = z.object({
status: z.enum(LISTING_STATUSES).optional(),
limit: z.coerce.number().min(1).max(100).default(50),
offset: z.coerce.number().min(0).default(0),
});
// ── Inferred Types ──────────────────────────────────────────
export type CreateListingInput = z.infer<typeof CreateListingSchema>;
export type UpdateListingInput = z.infer<typeof UpdateListingSchema>;
export type CreateReviewInput = z.infer<typeof CreateReviewSchema>;
export type CreateReportInput = z.infer<typeof CreateReportSchema>;
export type ResolveReportInput = z.infer<typeof ResolveReportSchema>;
export type CertificationDecisionInput = z.infer<typeof CertificationDecisionSchema>;
export type BrowseCatalogQuery = z.infer<typeof BrowseCatalogQuerySchema>;
export type ListQuery = z.infer<typeof ListQuerySchema>;