Skip to content
Merged
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
156 changes: 156 additions & 0 deletions src/commands/__tests__/instagram-content.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../api/gateway.js', () => ({ gatewayRequest: vi.fn() }));
vi.mock('../_helpers.js', () => ({ resolveChannelRefOrDefault: vi.fn(async () => ({ id: 'ch_ig', type: 'instagram', metaResourceId: '17841400000000000', metaWabaId: null, workspaceId: 'ws_1' })) }));
vi.mock('../../output/format.js', () => ({ isJsonMode: vi.fn(() => false) }));
import { runInstagramMediaList, runInstagramMentions, runInstagramProfile } from '../instagram-content.js';
import { gatewayRequest } from '../../api/gateway.js';
import { resolveChannelRefOrDefault } from '../_helpers.js';
import { isJsonMode } from '../../output/format.js';
import { ValidationError } from '../../output/error.js';

function captureStdout(): [() => string, () => void] {
const writes: string[] = [];
const spy = vi.spyOn(process.stdout, 'write').mockImplementation((s) => { writes.push(String(s)); return true; });
return [() => writes.join(''), () => spy.mockRestore()];
}

describe('instagram media', () => {
beforeEach(() => {
vi.mocked(gatewayRequest).mockReset();
vi.mocked(resolveChannelRefOrDefault).mockClear();
vi.mocked(isJsonMode).mockReturnValue(false);
});

it('lists posts from the media edge and prints the id first, since that is what other commands need', async () => {
vi.mocked(gatewayRequest).mockResolvedValue({ data: [{ id: '17999', media_type: 'IMAGE', timestamp: 'T', caption: 'hello' }] });
const [out, restore] = captureStdout();

await runInstagramMediaList({ channel: '@acme' });
restore();

expect(vi.mocked(gatewayRequest).mock.calls[0][0].path).toContain('/{ig_id}/media?');
expect(out()).toContain('17999\tIMAGE\tT\thello');
});

it('expands carousel children when one post is read', async () => {
vi.mocked(gatewayRequest).mockResolvedValue({ id: '17999' });
const [, restore] = captureStdout();

await runInstagramMediaList({ channel: '@acme', media: '17999' });
restore();

expect(decodeURIComponent(vi.mocked(gatewayRequest).mock.calls[0][0].path as string)).toContain('children{');
});

it('clamps a limit above Meta ceiling to 100 rather than letting Meta truncate silently', async () => {
vi.mocked(gatewayRequest).mockResolvedValue({ data: [] });
const [, restore] = captureStdout();

await runInstagramMediaList({ channel: '@acme', limit: '5000' });
restore();

expect(vi.mocked(gatewayRequest).mock.calls[0][0].path).toContain('limit=100');
});

it('reads tagged posts from the tags edge, the Instagram-Login mention read', async () => {
vi.mocked(gatewayRequest).mockResolvedValue({ data: [] });
const [, restore] = captureStdout();

await runInstagramMediaList({ channel: '@acme', source: 'tagged' });
restore();

const path = vi.mocked(gatewayRequest).mock.calls[0][0].path as string;
expect(path).toContain('/{ig_id}/tags?');
expect(decodeURIComponent(path)).toContain('username');
});

it('rejects an unknown source before any gateway call', async () => {
await expect(runInstagramMediaList({ channel: '@acme', source: 'stories' })).rejects.toBeInstanceOf(ValidationError);
expect(gatewayRequest).not.toHaveBeenCalled();
});

it('rejects a non-numeric media id before any gateway call', async () => {
await expect(runInstagramMediaList({ channel: '@acme', media: '../17999' })).rejects.toBeInstanceOf(ValidationError);
expect(gatewayRequest).not.toHaveBeenCalled();
});

it('reports the next cursor so paging is discoverable without --json', async () => {
vi.mocked(gatewayRequest).mockResolvedValue({ data: [], paging: { cursors: { after: 'CUR9' } } });
const [out, restore] = captureStdout();

await runInstagramMediaList({ channel: '@acme' });
restore();

expect(out()).toContain('--after CUR9');
});
});

