mirror of
https://github.com/typefully/agent-skills.git
synced 2026-09-14 20:50:23 +08:00
051a266090
## What Adds planned-drafts support to the Typefully skill, matching the API v2 write support shipping in typefully-backend#1785. A **planned draft** sits on the queue/calendar at its date but is inert — it never auto-publishes until confirmed into a real schedule. ## Changes - **`--plan <iso|next-free-slot>`** on `drafts:create` and `drafts:update` → sends `plan_at`. Mutually exclusive with `--schedule` (friendly CLI error before hitting the API). On update, literal `null` clears the plan and returns the draft to plain draft status (same convention as `--cover-media-id null`). - **New `drafts:plan <social_set_id> <draft_id> --time <...>`** command mirroring `drafts:schedule`, including the `--use-default` single-arg safety gate. - **`drafts:list --status planned`** documented (the filter was already passed through; help text and docs now include it). - **SKILL.md**: planned lifecycle in the Schedule & publish section (confirm via `drafts:schedule`, publish via `drafts:publish`, lapsed planned dates are *not* failures — replan or confirm), a Common-actions row, flag/command table entries, and planned-aware `queue:get` description. - **CHANGELOG.md**: user-facing entry. ## Tests 6 new tests in `tests/drafts.test.js` covering: `drafts:plan` payload + missing `--time`, create with `--plan` (and no `publish_at` leak), the `--schedule`+`--plan` mutual-exclusion error, update with `--plan`, and `--plan null` sending `plan_at: null`. Full suite: **130 passed**. ## Notes - The backend PR (typefully-backend#1785) should merge/deploy first; until then `plan_at` is rejected by the API. - Not included (possible follow-up): an unschedule command (`publish_at: null`) — the CLI never had one and it's orthogonal to planned drafts. https://claude.ai/code/session_01267PabjVxa2EQueJ9oZoT9
1839 lines
64 KiB
JavaScript
1839 lines
64 KiB
JavaScript
const {
|
|
describe,
|
|
it,
|
|
assert,
|
|
fs,
|
|
path,
|
|
runCli,
|
|
parseJsonOrNull,
|
|
authAssertFactory,
|
|
withCliHarness,
|
|
} = require('./typefully-cli.test-helpers');
|
|
|
|
describe('drafts', () => {
|
|
it('drafts:update with only --tags does not touch content (no platforms payload)', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('PATCH', '/v2/social-sets/100813/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, { tags: ['owner/me'] });
|
|
},
|
|
json: { id: 'd1', ok: true },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:update', 'd1', '--tags', 'owner/me', '--social-set-id', '100813'],
|
|
{
|
|
cwd: sandbox.cwd,
|
|
env: {
|
|
HOME: sandbox.home,
|
|
TYPEFULLY_API_BASE: baseUrl,
|
|
TYPEFULLY_API_KEY: apiKey,
|
|
},
|
|
}
|
|
);
|
|
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
|
|
server.assertNoPendingExpectations();
|
|
assert.equal(server.requests.length, 1);
|
|
}));
|
|
|
|
it('update-draft alias: tag-only update works and does not crash', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('PATCH', '/v2/social-sets/100813/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, { tags: ['owner/me'] });
|
|
},
|
|
json: { id: 'd1', ok: true },
|
|
});
|
|
const result = await runCli(
|
|
['update-draft', 'd1', '--tags', 'owner/me', '--social-set-id', '100813'],
|
|
{
|
|
cwd: sandbox.cwd,
|
|
env: {
|
|
HOME: sandbox.home,
|
|
TYPEFULLY_API_BASE: baseUrl,
|
|
TYPEFULLY_API_KEY: apiKey,
|
|
},
|
|
}
|
|
);
|
|
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
|
|
server.assertNoPendingExpectations();
|
|
assert.equal(server.requests.length, 1);
|
|
}));
|
|
|
|
it('create-draft alias: positional text forwards correctly to drafts:create', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('POST', '/v2/social-sets/100812/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
platforms: {
|
|
x: {
|
|
enabled: true,
|
|
posts: [{ text: 'Test content' }],
|
|
},
|
|
},
|
|
tags: ['owner/me'],
|
|
});
|
|
},
|
|
json: { id: 'new-draft' },
|
|
});
|
|
const result = await runCli(
|
|
['create-draft', 'Test content', '--social-set-id', '100812', '--platform', 'x', '--tags', 'owner/me'],
|
|
{
|
|
cwd: sandbox.cwd,
|
|
env: {
|
|
HOME: sandbox.home,
|
|
TYPEFULLY_API_BASE: baseUrl,
|
|
TYPEFULLY_API_KEY: apiKey,
|
|
},
|
|
}
|
|
);
|
|
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'new-draft' });
|
|
server.assertNoPendingExpectations();
|
|
assert.equal(server.requests.length, 1);
|
|
}));
|
|
|
|
it('draft target safety: drafts:get single arg with default social set requires --use-default', withCliHarness(async ({
|
|
sandbox, server, baseUrl
|
|
}) => {
|
|
// Create local config with a default social set.
|
|
const cfgDir = path.join(sandbox.cwd, '.typefully');
|
|
await fs.mkdir(cfgDir, { recursive: true });
|
|
await fs.writeFile(path.join(cfgDir, 'config.json'), JSON.stringify({ defaultSocialSetId: '111' }, null, 2));
|
|
const result = await runCli(
|
|
['drafts:get', 'd99'],
|
|
{
|
|
cwd: sandbox.cwd,
|
|
env: {
|
|
HOME: sandbox.home,
|
|
TYPEFULLY_API_BASE: baseUrl,
|
|
TYPEFULLY_API_KEY: 'typ_test_key',
|
|
},
|
|
}
|
|
);
|
|
|
|
assert.equal(result.code, 1);
|
|
const out = parseJsonOrNull(result.stdout);
|
|
assert.ok(out?.error?.includes('Ambiguous arguments for drafts:get'));
|
|
assert.equal(server.requests.length, 0);
|
|
}));
|
|
|
|
it('drafts:get with --use-default uses configured default social set', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
const cfgDir = path.join(sandbox.cwd, '.typefully');
|
|
await fs.mkdir(cfgDir, { recursive: true });
|
|
await fs.writeFile(path.join(cfgDir, 'config.json'), JSON.stringify({ defaultSocialSetId: '111' }, null, 2));
|
|
|
|
server.expect('GET', '/v2/social-sets/111/drafts/d99', {
|
|
assert: authAssertFactory(apiKey),
|
|
json: { id: 'd99' },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:get', 'd99', '--use-default'],
|
|
{
|
|
cwd: sandbox.cwd,
|
|
env: {
|
|
HOME: sandbox.home,
|
|
TYPEFULLY_API_BASE: baseUrl,
|
|
TYPEFULLY_API_KEY: apiKey,
|
|
},
|
|
}
|
|
);
|
|
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd99' });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:create chooses first connected platform when --platform omitted', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('GET', '/v2/social-sets/55', {
|
|
assert: authAssertFactory(apiKey),
|
|
json: { id: '55', platforms: { x: {}, linkedin: {} } },
|
|
});
|
|
|
|
server.expect('POST', '/v2/social-sets/55/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.equal(Object.keys(req.bodyJson.platforms).join(','), 'x');
|
|
assert.equal(req.bodyJson.platforms.x.posts[0].text, 'Hello');
|
|
},
|
|
json: { id: 'd1' },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:create', '55', '--text', 'Hello'],
|
|
{
|
|
cwd: sandbox.cwd,
|
|
env: {
|
|
HOME: sandbox.home,
|
|
TYPEFULLY_API_BASE: baseUrl,
|
|
TYPEFULLY_API_KEY: apiKey,
|
|
},
|
|
}
|
|
);
|
|
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:create --all targets all connected platforms', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('GET', '/v2/social-sets/55', {
|
|
assert: authAssertFactory(apiKey),
|
|
json: { id: '55', platforms: { linkedin: {}, x: {}, threads: {} } },
|
|
});
|
|
|
|
server.expect('POST', '/v2/social-sets/55/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(Object.keys(req.bodyJson.platforms), ['x', 'linkedin', 'threads']);
|
|
},
|
|
json: { id: 'd1' },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:create', '55', '--all', '--text', 'Hello'],
|
|
{
|
|
cwd: sandbox.cwd,
|
|
env: {
|
|
HOME: sandbox.home,
|
|
TYPEFULLY_API_BASE: baseUrl,
|
|
TYPEFULLY_API_KEY: apiKey,
|
|
},
|
|
}
|
|
);
|
|
|
|
assert.equal(result.code, 0);
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:create --all excludes x_article from connected post platforms', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('GET', '/v2/social-sets/55', {
|
|
assert: authAssertFactory(apiKey),
|
|
json: { id: '55', platforms: { x_article: {}, linkedin: {}, x: {} } },
|
|
});
|
|
|
|
server.expect('POST', '/v2/social-sets/55/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(Object.keys(req.bodyJson.platforms), ['x', 'linkedin']);
|
|
},
|
|
json: { id: 'd1' },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:create', '55', '--all', '--text', 'Hello'],
|
|
{
|
|
cwd: sandbox.cwd,
|
|
env: {
|
|
HOME: sandbox.home,
|
|
TYPEFULLY_API_BASE: baseUrl,
|
|
TYPEFULLY_API_KEY: apiKey,
|
|
},
|
|
}
|
|
);
|
|
|
|
assert.equal(result.code, 0);
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:create targets substack with a single post', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('POST', '/v2/social-sets/9/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
platforms: {
|
|
substack: {
|
|
enabled: true,
|
|
posts: [{ text: 'A quick note' }],
|
|
},
|
|
},
|
|
});
|
|
},
|
|
json: { id: 'd1' },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--platform', 'substack', '--text', 'A quick note'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:create --all includes substack when connected', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('GET', '/v2/social-sets/55', {
|
|
assert: authAssertFactory(apiKey),
|
|
json: { id: '55', platforms: { substack: {}, x: {} } },
|
|
});
|
|
|
|
server.expect('POST', '/v2/social-sets/55/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(Object.keys(req.bodyJson.platforms), ['x', 'substack']);
|
|
},
|
|
json: { id: 'd1' },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:create', '55', '--all', '--text', 'Hello'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:create errors when thread content targets substack', withCliHarness(async ({
|
|
sandbox, server
|
|
}) => {
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--platform', 'x,substack', '--text', 'Post one\n---\nPost two'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
|
|
);
|
|
assert.equal(result.code, 1);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), {
|
|
error: 'substack (Substack Notes) supports a single post per draft — threads are not supported. Use a single post, or target other platforms with --platform.',
|
|
});
|
|
assert.equal(server.requests.length, 0);
|
|
}));
|
|
|
|
it('drafts:update --append errors when the draft targets substack', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
|
|
assert: authAssertFactory(apiKey),
|
|
json: {
|
|
id: 'd1',
|
|
platforms: {
|
|
substack: { enabled: true, posts: [{ text: 'Existing note' }] },
|
|
},
|
|
},
|
|
});
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'd1', '--append', '--text', 'One more'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 1);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), {
|
|
error: 'substack (Substack Notes) supports a single post per draft — threads are not supported. Use a single post, or target other platforms with --platform.',
|
|
});
|
|
server.assertNoPendingExpectations();
|
|
assert.equal(server.requests.length, 1);
|
|
}));
|
|
|
|
it('drafts:create supports standalone X Article markdown and cover media', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
const markdown = '# Article Title\n\nFirst section\n---\nStill the same article body';
|
|
server.expect('POST', '/v2/social-sets/9/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
platforms: {
|
|
x_article: {
|
|
content_markdown: markdown,
|
|
cover_media_id: 'media-123',
|
|
},
|
|
},
|
|
draft_title: 'Article draft',
|
|
tags: ['longform'],
|
|
});
|
|
},
|
|
json: { id: 'article-1' },
|
|
});
|
|
const result = await runCli(
|
|
[
|
|
'drafts:create',
|
|
'9',
|
|
'--platform',
|
|
'x_article',
|
|
'--content-markdown',
|
|
markdown,
|
|
'--cover-media-id',
|
|
'media-123',
|
|
'--title',
|
|
'Article draft',
|
|
'--tags',
|
|
'longform',
|
|
],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'article-1' });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('create-draft alias forwards X Article content and cover flags', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
const markdown = '# Alias Article\n\nBody';
|
|
server.expect('POST', '/v2/social-sets/9/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
platforms: {
|
|
x_article: {
|
|
content_markdown: markdown,
|
|
cover_media_id: 'cover-1',
|
|
},
|
|
},
|
|
});
|
|
},
|
|
json: { id: 'article-alias' },
|
|
});
|
|
const result = await runCli(
|
|
[
|
|
'create-draft',
|
|
'--social-set-id',
|
|
'9',
|
|
'--platform',
|
|
'x_article',
|
|
'--content-markdown',
|
|
markdown,
|
|
'--cover-media-id',
|
|
'cover-1',
|
|
],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'article-alias' });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:schedule sends publish_at payload', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, { publish_at: 'next-free-slot' });
|
|
},
|
|
json: { id: 'd1', scheduled: true },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:schedule', '9', 'd1', '--time', 'next-free-slot'],
|
|
{
|
|
cwd: sandbox.cwd,
|
|
env: {
|
|
HOME: sandbox.home,
|
|
TYPEFULLY_API_BASE: baseUrl,
|
|
TYPEFULLY_API_KEY: apiKey,
|
|
},
|
|
}
|
|
);
|
|
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', scheduled: true });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:plan sends plan_at payload', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, { plan_at: 'next-free-slot' });
|
|
},
|
|
json: { id: 'd1', status: 'planned' },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:plan', '9', 'd1', '--time', 'next-free-slot'],
|
|
{
|
|
cwd: sandbox.cwd,
|
|
env: {
|
|
HOME: sandbox.home,
|
|
TYPEFULLY_API_BASE: baseUrl,
|
|
TYPEFULLY_API_KEY: apiKey,
|
|
},
|
|
}
|
|
);
|
|
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', status: 'planned' });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:plan requires --time', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
const result = await runCli(
|
|
['drafts:plan', '9', 'd1'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
|
|
assert.equal(result.code, 1);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), {
|
|
error: '--time is required (use "next-free-slot" or a future ISO datetime)',
|
|
});
|
|
assert.equal(server.requests.length, 0);
|
|
}));
|
|
|
|
it('drafts:create sends plan_at payload with --plan', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('POST', '/v2/social-sets/9/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.equal(req.bodyJson.plan_at, 'next-free-slot');
|
|
assert.ok(!('publish_at' in req.bodyJson));
|
|
},
|
|
json: { id: 'd1', status: 'planned' },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--platform', 'x', '--text', 'Pencil this in', '--plan', 'next-free-slot'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', status: 'planned' });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:create rejects --schedule combined with --plan', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--platform', 'x', '--text', 'Hello', '--schedule', 'now', '--plan', 'next-free-slot'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
|
|
assert.equal(result.code, 1);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), {
|
|
error: '--schedule and --plan are mutually exclusive - provide only one',
|
|
});
|
|
assert.equal(server.requests.length, 0);
|
|
}));
|
|
|
|
it('drafts:update sends plan_at payload with --plan', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, { plan_at: '2027-01-20T14:00:00Z' });
|
|
},
|
|
json: { id: 'd1', status: 'planned' },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'd1', '--plan', '2027-01-20T14:00:00Z'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', status: 'planned' });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:update --plan null clears the plan (sends plan_at: null)', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, { plan_at: null });
|
|
},
|
|
json: { id: 'd1', status: 'draft' },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'd1', '--plan', 'null'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', status: 'draft' });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:list builds query params (status/tag/sort/limit)', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('GET', '/v2/social-sets/9/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
const q = new URL('http://x' + req.path + req.search).searchParams;
|
|
assert.equal(q.get('limit'), '3');
|
|
assert.equal(q.get('status'), 'scheduled');
|
|
assert.equal(q.get('tag'), 'owner/me');
|
|
assert.equal(q.get('order_by'), '-created_at');
|
|
},
|
|
json: { results: [{ id: 'd1' }] },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:list', '9', '--limit', '3', '--status', 'scheduled', '--tag', 'owner/me', '--sort', '-created_at'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { results: [{ id: 'd1' }] });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:publish sends publish_at=now', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, { publish_at: 'now' });
|
|
},
|
|
json: { id: 'd1', published: true },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:publish', '9', 'd1'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', published: true });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:delete uses DELETE and returns success payload', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('DELETE', '/v2/social-sets/9/drafts/d1', {
|
|
assert: authAssertFactory(apiKey),
|
|
json: {},
|
|
});
|
|
const result = await runCli(
|
|
['drafts:delete', '9', 'd1'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { success: true, message: 'Draft deleted' });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:update with no update fields errors (message mentions --tags)', withCliHarness(async ({
|
|
sandbox, server, baseUrl
|
|
}) => {
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'd1'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: 'typ_test_key' } }
|
|
);
|
|
assert.equal(result.code, 1);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), {
|
|
error: 'At least one of --text, --file, --content-markdown, --cover-media-id, --title, --schedule, --plan, --share, --notes, --tags, --quote-post-url, --paid-partnership, --made-with-ai, --hide-link-preview, or --force-overwrite-comments is required',
|
|
});
|
|
assert.equal(server.requests.length, 0);
|
|
}));
|
|
|
|
it('drafts:update can clear tags with --tags \"\" (sends empty array)', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, { tags: [] });
|
|
},
|
|
json: { id: 'd1', ok: true },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'd1', '--tags', ''],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:create splits threads on --- and attaches media only to first post', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('POST', '/v2/social-sets/9/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
const posts = req.bodyJson.platforms.x.posts;
|
|
assert.equal(posts.length, 2);
|
|
assert.deepEqual(posts[0], { text: 'First', media_ids: ['m1', 'm2'] });
|
|
assert.deepEqual(posts[1], { text: 'Second' });
|
|
},
|
|
json: { id: 'd1' },
|
|
});
|
|
const threadText = 'First\n---\nSecond';
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--platform', 'x', '--text', threadText, '--media', 'm1,m2'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:create thread splitting supports CRLF (\\r\\n)', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('POST', '/v2/social-sets/9/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
const posts = req.bodyJson.platforms.x.posts;
|
|
assert.equal(posts.length, 2);
|
|
assert.deepEqual(posts[0], { text: 'First', media_ids: ['m1', 'm2'] });
|
|
assert.deepEqual(posts[1], { text: 'Second' });
|
|
},
|
|
json: { id: 'd1' },
|
|
});
|
|
const threadText = 'First\r\n---\r\nSecond';
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--platform', 'x', '--text', threadText, '--media', 'm1,m2'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:update --append fetches existing draft and appends a new post', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
|
|
assert: authAssertFactory(apiKey),
|
|
json: {
|
|
id: 'd1',
|
|
platforms: {
|
|
x: {
|
|
enabled: true,
|
|
posts: [{ text: 'Old' }],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
platforms: {
|
|
x: {
|
|
enabled: true,
|
|
posts: [{ text: 'Old' }, { text: 'New', media_ids: ['m1'] }],
|
|
},
|
|
},
|
|
});
|
|
},
|
|
json: { id: 'd1', ok: true },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'd1', '--append', '--text', 'New', '--media', 'm1'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:update replaces posts and attaches media only to first post', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
|
|
assert: authAssertFactory(apiKey),
|
|
json: {
|
|
id: 'd1',
|
|
platforms: {
|
|
x: { enabled: true, posts: [{ text: 'Old' }] },
|
|
},
|
|
},
|
|
});
|
|
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
const posts = req.bodyJson.platforms.x.posts;
|
|
assert.deepEqual(posts, [
|
|
{ text: 'First', media_ids: ['m1'] },
|
|
{ text: 'Second' },
|
|
]);
|
|
},
|
|
json: { id: 'd1', ok: true },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'd1', '--text', 'First\n---\nSecond', '--media', 'm1'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:create supports title/schedule/share/notes/tags/reply-to/community', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('POST', '/v2/social-sets/9/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
platforms: {
|
|
x: {
|
|
enabled: true,
|
|
posts: [{ text: 'Hello' }],
|
|
settings: {
|
|
reply_to_url: 'https://x.com/user/status/123',
|
|
community_id: '999',
|
|
},
|
|
},
|
|
},
|
|
draft_title: 'Title',
|
|
publish_at: 'now',
|
|
tags: ['a', 'b'],
|
|
share: true,
|
|
scratchpad_text: 'Some notes',
|
|
});
|
|
},
|
|
json: { id: 'd1' },
|
|
});
|
|
const result = await runCli(
|
|
[
|
|
'drafts:create',
|
|
'9',
|
|
'--platform',
|
|
'x',
|
|
'--text',
|
|
'Hello',
|
|
'--title',
|
|
'Title',
|
|
'--schedule',
|
|
'now',
|
|
'--tags',
|
|
'a,b',
|
|
'--share',
|
|
'--scratchpad',
|
|
'Some notes',
|
|
'--reply-to',
|
|
'https://x.com/user/status/123',
|
|
'--community',
|
|
'999',
|
|
],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:create applies quote_post_url only to X posts', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
const quoteUrl = 'https://x.com/typefullyapp/status/1234567890123456789';
|
|
server.expect('POST', '/v2/social-sets/9/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
platforms: {
|
|
x: {
|
|
enabled: true,
|
|
posts: [{ text: 'Hello', quote_post_url: quoteUrl }],
|
|
},
|
|
linkedin: {
|
|
enabled: true,
|
|
posts: [{ text: 'Hello' }],
|
|
},
|
|
},
|
|
});
|
|
},
|
|
json: { id: 'd1' },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--platform', 'x,linkedin', '--text', 'Hello', '--quote-post-url', quoteUrl],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:create accepts --quote-url alias', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
const quoteUrl = 'https://x.com/typefullyapp/status/1234567890123456789';
|
|
server.expect('POST', '/v2/social-sets/9/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.equal(req.bodyJson.platforms.x.posts[0].quote_post_url, quoteUrl);
|
|
},
|
|
json: { id: 'd1' },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--platform', 'x', '--text', 'Hello', '--quote-url', quoteUrl],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:create applies X content disclosures only to X posts', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('POST', '/v2/social-sets/9/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
platforms: {
|
|
x: {
|
|
enabled: true,
|
|
posts: [{ text: 'Sponsored AI update', paid_partnership: true, made_with_ai: true }],
|
|
},
|
|
linkedin: {
|
|
enabled: true,
|
|
posts: [{ text: 'Sponsored AI update' }],
|
|
},
|
|
},
|
|
});
|
|
},
|
|
json: { id: 'd1' },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--platform', 'x,linkedin', '--text', 'Sponsored AI update', '--paid-partnership', '--made-with-ai'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:create errors when quote URL is used without X platform', withCliHarness(async ({
|
|
sandbox, server
|
|
}) => {
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--platform', 'linkedin', '--text', 'Hello', '--quote-post-url', 'https://x.com/user/status/123'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
|
|
);
|
|
assert.equal(result.code, 1);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), {
|
|
error: '--quote-post-url is only supported for X posts. Include x in --platform or remove the quote flag.',
|
|
});
|
|
assert.equal(server.requests.length, 0);
|
|
}));
|
|
|
|
it('drafts:create errors when X content disclosures are used without X platform', withCliHarness(async ({
|
|
sandbox, server
|
|
}) => {
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--platform', 'linkedin', '--text', 'Hello', '--made-with-ai'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
|
|
);
|
|
assert.equal(result.code, 1);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), {
|
|
error: '--paid-partnership/--made-with-ai is only supported for X posts. Include x in --platform or remove the X-only flag.',
|
|
});
|
|
assert.equal(server.requests.length, 0);
|
|
}));
|
|
|
|
it('drafts:create applies --hide-link-preview only to LinkedIn, Threads, and Substack posts', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('POST', '/v2/social-sets/9/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
platforms: {
|
|
linkedin: {
|
|
enabled: true,
|
|
posts: [{ text: 'Read this https://example.com', hide_link_preview: true }],
|
|
},
|
|
threads: {
|
|
enabled: true,
|
|
posts: [{ text: 'Read this https://example.com', hide_link_preview: true }],
|
|
},
|
|
bluesky: {
|
|
enabled: true,
|
|
posts: [{ text: 'Read this https://example.com' }],
|
|
},
|
|
substack: {
|
|
enabled: true,
|
|
posts: [{ text: 'Read this https://example.com', hide_link_preview: true }],
|
|
},
|
|
},
|
|
});
|
|
},
|
|
json: { id: 'd1' },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--platform', 'linkedin,threads,bluesky,substack', '--text', 'Read this https://example.com', '--hide-link-preview'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:create errors when --hide-link-preview is used without LinkedIn, Threads, or Substack', withCliHarness(async ({
|
|
sandbox, server
|
|
}) => {
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--platform', 'bluesky', '--text', 'Read this https://example.com', '--hide-link-preview'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
|
|
);
|
|
assert.equal(result.code, 1);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), {
|
|
error: '--hide-link-preview is only supported for LinkedIn, Threads, and Substack posts. Include linkedin, threads, or substack in --platform or remove the flag.',
|
|
});
|
|
assert.equal(server.requests.length, 0);
|
|
}));
|
|
|
|
it('drafts:create surfaces API VALIDATION_ERROR messages for quote URLs', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
const validationMessage = 'Invalid quote_post_url. Expected an X/Twitter post URL like https://x.com/<username>/status/<id>.';
|
|
server.expect('POST', '/v2/social-sets/9/drafts', {
|
|
assert: authAssertFactory(apiKey),
|
|
status: 400,
|
|
json: {
|
|
code: 'VALIDATION_ERROR',
|
|
message: validationMessage,
|
|
},
|
|
});
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--platform', 'x', '--text', 'Hello', '--quote-post-url', 'https://example.com/not-x'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 1);
|
|
const out = parseJsonOrNull(result.stdout);
|
|
assert.equal(out?.error, `Validation error: ${validationMessage}`);
|
|
assert.deepEqual(out?.response, {
|
|
code: 'VALIDATION_ERROR',
|
|
message: validationMessage,
|
|
});
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:create reads from file via -f', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
const filePath = path.join(sandbox.cwd, 'post.txt');
|
|
await fs.writeFile(filePath, 'From file');
|
|
|
|
server.expect('POST', '/v2/social-sets/9/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.equal(req.bodyJson.platforms.x.posts[0].text, 'From file');
|
|
},
|
|
json: { id: 'd1' },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--platform', 'x', '-f', filePath],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:create errors when both --all and --platform are provided', withCliHarness(async ({
|
|
sandbox
|
|
}) => {
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--all', '--platform', 'x', '--text', 'Hello'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
|
|
);
|
|
assert.equal(result.code, 1);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { error: 'Cannot use both --all and --platform flags' });
|
|
}));
|
|
|
|
it('drafts:create errors when neither --text nor --file are provided', withCliHarness(async ({
|
|
sandbox
|
|
}) => {
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--platform', 'x'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
|
|
);
|
|
assert.equal(result.code, 1);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { error: '--text or --file is required' });
|
|
}));
|
|
|
|
it('drafts:create requires X Article flags to use --platform x_article', withCliHarness(async ({
|
|
sandbox, server
|
|
}) => {
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--content-markdown', '# Title\n\nBody'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
|
|
);
|
|
assert.equal(result.code, 1);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), {
|
|
error: 'X Article flags require --platform x_article',
|
|
});
|
|
assert.equal(server.requests.length, 0);
|
|
}));
|
|
|
|
it('drafts:create requires content markdown for X Article drafts', withCliHarness(async ({
|
|
sandbox, server
|
|
}) => {
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--platform', 'x_article', '--cover-media-id', 'cover-1'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
|
|
);
|
|
assert.equal(result.code, 1);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), {
|
|
error: '--content-markdown is required for X Article drafts',
|
|
});
|
|
assert.equal(server.requests.length, 0);
|
|
}));
|
|
|
|
it('drafts:create rejects x_article combined with other platforms', withCliHarness(async ({
|
|
sandbox, server
|
|
}) => {
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--platform', 'x_article,x', '--content-markdown', '# Title\n\nBody'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
|
|
);
|
|
assert.equal(result.code, 1);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), {
|
|
error: 'x_article is standalone and cannot be combined with other platforms',
|
|
});
|
|
assert.equal(server.requests.length, 0);
|
|
}));
|
|
|
|
it('drafts:create rejects post-only flags for X Article drafts', withCliHarness(async ({
|
|
sandbox, server
|
|
}) => {
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--platform', 'x_article', '--content-markdown', '# Title\n\nBody', '--media', 'm1'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
|
|
);
|
|
assert.equal(result.code, 1);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), {
|
|
error: '--media cannot be used with --platform x_article',
|
|
});
|
|
assert.equal(server.requests.length, 0);
|
|
}));
|
|
|
|
it('drafts:update rejects post-only flags for X Article updates before reading files', withCliHarness(async ({
|
|
sandbox, server
|
|
}) => {
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'article-1', '--platform', 'x_article', '--file', './missing.md'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
|
|
);
|
|
assert.equal(result.code, 1);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), {
|
|
error: '--file cannot be used with --platform x_article',
|
|
});
|
|
assert.equal(server.requests.length, 0);
|
|
}));
|
|
|
|
it('drafts:update supports title/schedule/share/notes without fetching existing draft', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
draft_title: 'Title',
|
|
publish_at: 'now',
|
|
share: true,
|
|
scratchpad_text: 'Notes',
|
|
tags: ['t1'],
|
|
});
|
|
},
|
|
json: { id: 'd1', ok: true },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'd1', '--title', 'Title', '--schedule', 'now', '--share', '--notes', 'Notes', '--tags', 't1'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.equal(server.requests.length, 1);
|
|
server.assertNoPendingExpectations();
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
|
|
}));
|
|
|
|
it('drafts:update supports X Article content and cover without fetching existing draft', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
const markdown = '# Updated Article\n\nBody with --- preserved';
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/article-1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
platforms: {
|
|
x_article: {
|
|
content_markdown: markdown,
|
|
cover_media_id: 'cover-2',
|
|
},
|
|
},
|
|
});
|
|
},
|
|
json: { id: 'article-1', ok: true },
|
|
});
|
|
const result = await runCli(
|
|
[
|
|
'drafts:update',
|
|
'9',
|
|
'article-1',
|
|
'--platform',
|
|
'x_article',
|
|
'--content-markdown',
|
|
markdown,
|
|
'--cover-media-id',
|
|
'cover-2',
|
|
],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
|
|
assert.equal(result.code, 0);
|
|
assert.equal(server.requests.length, 1);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'article-1', ok: true });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:update supports X Article cover-only update and removal with null', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/article-1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
platforms: {
|
|
x_article: {
|
|
cover_media_id: 'cover-3',
|
|
},
|
|
},
|
|
});
|
|
},
|
|
json: { id: 'article-1', ok: true },
|
|
});
|
|
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/article-1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
platforms: {
|
|
x_article: {
|
|
cover_media_id: null,
|
|
},
|
|
},
|
|
});
|
|
},
|
|
json: { id: 'article-1', ok: true },
|
|
});
|
|
|
|
const setCover = await runCli(
|
|
['drafts:update', '9', 'article-1', '--platform', 'x_article', '--cover-media-id', 'cover-3'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(setCover.code, 0);
|
|
|
|
const removeCover = await runCli(
|
|
['drafts:update', '9', 'article-1', '--platform', 'x_article', '--cover-media-id', 'null'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(removeCover.code, 0);
|
|
assert.equal(server.requests.length, 2);
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('update-draft alias forwards X Article update flags', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
const markdown = '# Alias Update\n\nBody';
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/article-1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
platforms: {
|
|
x_article: {
|
|
content_markdown: markdown,
|
|
},
|
|
},
|
|
});
|
|
},
|
|
json: { id: 'article-1', ok: true },
|
|
});
|
|
const result = await runCli(
|
|
[
|
|
'update-draft',
|
|
'article-1',
|
|
'--social-set-id',
|
|
'9',
|
|
'--platform',
|
|
'x_article',
|
|
'--content-markdown',
|
|
markdown,
|
|
],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'article-1', ok: true });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:update reads from file via -f and can target multiple platforms', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
const filePath = path.join(sandbox.cwd, 'thread.txt');
|
|
await fs.writeFile(filePath, 'First\n---\nSecond');
|
|
|
|
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
|
|
assert: authAssertFactory(apiKey),
|
|
json: { id: 'd1', platforms: { x: { enabled: true, posts: [{ text: 'Old' }] } } },
|
|
});
|
|
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
platforms: {
|
|
x: {
|
|
enabled: true,
|
|
posts: [{ text: 'First', media_ids: ['m1'] }, { text: 'Second' }],
|
|
},
|
|
linkedin: {
|
|
enabled: true,
|
|
posts: [{ text: 'First', media_ids: ['m1'] }, { text: 'Second' }],
|
|
},
|
|
},
|
|
});
|
|
},
|
|
json: { id: 'd1', ok: true },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'd1', '--platform', 'x,linkedin', '-f', filePath, '--media', 'm1'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
server.assertNoPendingExpectations();
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
|
|
}));
|
|
|
|
it('drafts:update supports -a shorthand for --append', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
|
|
assert: authAssertFactory(apiKey),
|
|
json: {
|
|
id: 'd1',
|
|
platforms: {
|
|
x: { enabled: true, posts: [{ text: 'Old' }] },
|
|
},
|
|
},
|
|
});
|
|
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
platforms: {
|
|
x: { enabled: true, posts: [{ text: 'Old' }, { text: 'New' }] },
|
|
},
|
|
});
|
|
},
|
|
json: { id: 'd1', ok: true },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'd1', '-a', '--text', 'New'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('default social set safety: drafts:update/delete/schedule/publish require --use-default with a single arg', withCliHarness(async ({
|
|
sandbox
|
|
}) => {
|
|
const cfgDir = path.join(sandbox.cwd, '.typefully');
|
|
await fs.mkdir(cfgDir, { recursive: true });
|
|
await fs.writeFile(path.join(cfgDir, 'config.json'), JSON.stringify({ defaultSocialSetId: '9' }, null, 2));
|
|
for (const args of [
|
|
['drafts:update', 'd1', '--title', 'T'],
|
|
['drafts:delete', 'd1'],
|
|
['drafts:schedule', 'd1', '--time', 'next-free-slot'],
|
|
['drafts:publish', 'd1'],
|
|
]) {
|
|
const result = await runCli(args, {
|
|
cwd: sandbox.cwd,
|
|
env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' },
|
|
});
|
|
assert.equal(result.code, 1);
|
|
const out = parseJsonOrNull(result.stdout);
|
|
assert.ok(out?.error?.includes('Ambiguous arguments'));
|
|
}
|
|
}));
|
|
|
|
it('drafts:schedule/delete/publish work with --use-default and configured default social set', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
const cfgDir = path.join(sandbox.cwd, '.typefully');
|
|
await fs.mkdir(cfgDir, { recursive: true });
|
|
await fs.writeFile(path.join(cfgDir, 'config.json'), JSON.stringify({ defaultSocialSetId: '9' }, null, 2));
|
|
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, { publish_at: 'next-free-slot' });
|
|
},
|
|
json: { id: 'd1', scheduled: true },
|
|
});
|
|
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, { publish_at: 'now' });
|
|
},
|
|
json: { id: 'd1', published: true },
|
|
});
|
|
|
|
server.expect('DELETE', '/v2/social-sets/9/drafts/d1', {
|
|
assert: authAssertFactory(apiKey),
|
|
json: {},
|
|
});
|
|
const sched = await runCli(['drafts:schedule', 'd1', '--time', 'next-free-slot', '--use-default'], {
|
|
cwd: sandbox.cwd,
|
|
env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey },
|
|
});
|
|
assert.equal(sched.code, 0);
|
|
|
|
const pub = await runCli(['drafts:publish', 'd1', '--use-default'], {
|
|
cwd: sandbox.cwd,
|
|
env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey },
|
|
});
|
|
assert.equal(pub.code, 0);
|
|
|
|
const del = await runCli(['drafts:delete', 'd1', '--use-default'], {
|
|
cwd: sandbox.cwd,
|
|
env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey },
|
|
});
|
|
assert.equal(del.code, 0);
|
|
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:list accepts --social_set_id (snake_case)', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('GET', '/v2/social-sets/9/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.equal(req.search, '?limit=1');
|
|
},
|
|
json: { results: [] },
|
|
});
|
|
const result = await runCli(['drafts:list', '--social_set_id', '9', '--limit', '1'], {
|
|
cwd: sandbox.cwd,
|
|
env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey },
|
|
});
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { results: [] });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:create reads from file via --file (long form)', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
const filePath = path.join(sandbox.cwd, 'post.txt');
|
|
await fs.writeFile(filePath, 'Hello from file', 'utf8');
|
|
|
|
server.expect('POST', '/v2/social-sets/9/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.equal(req.bodyJson.platforms.x.posts[0].text, 'Hello from file');
|
|
},
|
|
json: { id: 'd1' },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:create', '9', '--platform', 'x', '--file', filePath],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:update reads from file via --file (long form)', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
const filePath = path.join(sandbox.cwd, 'post.txt');
|
|
await fs.writeFile(filePath, 'Updated from file', 'utf8');
|
|
|
|
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
|
|
assert: authAssertFactory(apiKey),
|
|
json: { id: 'd1', platforms: { x: { enabled: true, posts: [{ text: 'Old' }] } } },
|
|
});
|
|
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.equal(req.bodyJson.platforms.x.posts[0].text, 'Updated from file');
|
|
},
|
|
json: { id: 'd1', ok: true },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'd1', '--platform', 'x', '--file', filePath],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:update applies quote_post_url only to X posts', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
const quoteUrl = 'https://x.com/typefullyapp/status/1234567890123456789';
|
|
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
|
|
assert: authAssertFactory(apiKey),
|
|
json: {
|
|
id: 'd1',
|
|
platforms: {
|
|
x: { enabled: true, posts: [{ text: 'Old X' }] },
|
|
linkedin: { enabled: true, posts: [{ text: 'Old LI' }] },
|
|
},
|
|
},
|
|
});
|
|
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
platforms: {
|
|
x: {
|
|
enabled: true,
|
|
posts: [{ text: 'Hello', quote_post_url: quoteUrl }],
|
|
},
|
|
linkedin: {
|
|
enabled: true,
|
|
posts: [{ text: 'Hello' }],
|
|
},
|
|
},
|
|
});
|
|
},
|
|
json: { id: 'd1', ok: true },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'd1', '--platform', 'x,linkedin', '--text', 'Hello', '--quote-post-url', quoteUrl],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:update can set quote URL without changing text', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
const quoteUrl = 'https://x.com/typefullyapp/status/1234567890123456789';
|
|
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
|
|
assert: authAssertFactory(apiKey),
|
|
json: {
|
|
id: 'd1',
|
|
platforms: {
|
|
x: { enabled: true, posts: [{ text: 'Existing text' }] },
|
|
},
|
|
},
|
|
});
|
|
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
platforms: {
|
|
x: {
|
|
enabled: true,
|
|
posts: [{ text: 'Existing text', quote_post_url: quoteUrl }],
|
|
},
|
|
},
|
|
});
|
|
},
|
|
json: { id: 'd1', ok: true },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'd1', '--quote-url', quoteUrl],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:update can hide the link preview without changing text', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
|
|
assert: authAssertFactory(apiKey),
|
|
json: {
|
|
id: 'd1',
|
|
platforms: {
|
|
linkedin: {
|
|
enabled: true,
|
|
posts: [{ text: 'Read this https://example.com', hide_link_preview: false }],
|
|
},
|
|
threads: {
|
|
enabled: true,
|
|
posts: [{ text: 'Read this https://example.com', hide_link_preview: false }],
|
|
},
|
|
bluesky: {
|
|
enabled: true,
|
|
posts: [{ text: 'Read this https://example.com', hide_link_preview: false }],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
platforms: {
|
|
linkedin: {
|
|
enabled: true,
|
|
posts: [{ text: 'Read this https://example.com', hide_link_preview: true }],
|
|
},
|
|
threads: {
|
|
enabled: true,
|
|
posts: [{ text: 'Read this https://example.com', hide_link_preview: true }],
|
|
},
|
|
},
|
|
});
|
|
},
|
|
json: { id: 'd1', ok: true },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'd1', '--hide-link-preview'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:update errors on --hide-link-preview when the draft has no LinkedIn, Threads, or Substack posts', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
|
|
assert: authAssertFactory(apiKey),
|
|
json: {
|
|
id: 'd1',
|
|
platforms: {
|
|
bluesky: { enabled: true, posts: [{ text: 'Read this https://example.com' }] },
|
|
},
|
|
},
|
|
});
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'd1', '--hide-link-preview'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 1);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), {
|
|
error: '--hide-link-preview is only supported for LinkedIn, Threads, and Substack posts. Include linkedin, threads, or substack in --platform or remove the flag.',
|
|
});
|
|
assert.equal(server.requests.length, 1);
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:update can set X content disclosures without changing text', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
|
|
assert: authAssertFactory(apiKey),
|
|
json: {
|
|
id: 'd1',
|
|
platforms: {
|
|
x: {
|
|
enabled: true,
|
|
posts: [{ text: 'Existing 1' }, { text: 'Existing 2' }],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
platforms: {
|
|
x: {
|
|
enabled: true,
|
|
posts: [
|
|
{ text: 'Existing 1', made_with_ai: true },
|
|
{ text: 'Existing 2', made_with_ai: true },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
},
|
|
json: { id: 'd1', ok: true },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'd1', '--made-with-ai'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:update errors when quote URL is used without X platform', withCliHarness(async ({
|
|
sandbox, server
|
|
}) => {
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'd1', '--platform', 'linkedin', '--text', 'Hello', '--quote-post-url', 'https://x.com/user/status/123'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } }
|
|
);
|
|
assert.equal(result.code, 1);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), {
|
|
error: '--quote-post-url is only supported for X posts. Include x in --platform or remove the quote flag.',
|
|
});
|
|
assert.equal(server.requests.length, 0);
|
|
}));
|
|
|
|
it('drafts:schedule errors when --time is missing', withCliHarness(async ({
|
|
sandbox, server, baseUrl
|
|
}) => {
|
|
const result = await runCli(
|
|
['drafts:schedule', '9', 'd1'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: 'typ_test_key' } }
|
|
);
|
|
assert.equal(result.code, 1);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), {
|
|
error: '--time is required (use "next-free-slot" or ISO datetime)',
|
|
});
|
|
assert.equal(server.requests.length, 0);
|
|
}));
|
|
|
|
it('global flag: drafts:list accepts --social-set-id (kebab-case)', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('GET', '/v2/social-sets/9/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.equal(req.search, '?limit=10');
|
|
},
|
|
json: { results: [] },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:list', '--social-set-id', '9'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { results: [] });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('global flag: drafts:create accepts --social-set-id (kebab-case) with no positional social set', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey
|
|
}) => {
|
|
server.expect('POST', '/v2/social-sets/9/drafts', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.equal(req.bodyJson.platforms.x.posts[0].text, 'Hello');
|
|
},
|
|
json: { id: 'd1' },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:create', '--social-set-id', '9', '--platform', 'x', '--text', 'Hello'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:get with --exclude-comment-markers adds exclude_comment_markers query param', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey,
|
|
}) => {
|
|
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.equal(req.search, '?exclude_comment_markers=true');
|
|
},
|
|
json: { id: 'd1', platforms: { x: { posts: [{ text: 'plain text' }] } } },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:get', '9', 'd1', '--exclude-comment-markers'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), {
|
|
id: 'd1',
|
|
platforms: { x: { posts: [{ text: 'plain text' }] } },
|
|
});
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:update with only --force-overwrite-comments sends PATCH without fetching existing draft', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey,
|
|
}) => {
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.equal(req.search, '');
|
|
assert.deepEqual(req.bodyJson, { force_overwrite_comments: true });
|
|
},
|
|
json: { id: 'd1', ok: true },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'd1', '--force-overwrite-comments'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
|
|
assert.equal(server.requests.length, 1);
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:update combines --text with --force-overwrite-comments in body', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey,
|
|
}) => {
|
|
server.expect('GET', '/v2/social-sets/9/drafts/d1', {
|
|
assert: authAssertFactory(apiKey),
|
|
json: { id: 'd1', platforms: { x: { enabled: true, posts: [{ text: 'Old' }] } } },
|
|
});
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.deepEqual(req.bodyJson, {
|
|
platforms: {
|
|
x: { enabled: true, posts: [{ text: 'Rewritten body' }] },
|
|
},
|
|
force_overwrite_comments: true,
|
|
});
|
|
},
|
|
json: { id: 'd1', ok: true },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'd1', '--text', 'Rewritten body', '--force-overwrite-comments'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
|
|
it('drafts:update with --exclude-comment-markers adds query string to PATCH URL', withCliHarness(async ({
|
|
sandbox, server, baseUrl, apiKey,
|
|
}) => {
|
|
server.expect('PATCH', '/v2/social-sets/9/drafts/d1', {
|
|
assert: (req) => {
|
|
authAssertFactory(apiKey)(req);
|
|
assert.equal(req.search, '?exclude_comment_markers=true');
|
|
assert.deepEqual(req.bodyJson, { force_overwrite_comments: true });
|
|
},
|
|
json: { id: 'd1', ok: true },
|
|
});
|
|
const result = await runCli(
|
|
['drafts:update', '9', 'd1', '--force-overwrite-comments', '--exclude-comment-markers'],
|
|
{ cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } }
|
|
);
|
|
assert.equal(result.code, 0);
|
|
assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true });
|
|
server.assertNoPendingExpectations();
|
|
}));
|
|
});
|