diff --git a/CHANGELOG.md b/CHANGELOG.md index 55268a4..85b1998 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - `list-timeline` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#30) — thanks @zheli. - Rich text output now shows article previews, quoted tweets, and media links (#32) — thanks @odysseus0. - Long-form article tweets now render rich Draft.js content blocks/entities (#36) — thanks @crcatala. +- Twitter client now supports like/unlike/retweet/unretweet/bookmark via the engagement mixin (#53) — thanks @the-vampiire. ### Changed - Library typing: `SearchResult` is now a discriminated union (so `error` only exists when `success: false`). @@ -38,6 +39,7 @@ ### Tests - Following: split following/likes tests + cover cursor handling (#33) — thanks @VACInc. +- Live tests now exercise engagement mutations (opt-in) (#53) — thanks @the-vampiire. ## 0.6.0 — 2026-01-05 diff --git a/src/lib/twitter-client-engagement.ts b/src/lib/twitter-client-engagement.ts index ad599de..32435ac 100644 --- a/src/lib/twitter-client-engagement.ts +++ b/src/lib/twitter-client-engagement.ts @@ -28,7 +28,9 @@ export function withEngagement { - const variables = { tweet_id: tweetId }; + await this.ensureClientUserId(); + const variables = + operationName === 'DeleteRetweet' ? { tweet_id: tweetId, source_tweet_id: tweetId } : { tweet_id: tweetId }; let queryId = await this.getQueryId(operationName); let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/${operationName}`; diff --git a/tests/live/live-all.test.ts b/tests/live/live-all.test.ts index 5e01c24..8dfe9f2 100644 --- a/tests/live/live-all.test.ts +++ b/tests/live/live-all.test.ts @@ -2,6 +2,8 @@ import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import path from 'node:path'; import { beforeAll, describe, expect, it } from 'vitest'; +import { resolveCredentials } from '../../src/lib/cookies.js'; +import { TwitterClient } from '../../src/lib/twitter-client.js'; type RunResult = { exitCode: number; stdout: string; stderr: string; signal: NodeJS.Signals | null }; @@ -386,4 +388,62 @@ d('live CLI (Twitter/X) all commands', () => { const snapshot = parseJson<{ cached?: boolean; ids?: Record }>(queryIds.stdout); expect(typeof snapshot.cached).toBe('boolean'); }); + + it('engagement mutations work (opt-in)', async () => { + const engagementTweetId = (process.env.BIRD_LIVE_ENGAGEMENT_TWEET_ID ?? '').trim(); + if (!engagementTweetId) { + return; + } + if (!TWEET_ID_REGEX.test(engagementTweetId)) { + throw new Error(`Invalid BIRD_LIVE_ENGAGEMENT_TWEET_ID (expected digits): "${engagementTweetId}"`); + } + + const cookieTimeoutMs = Number.parseInt(cookieTimeoutArg, 10); + const { cookies, warnings } = await resolveCredentials({ + authToken, + ct0, + cookieTimeoutMs: Number.isFinite(cookieTimeoutMs) ? cookieTimeoutMs : undefined, + }); + for (const warning of warnings) { + console.error(`${warning}`); + } + if (!cookies.authToken || !cookies.ct0) { + throw new Error('Missing live credentials for engagement test.'); + } + + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = LIVE_NODE_ENV; + try { + const timeoutMs = Number.parseInt(timeoutArg, 10); + const client = new TwitterClient({ + cookies, + timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : undefined, + }); + + // Cleanup (best-effort) to make the mutation sequence deterministic. + await client.unbookmark(engagementTweetId); + await client.unlike(engagementTweetId); + await client.unretweet(engagementTweetId); + + const like = await client.like(engagementTweetId); + expect(like.success).toBe(true); + const retweet = await client.retweet(engagementTweetId); + expect(retweet.success).toBe(true); + const bookmark = await client.bookmark(engagementTweetId); + expect(bookmark.success).toBe(true); + + const unbookmark = await client.unbookmark(engagementTweetId); + expect(unbookmark.success).toBe(true); + const unlike = await client.unlike(engagementTweetId); + expect(unlike.success).toBe(true); + const unretweet = await client.unretweet(engagementTweetId); + expect(unretweet.success).toBe(true); + } finally { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } + } + }); }); diff --git a/tests/live/live.test.ts b/tests/live/live.test.ts index 29a40a1..b0c76d8 100644 --- a/tests/live/live.test.ts +++ b/tests/live/live.test.ts @@ -2,6 +2,8 @@ import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import path from 'node:path'; import { beforeAll, describe, expect, it } from 'vitest'; +import { resolveCredentials } from '../../src/lib/cookies.js'; +import { TwitterClient } from '../../src/lib/twitter-client.js'; type RunResult = { exitCode: number; stdout: string; stderr: string; signal: NodeJS.Signals | null }; @@ -328,4 +330,62 @@ d('live CLI (Twitter/X)', () => { // This verifies the article content is being extracted, not just a stub expect(tweet.text?.length).toBeGreaterThan(500); }); + + it('engagement mutations work (opt-in)', async () => { + const engagementTweetId = (process.env.BIRD_LIVE_ENGAGEMENT_TWEET_ID ?? '').trim(); + if (!engagementTweetId) { + return; + } + if (!TWEET_ID_REGEX.test(engagementTweetId)) { + throw new Error(`Invalid BIRD_LIVE_ENGAGEMENT_TWEET_ID (expected digits): "${engagementTweetId}"`); + } + + const cookieTimeoutMs = Number.parseInt(cookieTimeoutArg, 10); + const { cookies, warnings } = await resolveCredentials({ + authToken, + ct0, + cookieTimeoutMs: Number.isFinite(cookieTimeoutMs) ? cookieTimeoutMs : undefined, + }); + for (const warning of warnings) { + console.error(`${warning}`); + } + if (!cookies.authToken || !cookies.ct0) { + throw new Error('Missing live credentials for engagement test.'); + } + + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = LIVE_NODE_ENV; + try { + const timeoutMs = Number.parseInt(timeoutArg, 10); + const client = new TwitterClient({ + cookies, + timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : undefined, + }); + + // Cleanup (best-effort) to make the mutation sequence deterministic. + await client.unbookmark(engagementTweetId); + await client.unlike(engagementTweetId); + await client.unretweet(engagementTweetId); + + const like = await client.like(engagementTweetId); + expect(like.success).toBe(true); + const retweet = await client.retweet(engagementTweetId); + expect(retweet.success).toBe(true); + const bookmark = await client.bookmark(engagementTweetId); + expect(bookmark.success).toBe(true); + + const unbookmark = await client.unbookmark(engagementTweetId); + expect(unbookmark.success).toBe(true); + const unlike = await client.unlike(engagementTweetId); + expect(unlike.success).toBe(true); + const unretweet = await client.unretweet(engagementTweetId); + expect(unretweet.success).toBe(true); + } finally { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } + } + }); }); diff --git a/tests/twitter-client.engagement.test.ts b/tests/twitter-client.engagement.test.ts index d511e2c..dec4d33 100644 --- a/tests/twitter-client.engagement.test.ts +++ b/tests/twitter-client.engagement.test.ts @@ -124,7 +124,7 @@ describe('TwitterClient engagement', () => { }); describe('unretweet', () => { - it('successfully unrets a tweet', async () => { + it('successfully unretweets a tweet', async () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, @@ -140,6 +140,9 @@ describe('TwitterClient engagement', () => { const [url, options] = mockFetch.mock.calls[0]; expect(options.method).toBe('POST'); expect(String(url)).toContain('/DeleteRetweet'); + const body = JSON.parse(options.body); + expect(body.variables.tweet_id).toBe('1234567890'); + expect(body.variables.source_tweet_id).toBe('1234567890'); }); });