describe('instagram mentions', () => {
beforeEach(() => {
vi.mocked(gatewayRequest).mockReset();
vi.mocked(isJsonMode).mockReturnValue(false);
});

it('POSTs the reply to the mentions edge, the only mention write Instagram Login has', async () => {
vi.mocked(gatewayRequest).mockResolvedValue({ id: '17846' });
const [out, restore] = captureStdout();

await runInstagramMentions({ channel: '@acme', media: '17999', reply: 'thanks!' });
restore();

const call = vi.mocked(gatewayRequest).mock.calls[0][0];
expect(call.path).toBe('/{ig_id}/mentions');
expect(out()).toContain('Replied. id=17846');
});

it('includes comment_id when the mention was in a comment', async () => {
vi.mocked(gatewayRequest).mockResolvedValue({ id: '17847' });
const [, restore] = captureStdout();

await runInstagramMentions({ channel: '@acme', media: '17999', comment: '17888', reply: 'hi' });
restore();

expect((vi.mocked(gatewayRequest).mock.calls[0][0].body as Record<string, unknown>).comment_id).toBe('17888');
});

it('points at the tagged list when --media is missing', async () => {
await expect(runInstagramMentions({ channel: '@acme', reply: 'hi' })).rejects.toBeInstanceOf(ValidationError);
expect(gatewayRequest).not.toHaveBeenCalled();
});

it('refuses without reply text, since this command only posts', async () => {
await expect(runInstagramMentions({ channel: '@acme', media: '17999' })).rejects.toBeInstanceOf(ValidationError);
expect(gatewayRequest).not.toHaveBeenCalled();
});
});

describe('instagram profile', () => {
beforeEach(() => {
vi.mocked(gatewayRequest).mockReset();
vi.mocked(isJsonMode).mockReturnValue(false);
});

it('reads the profile node and skips the quota edge unless asked', async () => {
vi.mocked(gatewayRequest).mockResolvedValue({ username: 'acme', followers_count: 12 });
const [out, restore] = captureStdout();

await runInstagramProfile({ channel: '@acme' });
restore();

expect(gatewayRequest).toHaveBeenCalledTimes(1);
expect(out()).toContain('username\tacme');
});

it('also reads the publishing quota when --quota is given', async () => {
vi.mocked(gatewayRequest)
.mockResolvedValueOnce({ username: 'acme' })
.mockResolvedValueOnce({ data: [{ quota_usage: 3 }] });
const [out, restore] = captureStdout();

await runInstagramProfile({ channel: '@acme', quota: true });
restore();

expect(vi.mocked(gatewayRequest).mock.calls[1][0].path).toContain('content_publishing_limit');
expect(out()).toContain('publishing_quota_used\t3');
});
});
111 changes: 111 additions & 0 deletions src/commands/__tests__/instagram-inbox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../api/gateway.js', () => ({ gatewayRequest: vi.fn() }));
vi.mock('../_helpers.js', () => ({ resolveChannelRefOrDefault: vi.fn(async () => ({ id: 'ch_ig', type: 'instagram', metaResourceId: '17841400000000000', metaWabaId: null, workspaceId: 'ws_1' })) }));
vi.mock('../../output/format.js', () => ({ isJsonMode: vi.fn(() => false) }));
import { runInstagramThreads } from '../instagram-inbox.js';
import { gatewayRequest } from '../../api/gateway.js';
import { isJsonMode } from '../../output/format.js';
import { ValidationError } from '../../output/error.js';

function captureStdout(): [() => string, () => void] {
const writes: string[] = [];
const spy = vi.spyOn(process.stdout, 'write').mockImplementation((s) => { writes.push(String(s)); return true; });
return [() => writes.join(''), () => spy.mockRestore()];
}

