Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 122 additions & 2 deletions packages/social/ugig/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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]');
});
});
53 changes: 40 additions & 13 deletions packages/social/ugig/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Config>({
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');
Expand All @@ -49,24 +59,41 @@ export default defineSocial<Config>({
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<string, unknown> = {
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',
};

Expand All @@ -80,7 +107,7 @@ export default defineSocial<Config>({
});

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}`);
}

Expand All @@ -103,7 +130,7 @@ export default defineSocial<Config>({
'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',
],
}),
});
Loading