fix: harden engagement mutations (#53) (thanks @the-vampiire)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@ export function withEngagement<TBase extends AbstractConstructor<TwitterClientBa
|
||||
operationName: OperationName,
|
||||
tweetId: string,
|
||||
): Promise<BookmarkMutationResult> {
|
||||
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}`;
|
||||
|
||||
|
||||
@@ -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<string, string> }>(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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user