diff --git a/packages/social/ugig/src/index.test.ts b/packages/social/ugig/src/index.test.ts index 6433f9b6..2dcce184 100644 --- a/packages/social/ugig/src/index.test.ts +++ b/packages/social/ugig/src/index.test.ts @@ -1,8 +1,15 @@ -import { fakeConnectContext, smokeTest } from '@profullstack/sh1pt-core/testing'; +import { contractTestSocial, fakeConnectContext } from '@profullstack/sh1pt-core/testing'; import { afterEach, describe, expect, it, vi } from 'vitest'; import adapter from './index.js'; -smokeTest(adapter, { idPrefix: 'social' }); +contractTestSocial(adapter, { + sampleConfig: { defaultSkills: ['Research'] }, + samplePost: { + title: 'Research one public technical question', + body: 'I will answer one bounded public technical question with primary sources and explicit confidence labels.', + }, + requiredSecrets: ['UGIG_TOKEN'], +}); afterEach(() => { vi.restoreAllMocks(); @@ -27,4 +34,117 @@ describe('social-ugig adapter', () => { }), ); }); + + it('creates a for-hire listing with the current GigInput fields', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + gig: { id: 'gig-123' }, + }), { status: 201, headers: { 'content-type': 'application/json' } })); + const ctx = { + ...fakeConnectContext({ UGIG_TOKEN: 'test-token' }), + dryRun: false, + }; + + const result = await adapter.post(ctx as any, { + title: 'Review one TypeScript script for bugs', + body: 'I will review one public TypeScript script and return exact file and line findings with a corrected patch.', + hashtags: ['ignored-when-default-skills-exist'], + link: 'https://github.com/example/repository/pull/1', + }, { + defaultCategory: 'Development', + defaultSkills: ['TypeScript', 'Code Review'], + defaultAiTools: ['Codex'], + defaultPriceCents: 2500, + paymentCoin: 'USDC', + duration: '6 hours', + }); + + expect(result).toEqual({ + id: 'gig-123', + url: 'https://ugig.net/gigs/gig-123', + platform: 'ugig', + publishedAt: expect.any(String), + }); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://ugig.net/api/gigs'); + expect((init as RequestInit).method).toBe('POST'); + expect((init as RequestInit).headers).toMatchObject({ + Authorization: 'Bearer test-token', + 'Content-Type': 'application/json', + }); + expect(JSON.parse(String((init as RequestInit).body))).toEqual({ + listing_type: 'for_hire', + title: 'Review one TypeScript script for bugs', + description: 'I will review one public TypeScript script and return exact file and line findings with a corrected patch.\n\nhttps://github.com/example/repository/pull/1', + category: 'Development', + skills_required: ['TypeScript', 'Code Review'], + ai_tools_preferred: ['Codex'], + budget_type: 'fixed', + budget_min: 25, + budget_max: 25, + payment_coin: 'USDC', + duration: '6 hours', + location_type: 'remote', + status: 'active', + }); + }); + + it('uses hashtags as required skills and omits a negotiable price', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + id: 'gig-456', + }), { status: 201, headers: { 'content-type': 'application/json' } })); + const ctx = { + ...fakeConnectContext({ UGIG_TOKEN: 'test-token' }), + dryRun: false, + }; + + await adapter.post(ctx as any, { + title: 'Research one public technical question', + body: 'I will answer one bounded public technical question with authoritative sources and a concise conclusion.', + hashtags: ['#research', 'source-verification'], + }, { listingType: 'hiring' }); + + const payload = JSON.parse(String((fetchMock.mock.calls[0]?.[1] as RequestInit).body)); + expect(payload.listing_type).toBe('hiring'); + expect(payload.skills_required).toEqual(['research', 'source-verification']); + expect(payload).not.toHaveProperty('price_cents'); + expect(payload).not.toHaveProperty('budget_min'); + expect(payload).not.toHaveProperty('budget_max'); + expect(payload).not.toHaveProperty('content'); + expect(payload).not.toHaveProperty('tags'); + }); + + it('rejects values below the current uGig minimums before calling the API', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch'); + const ctx = { + ...fakeConnectContext({ UGIG_TOKEN: 'test-token' }), + dryRun: false, + }; + + await expect(adapter.post(ctx as any, { + title: 'Too short', + body: 'This description is long enough to isolate the title validation branch for this regression test.', + }, {})).rejects.toThrow('title must be at least 10 characters'); + + await expect(adapter.post(ctx as any, { + title: 'A valid listing title', + body: 'Too short', + }, {})).rejects.toThrow('description must be at least 50 characters'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('redacts the bearer token from API errors', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response( + 'validation failed for bearer test-token', + { status: 400 }, + )); + const ctx = { + ...fakeConnectContext({ UGIG_TOKEN: 'test-token' }), + dryRun: false, + }; + + await expect(adapter.post(ctx as any, { + title: 'A valid listing title', + body: 'This description is intentionally longer than fifty characters so the request reaches the mocked API.', + }, {})).rejects.toThrow('validation failed for bearer [redacted]'); + }); }); diff --git a/packages/social/ugig/src/index.ts b/packages/social/ugig/src/index.ts index 9763753c..8d468c65 100644 --- a/packages/social/ugig/src/index.ts +++ b/packages/social/ugig/src/index.ts @@ -21,14 +21,24 @@ interface Config { username?: string; /** Default price in cents for gigs (0 = negotiate). */ defaultPriceCents?: number; - /** Default category for gigs: 'research'|'content-writing'|'seo'|'technical-documentation'|'data-analysis'|'code-review'|'other' */ + /** Default category for gigs (for example 'Development' or 'Research'). */ defaultCategory?: string; + /** Skills sent to uGig when a post has no hashtags. At least one is required. */ + defaultSkills?: string[]; + /** Optional preferred AI tools advertised on the listing. */ + defaultAiTools?: string[]; + /** Whether this account is hiring or offering its own services. */ + listingType?: 'hiring' | 'for_hire'; + /** Fixed-price settlement coin. */ + paymentCoin?: 'SOL' | 'ETH' | 'USDC' | 'USDT' | 'POL'; + /** Optional delivery window shown on the listing. */ + duration?: string; } export default defineSocial({ id: 'social-ugig', - label: 'uGig (Prompts Marketplace)', - requires: { maxBodyChars: 10_000, maxHashtags: 10, hashtagsInBody: false }, + label: 'uGig (AI Gig Marketplace)', + requires: { maxBodyChars: 5_000, maxHashtags: 10, hashtagsInBody: false }, async connect(ctx, config) { const token = ctx.secret('UGIG_TOKEN'); @@ -49,24 +59,41 @@ export default defineSocial({ const token = ctx.secret('UGIG_TOKEN'); if (!token) throw new Error('UGIG_TOKEN not in vault'); - const title = post.title ?? post.body.slice(0, 80).replace(/\n/g, ' '); - const tags = (post.hashtags ?? []).slice(0, 10); - const category = config.defaultCategory ?? 'research'; + const title = (post.title ?? post.body.slice(0, 80).replace(/\n/g, ' ')).trim().slice(0, 100); + const description = (post.link ? `${post.body}\n\n${post.link}` : post.body).trim().slice(0, 5_000); + const tags = (post.hashtags ?? []).map((tag) => tag.replace(/^#/, '').trim()).filter(Boolean).slice(0, 10); + const category = config.defaultCategory ?? 'Research'; + const skills = (config.defaultSkills?.length ? config.defaultSkills : tags.length ? tags : [category]) + .map((skill) => skill.trim()) + .filter(Boolean) + .slice(0, 10); const priceCents = config.defaultPriceCents ?? 0; - ctx.log(`ugig gig · "${title}" · ${post.body.length} chars · ${tags.length} tags`); + if (title.length < 10) throw new Error('uGig gig title must be at least 10 characters'); + if (description.length < 50) throw new Error('uGig gig description must be at least 50 characters'); + if (skills.length === 0) throw new Error('uGig requires at least one skill'); + + ctx.log(`ugig gig · "${title}" · ${description.length} chars · ${skills.length} skills`); if (ctx.dryRun) { return { id: 'dry-run', url: 'https://ugig.net/gigs', platform: 'ugig', publishedAt: new Date().toISOString() }; } const payload: Record = { + listing_type: config.listingType ?? 'for_hire', title, - description: post.body.slice(0, 300), - content: post.link ? `${post.body}\n\n${post.link}` : post.body, + description, category, - tags, - price_cents: priceCents, + skills_required: skills, + ai_tools_preferred: (config.defaultAiTools ?? []).map((tool) => tool.trim()).filter(Boolean).slice(0, 10), + budget_type: 'fixed', + ...(priceCents > 0 ? { + budget_min: Number((priceCents / 100).toFixed(2)), + budget_max: Number((priceCents / 100).toFixed(2)), + } : {}), + ...(config.paymentCoin ? { payment_coin: config.paymentCoin } : {}), + ...(config.duration ? { duration: config.duration } : {}), + location_type: 'remote', status: 'active', }; @@ -80,7 +107,7 @@ export default defineSocial({ }); if (!res.ok) { - const err = await res.text(); + const err = (await res.text()).replaceAll(token, '[redacted]'); throw new Error(`ugig post failed: HTTP ${res.status} — ${err}`); } @@ -103,7 +130,7 @@ export default defineSocial({ 'Obtain Bearer token: POST https://ugig.net/api/auth/login body={"email":"…","password":"…"}', 'Copy access_token from the JSON response', 'Store it as UGIG_TOKEN in your sh1pt secrets vault', - 'Optionally set defaultCategory (research|content-writing|seo|technical-documentation|other) and defaultPriceCents in config', + 'Optionally set defaultCategory, defaultSkills, defaultPriceCents, paymentCoin, duration, and listingType in config', ], }), });