describe('instagram threads', () => {
beforeEach(() => {
vi.mocked(gatewayRequest).mockReset();
vi.mocked(isJsonMode).mockReturnValue(false);
});

it('scopes the conversation list to the instagram inbox, not the linked Page inbox', async () => {
vi.mocked(gatewayRequest).mockResolvedValue({ data: [] });
const [, restore] = captureStdout();

await runInstagramThreads({ channel: '@acme' });
restore();

expect(vi.mocked(gatewayRequest).mock.calls[0][0].path).toContain('platform=instagram');
});

it('lists threads with the people in them', async () => {
vi.mocked(gatewayRequest).mockResolvedValue({
data: [{ id: 'aWdfX', updated_time: 'T', participants: { data: [{ username: 'fan' }] } }],
});
const [out, restore] = captureStdout();

await runInstagramThreads({ channel: '@acme' });
restore();

expect(out()).toContain('aWdfX\tT\t@fan');
});

it('expands messages on the conversation node and unwraps messages.data', async () => {
vi.mocked(gatewayRequest).mockResolvedValue({
messages: { data: [{ created_time: 'T', from: { username: 'fan' }, message: 'hello there' }] },
});
const [out, restore] = captureStdout();

await runInstagramThreads({ channel: '@acme', thread: 'aWdfXTHREAD' });
restore();

const p = decodeURIComponent(vi.mocked(gatewayRequest).mock.calls[0][0].path as string);
expect(p).toContain('/aWdfXTHREAD?');
expect(p).toContain('messages.limit(25){');
expect(out()).toContain('@fan\thello there');
});

it('shows how to page on from a thread, the way the conversation list does', async () => {
vi.mocked(gatewayRequest).mockResolvedValue({
messages: { data: [{ created_time: 'T', message: 'hi' }], paging: { cursors: { after: 'CUR2' } } },
});
const [out, restore] = captureStdout();

await runInstagramThreads({ channel: '@acme', thread: 'aWdfXTHREAD' });
restore();

expect(out()).toContain('More: --after CUR2');
});

it('pages the messages, not the conversation node, when --after is given', async () => {
vi.mocked(gatewayRequest).mockResolvedValue({ messages: { data: [] } });

await runInstagramThreads({ channel: '@acme', thread: 'aWdfXTHREAD', after: 'CUR2', limit: '5' });

const p = decodeURIComponent(vi.mocked(gatewayRequest).mock.calls[0][0].path as string);
expect(p).toContain('messages.limit(5).after(CUR2){');
expect(p).not.toMatch(/[?&](limit|after)=/);
});

it('rejects a cursor that could break out of the field expansion', async () => {
await expect(
runInstagramThreads({ channel: '@acme', thread: 'aWdfXTHREAD', after: 'CUR){x' }),
).rejects.toBeInstanceOf(ValidationError);
expect(gatewayRequest).not.toHaveBeenCalled();
});

it('reads only the public profile when --participant is given', async () => {
vi.mocked(gatewayRequest).mockResolvedValue({ id: 'IGSID1', username: 'fan' });
const [, restore] = captureStdout();

await runInstagramThreads({ channel: '@acme', participant: 'IGSID1' });
restore();

const path = decodeURIComponent(vi.mocked(gatewayRequest).mock.calls[0][0].path as string);
expect(path).toContain('/IGSID1?fields=');
expect(path).not.toContain('followers_count');
});

it('rejects a thread id carrying a path separator before any gateway call', async () => {
await expect(
runInstagramThreads({ channel: '@acme', thread: 'abc/../def' }),
).rejects.toBeInstanceOf(ValidationError);
expect(gatewayRequest).not.toHaveBeenCalled();
});

it('rejects a limit that is not a whole number', async () => {
await expect(runInstagramThreads({ channel: '@acme', limit: 'lots' })).rejects.toBeInstanceOf(ValidationError);
expect(gatewayRequest).not.toHaveBeenCalled();
});
});
Loading
Loading