xfeed 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +5 -0
- package/package.json +43 -0
- package/src/api/actions.ts +16 -0
- package/src/api/client.test.ts +3370 -0
- package/src/api/client.ts +4319 -0
- package/src/api/query-ids.json +11 -0
- package/src/api/query-ids.test.ts +118 -0
- package/src/api/query-ids.ts +59 -0
- package/src/api/runtime-query-ids.test.ts +926 -0
- package/src/api/runtime-query-ids.ts +389 -0
- package/src/api/types.ts +581 -0
- package/src/app.tsx +664 -0
- package/src/auth/browser-detect.ts +150 -0
- package/src/auth/browser-picker.ts +118 -0
- package/src/auth/check.test.preload.ts +94 -0
- package/src/auth/check.test.ts +388 -0
- package/src/auth/check.ts +220 -0
- package/src/auth/cookies.test.ts +529 -0
- package/src/auth/cookies.ts +299 -0
- package/src/auth/manual-entry.ts +88 -0
- package/src/auth/session.ts +30 -0
- package/src/components/ErrorBanner.tsx +172 -0
- package/src/components/Footer.tsx +90 -0
- package/src/components/Header.tsx +57 -0
- package/src/components/NotificationItem.test.ts +252 -0
- package/src/components/NotificationItem.tsx +80 -0
- package/src/components/NotificationList.test.ts +328 -0
- package/src/components/NotificationList.tsx +157 -0
- package/src/components/PostCard.tsx +186 -0
- package/src/components/PostList.tsx +232 -0
- package/src/components/QuotedPostCard.tsx +55 -0
- package/src/components/ReplyPreviewCard.tsx +80 -0
- package/src/components/ThreadView.prototype.tsx +533 -0
- package/src/components/Toast.tsx +28 -0
- package/src/config/loader.ts +69 -0
- package/src/config/types.ts +27 -0
- package/src/contexts/ModalContext.tsx +227 -0
- package/src/experiments/TimelineScreenExperimental.tsx +202 -0
- package/src/experiments/index.tsx +43 -0
- package/src/experiments/query-client.ts +132 -0
- package/src/experiments/use-bookmark-mutation.ts +342 -0
- package/src/experiments/use-bookmarks-query.ts +166 -0
- package/src/experiments/use-notifications-query.ts +368 -0
- package/src/experiments/use-post-detail-query.ts +187 -0
- package/src/experiments/use-profile-query.ts +162 -0
- package/src/experiments/use-timeline-query.ts +201 -0
- package/src/hooks/.gitkeep +0 -0
- package/src/hooks/useActions.ts +354 -0
- package/src/hooks/useBookmarkFolders.ts +70 -0
- package/src/hooks/useBookmarks.ts +111 -0
- package/src/hooks/useCountdown.ts +75 -0
- package/src/hooks/useListNavigation.test.ts +273 -0
- package/src/hooks/useListNavigation.ts +118 -0
- package/src/hooks/useNavigation.test.ts +340 -0
- package/src/hooks/useNavigation.ts +103 -0
- package/src/hooks/useNotifications.test.ts +377 -0
- package/src/hooks/useNotifications.ts +117 -0
- package/src/hooks/usePaginatedData.ts +217 -0
- package/src/hooks/usePostDetail.ts +137 -0
- package/src/hooks/useThread.prototype.ts +314 -0
- package/src/hooks/useTimeline.ts +136 -0
- package/src/hooks/useUserProfile.ts +142 -0
- package/src/index.tsx +304 -0
- package/src/lib/colors.ts +41 -0
- package/src/lib/format.ts +69 -0
- package/src/lib/media.ts +464 -0
- package/src/lib/result.ts +6 -0
- package/src/lib/text.tsx +76 -0
- package/src/modals/BookmarkFolderSelector.tsx +260 -0
- package/src/modals/ExitConfirmationModal.tsx +131 -0
- package/src/modals/FolderPicker.tsx +281 -0
- package/src/modals/README.md +171 -0
- package/src/modals/SessionExpiredModal.tsx +47 -0
- package/src/modals/index.ts +4 -0
- package/src/screens/.gitkeep +0 -0
- package/src/screens/BookmarksScreen.tsx +168 -0
- package/src/screens/NotificationsScreen.tsx +172 -0
- package/src/screens/PostDetailScreen.tsx +976 -0
- package/src/screens/ProfileScreen.tsx +528 -0
- package/src/screens/SplashScreen.tsx +72 -0
- package/src/screens/ThreadScreen.tsx +81 -0
- package/src/screens/TimelineScreen.tsx +188 -0
- package/vendor/sweet-cookie/LICENSE +22 -0
- package/vendor/sweet-cookie/README.md +29 -0
- package/vendor/sweet-cookie/dist/index.d.ts +3 -0
- package/vendor/sweet-cookie/dist/index.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/index.js +2 -0
- package/vendor/sweet-cookie/dist/index.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chrome.d.ts +10 -0
- package/vendor/sweet-cookie/dist/providers/chrome.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chrome.js +27 -0
- package/vendor/sweet-cookie/dist/providers/chrome.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.d.ts +11 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.js +100 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.d.ts +25 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.js +104 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.d.ts +10 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.js +293 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.d.ts +10 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.js +26 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.d.ts +7 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.js +51 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.d.ts +10 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.js +118 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.d.ts +7 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.js +38 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.d.ts +5 -0
- package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.js +33 -0
- package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.d.ts +24 -0
- package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.js +30 -0
- package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/paths.d.ts +11 -0
- package/vendor/sweet-cookie/dist/providers/chromium/paths.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/paths.js +43 -0
- package/vendor/sweet-cookie/dist/providers/chromium/paths.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.d.ts +8 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.js +41 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.d.ts +8 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.js +53 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edge.d.ts +8 -0
- package/vendor/sweet-cookie/dist/providers/edge.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edge.js +27 -0
- package/vendor/sweet-cookie/dist/providers/edge.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.d.ts +7 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.js +53 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.d.ts +8 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.js +60 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.d.ts +7 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.js +38 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/firefoxSqlite.d.ts +6 -0
- package/vendor/sweet-cookie/dist/providers/firefoxSqlite.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/firefoxSqlite.js +257 -0
- package/vendor/sweet-cookie/dist/providers/firefoxSqlite.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/inline.d.ts +8 -0
- package/vendor/sweet-cookie/dist/providers/inline.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/inline.js +71 -0
- package/vendor/sweet-cookie/dist/providers/inline.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.d.ts +6 -0
- package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.js +173 -0
- package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.js.map +1 -0
- package/vendor/sweet-cookie/dist/public.d.ts +26 -0
- package/vendor/sweet-cookie/dist/public.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/public.js +197 -0
- package/vendor/sweet-cookie/dist/public.js.map +1 -0
- package/vendor/sweet-cookie/dist/types.d.ts +127 -0
- package/vendor/sweet-cookie/dist/types.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/types.js +2 -0
- package/vendor/sweet-cookie/dist/types.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/base64.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/base64.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/base64.js +18 -0
- package/vendor/sweet-cookie/dist/util/base64.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/exec.d.ts +8 -0
- package/vendor/sweet-cookie/dist/util/exec.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/exec.js +110 -0
- package/vendor/sweet-cookie/dist/util/exec.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/expire.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/expire.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/expire.js +32 -0
- package/vendor/sweet-cookie/dist/util/expire.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/fs.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/fs.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/fs.js +13 -0
- package/vendor/sweet-cookie/dist/util/fs.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/hostMatch.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/hostMatch.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/hostMatch.js +7 -0
- package/vendor/sweet-cookie/dist/util/hostMatch.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/nodeSqlite.d.ts +5 -0
- package/vendor/sweet-cookie/dist/util/nodeSqlite.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/nodeSqlite.js +58 -0
- package/vendor/sweet-cookie/dist/util/nodeSqlite.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/origins.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/origins.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/origins.js +27 -0
- package/vendor/sweet-cookie/dist/util/origins.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/runtime.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/runtime.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/runtime.js +8 -0
- package/vendor/sweet-cookie/dist/util/runtime.js.map +1 -0
- package/vendor/sweet-cookie/package.json +40 -0
|
@@ -0,0 +1,3370 @@
|
|
|
1
|
+
// @ts-nocheck - Test file with complex mocking that conflicts with Bun's strict fetch types
|
|
2
|
+
/**
|
|
3
|
+
* Unit tests for XClient
|
|
4
|
+
* Tests all public methods and edge cases for 100% coverage
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
afterAll,
|
|
9
|
+
afterEach,
|
|
10
|
+
beforeAll,
|
|
11
|
+
beforeEach,
|
|
12
|
+
describe,
|
|
13
|
+
expect,
|
|
14
|
+
it,
|
|
15
|
+
mock,
|
|
16
|
+
spyOn,
|
|
17
|
+
type Mock,
|
|
18
|
+
} from "bun:test";
|
|
19
|
+
|
|
20
|
+
import { XClient } from "./client";
|
|
21
|
+
import { runtimeQueryIds } from "./query-ids";
|
|
22
|
+
|
|
23
|
+
// Mock runtimeQueryIds
|
|
24
|
+
const mockGetQueryId = mock(() => Promise.resolve(null));
|
|
25
|
+
const mockRefresh = mock(() => Promise.resolve(null));
|
|
26
|
+
|
|
27
|
+
// Store original fetch
|
|
28
|
+
const originalFetch = globalThis.fetch;
|
|
29
|
+
|
|
30
|
+
// Store original env values for restoration
|
|
31
|
+
const originalNodeEnv = process.env.NODE_ENV;
|
|
32
|
+
const originalDebugArticle = process.env.XFEED_DEBUG_ARTICLE;
|
|
33
|
+
|
|
34
|
+
// Store spies for cleanup
|
|
35
|
+
let getQueryIdSpy: Mock<typeof runtimeQueryIds.getQueryId>;
|
|
36
|
+
let refreshSpy: Mock<typeof runtimeQueryIds.refresh>;
|
|
37
|
+
|
|
38
|
+
// Mock response factory
|
|
39
|
+
function mockResponse(
|
|
40
|
+
body: unknown,
|
|
41
|
+
options: { status?: number; ok?: boolean } = {}
|
|
42
|
+
) {
|
|
43
|
+
const status = options.status ?? 200;
|
|
44
|
+
const ok = options.ok ?? (status >= 200 && status < 300);
|
|
45
|
+
return {
|
|
46
|
+
ok,
|
|
47
|
+
status,
|
|
48
|
+
text: () =>
|
|
49
|
+
Promise.resolve(typeof body === "string" ? body : JSON.stringify(body)),
|
|
50
|
+
json: () => Promise.resolve(body),
|
|
51
|
+
} as Response;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Valid cookies for tests
|
|
55
|
+
const validCookies = {
|
|
56
|
+
authToken: "test-auth-token",
|
|
57
|
+
ct0: "test-ct0",
|
|
58
|
+
cookieHeader: "auth_token=test-auth-token; ct0=test-ct0",
|
|
59
|
+
source: "test",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe("XClient", () => {
|
|
63
|
+
beforeAll(() => {
|
|
64
|
+
// Create spies once for the entire test suite
|
|
65
|
+
getQueryIdSpy = spyOn(runtimeQueryIds, "getQueryId").mockImplementation(
|
|
66
|
+
mockGetQueryId
|
|
67
|
+
);
|
|
68
|
+
refreshSpy = spyOn(runtimeQueryIds, "refresh").mockImplementation(
|
|
69
|
+
mockRefresh
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterAll(() => {
|
|
74
|
+
// Restore original methods
|
|
75
|
+
getQueryIdSpy.mockRestore();
|
|
76
|
+
refreshSpy.mockRestore();
|
|
77
|
+
|
|
78
|
+
// Restore original env values
|
|
79
|
+
if (originalNodeEnv !== undefined) {
|
|
80
|
+
process.env.NODE_ENV = originalNodeEnv;
|
|
81
|
+
} else {
|
|
82
|
+
delete process.env.NODE_ENV;
|
|
83
|
+
}
|
|
84
|
+
if (originalDebugArticle !== undefined) {
|
|
85
|
+
process.env.XFEED_DEBUG_ARTICLE = originalDebugArticle;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
// Reset mocks
|
|
91
|
+
mockGetQueryId.mockReset();
|
|
92
|
+
mockRefresh.mockReset();
|
|
93
|
+
mockGetQueryId.mockImplementation(() => Promise.resolve(null));
|
|
94
|
+
mockRefresh.mockImplementation(() => Promise.resolve(null));
|
|
95
|
+
|
|
96
|
+
// Set test environment
|
|
97
|
+
process.env.NODE_ENV = "test";
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
globalThis.fetch = originalFetch;
|
|
102
|
+
delete process.env.XFEED_DEBUG_ARTICLE;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("constructor", () => {
|
|
106
|
+
it("throws if authToken is missing", () => {
|
|
107
|
+
expect(() => {
|
|
108
|
+
new XClient({
|
|
109
|
+
cookies: {
|
|
110
|
+
authToken: null,
|
|
111
|
+
ct0: "test",
|
|
112
|
+
cookieHeader: null,
|
|
113
|
+
source: null,
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}).toThrow("Both authToken and ct0 cookies are required");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("throws if ct0 is missing", () => {
|
|
120
|
+
expect(() => {
|
|
121
|
+
new XClient({
|
|
122
|
+
cookies: {
|
|
123
|
+
authToken: "test",
|
|
124
|
+
ct0: null,
|
|
125
|
+
cookieHeader: null,
|
|
126
|
+
source: null,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
}).toThrow("Both authToken and ct0 cookies are required");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("creates client with valid cookies", () => {
|
|
133
|
+
const client = new XClient({ cookies: validCookies });
|
|
134
|
+
expect(client).toBeDefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("uses provided userAgent", () => {
|
|
138
|
+
const client = new XClient({
|
|
139
|
+
cookies: validCookies,
|
|
140
|
+
userAgent: "CustomAgent/1.0",
|
|
141
|
+
});
|
|
142
|
+
expect(client).toBeDefined();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("uses provided timeoutMs", () => {
|
|
146
|
+
const client = new XClient({
|
|
147
|
+
cookies: validCookies,
|
|
148
|
+
timeoutMs: 5000,
|
|
149
|
+
});
|
|
150
|
+
expect(client).toBeDefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("builds cookieHeader from authToken and ct0 if not provided", () => {
|
|
154
|
+
const client = new XClient({
|
|
155
|
+
cookies: {
|
|
156
|
+
authToken: "abc",
|
|
157
|
+
ct0: "xyz",
|
|
158
|
+
cookieHeader: null,
|
|
159
|
+
source: null,
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
expect(client).toBeDefined();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("getTweet", () => {
|
|
167
|
+
it("returns tweet on success", async () => {
|
|
168
|
+
const client = new XClient({ cookies: validCookies });
|
|
169
|
+
const tweetResponse = {
|
|
170
|
+
data: {
|
|
171
|
+
tweetResult: {
|
|
172
|
+
result: {
|
|
173
|
+
rest_id: "123456",
|
|
174
|
+
legacy: {
|
|
175
|
+
full_text: "Hello world!",
|
|
176
|
+
created_at: "Wed Oct 10 20:19:24 +0000 2018",
|
|
177
|
+
reply_count: 5,
|
|
178
|
+
retweet_count: 10,
|
|
179
|
+
favorite_count: 20,
|
|
180
|
+
conversation_id_str: "123456",
|
|
181
|
+
},
|
|
182
|
+
core: {
|
|
183
|
+
user_results: {
|
|
184
|
+
result: {
|
|
185
|
+
rest_id: "user123",
|
|
186
|
+
legacy: {
|
|
187
|
+
screen_name: "testuser",
|
|
188
|
+
name: "Test User",
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
globalThis.fetch = mock(() =>
|
|
199
|
+
Promise.resolve(mockResponse(tweetResponse))
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const result = await client.getTweet("123456");
|
|
203
|
+
expect(result.success).toBe(true);
|
|
204
|
+
if (result.success) {
|
|
205
|
+
expect(result.tweet?.id).toBe("123456");
|
|
206
|
+
expect(result.tweet?.text).toBe("Hello world!");
|
|
207
|
+
expect(result.tweet?.author.username).toBe("testuser");
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("finds tweet in instructions if not in tweetResult", async () => {
|
|
212
|
+
const client = new XClient({ cookies: validCookies });
|
|
213
|
+
const tweetResponse = {
|
|
214
|
+
data: {
|
|
215
|
+
threaded_conversation_with_injections_v2: {
|
|
216
|
+
instructions: [
|
|
217
|
+
{
|
|
218
|
+
entries: [
|
|
219
|
+
{
|
|
220
|
+
content: {
|
|
221
|
+
itemContent: {
|
|
222
|
+
tweet_results: {
|
|
223
|
+
result: {
|
|
224
|
+
rest_id: "123456",
|
|
225
|
+
legacy: {
|
|
226
|
+
full_text: "Found in instructions!",
|
|
227
|
+
},
|
|
228
|
+
core: {
|
|
229
|
+
user_results: {
|
|
230
|
+
result: {
|
|
231
|
+
rest_id: "user123",
|
|
232
|
+
legacy: {
|
|
233
|
+
screen_name: "testuser",
|
|
234
|
+
name: "Test User",
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
globalThis.fetch = mock(() =>
|
|
252
|
+
Promise.resolve(mockResponse(tweetResponse))
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const result = await client.getTweet("123456");
|
|
256
|
+
expect(result.success).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("returns error on HTTP failure", async () => {
|
|
260
|
+
const client = new XClient({ cookies: validCookies });
|
|
261
|
+
globalThis.fetch = mock(() =>
|
|
262
|
+
Promise.resolve(mockResponse("Not found", { status: 500, ok: false }))
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const result = await client.getTweet("123456");
|
|
266
|
+
expect(result.success).toBe(false);
|
|
267
|
+
if (!result.success) {
|
|
268
|
+
expect(result.error).toContain("HTTP 500");
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("returns error on GraphQL errors", async () => {
|
|
273
|
+
const client = new XClient({ cookies: validCookies });
|
|
274
|
+
globalThis.fetch = mock(() =>
|
|
275
|
+
Promise.resolve(
|
|
276
|
+
mockResponse({
|
|
277
|
+
errors: [{ message: "Tweet not found" }],
|
|
278
|
+
})
|
|
279
|
+
)
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const result = await client.getTweet("123456");
|
|
283
|
+
expect(result.success).toBe(false);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("returns error if tweet not found in response", async () => {
|
|
287
|
+
const client = new XClient({ cookies: validCookies });
|
|
288
|
+
globalThis.fetch = mock(() =>
|
|
289
|
+
Promise.resolve(mockResponse({ data: {} }))
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const result = await client.getTweet("123456");
|
|
293
|
+
expect(result.success).toBe(false);
|
|
294
|
+
if (!result.success) {
|
|
295
|
+
expect(result.error).toBe("Tweet not found in response");
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("handles fetch exception", async () => {
|
|
300
|
+
const client = new XClient({ cookies: validCookies });
|
|
301
|
+
globalThis.fetch = mock(() => Promise.reject(new Error("Network error")));
|
|
302
|
+
|
|
303
|
+
const result = await client.getTweet("123456");
|
|
304
|
+
expect(result.success).toBe(false);
|
|
305
|
+
if (!result.success) {
|
|
306
|
+
expect(result.error).toBe("Network error");
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("retries with refreshed query IDs on 404", async () => {
|
|
311
|
+
const client = new XClient({ cookies: validCookies });
|
|
312
|
+
|
|
313
|
+
// All calls return 404 to test the failure case
|
|
314
|
+
globalThis.fetch = mock(() =>
|
|
315
|
+
Promise.resolve(mockResponse("Not found", { status: 404, ok: false }))
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const result = await client.getTweet("123456");
|
|
319
|
+
expect(result.success).toBe(false);
|
|
320
|
+
if (!result.success) {
|
|
321
|
+
expect(result.error).toContain("404");
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("extracts article text when present", async () => {
|
|
326
|
+
const client = new XClient({ cookies: validCookies });
|
|
327
|
+
const tweetResponse = {
|
|
328
|
+
data: {
|
|
329
|
+
tweetResult: {
|
|
330
|
+
result: {
|
|
331
|
+
rest_id: "123456",
|
|
332
|
+
article: {
|
|
333
|
+
article_results: {
|
|
334
|
+
result: {
|
|
335
|
+
title: "Article Title",
|
|
336
|
+
plain_text: "This is the article body text.",
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
core: {
|
|
341
|
+
user_results: {
|
|
342
|
+
result: {
|
|
343
|
+
rest_id: "user123",
|
|
344
|
+
legacy: { screen_name: "testuser", name: "Test User" },
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
globalThis.fetch = mock(() =>
|
|
354
|
+
Promise.resolve(mockResponse(tweetResponse))
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const result = await client.getTweet("123456");
|
|
358
|
+
expect(result.success).toBe(true);
|
|
359
|
+
if (result.success) {
|
|
360
|
+
expect(result.tweet?.text).toContain("Article Title");
|
|
361
|
+
expect(result.tweet?.text).toContain("article body text");
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("extracts note tweet text when present", async () => {
|
|
366
|
+
const client = new XClient({ cookies: validCookies });
|
|
367
|
+
const tweetResponse = {
|
|
368
|
+
data: {
|
|
369
|
+
tweetResult: {
|
|
370
|
+
result: {
|
|
371
|
+
rest_id: "123456",
|
|
372
|
+
note_tweet: {
|
|
373
|
+
note_tweet_results: {
|
|
374
|
+
result: {
|
|
375
|
+
text: "This is a long note tweet that exceeds 280 characters.",
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
core: {
|
|
380
|
+
user_results: {
|
|
381
|
+
result: {
|
|
382
|
+
rest_id: "user123",
|
|
383
|
+
legacy: { screen_name: "testuser", name: "Test User" },
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
globalThis.fetch = mock(() =>
|
|
393
|
+
Promise.resolve(mockResponse(tweetResponse))
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
const result = await client.getTweet("123456");
|
|
397
|
+
expect(result.success).toBe(true);
|
|
398
|
+
if (result.success) {
|
|
399
|
+
expect(result.tweet?.text).toContain("long note tweet");
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("handles article with title only, fetches from UserArticlesTweets", async () => {
|
|
404
|
+
const client = new XClient({ cookies: validCookies });
|
|
405
|
+
let callCount = 0;
|
|
406
|
+
|
|
407
|
+
globalThis.fetch = mock(() => {
|
|
408
|
+
callCount++;
|
|
409
|
+
if (callCount === 1) {
|
|
410
|
+
// TweetDetail response with title-only article
|
|
411
|
+
return Promise.resolve(
|
|
412
|
+
mockResponse({
|
|
413
|
+
data: {
|
|
414
|
+
tweetResult: {
|
|
415
|
+
result: {
|
|
416
|
+
rest_id: "123456",
|
|
417
|
+
article: {
|
|
418
|
+
article_results: {
|
|
419
|
+
result: { title: "Title Only" },
|
|
420
|
+
},
|
|
421
|
+
title: "Title Only",
|
|
422
|
+
},
|
|
423
|
+
core: {
|
|
424
|
+
user_results: {
|
|
425
|
+
result: {
|
|
426
|
+
rest_id: "user123",
|
|
427
|
+
legacy: {
|
|
428
|
+
screen_name: "testuser",
|
|
429
|
+
name: "Test User",
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
})
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
// UserArticlesTweets fallback
|
|
441
|
+
return Promise.resolve(
|
|
442
|
+
mockResponse({
|
|
443
|
+
data: {
|
|
444
|
+
user: {
|
|
445
|
+
result: {
|
|
446
|
+
timeline: {
|
|
447
|
+
timeline: {
|
|
448
|
+
instructions: [
|
|
449
|
+
{
|
|
450
|
+
entries: [
|
|
451
|
+
{
|
|
452
|
+
content: {
|
|
453
|
+
itemContent: {
|
|
454
|
+
tweet_results: {
|
|
455
|
+
result: {
|
|
456
|
+
rest_id: "123456",
|
|
457
|
+
article: {
|
|
458
|
+
article_results: {
|
|
459
|
+
result: {
|
|
460
|
+
title: "Title Only",
|
|
461
|
+
plain_text:
|
|
462
|
+
"Article body from fallback.",
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
},
|
|
473
|
+
],
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
})
|
|
480
|
+
);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
const result = await client.getTweet("123456");
|
|
484
|
+
expect(result.success).toBe(true);
|
|
485
|
+
if (result.success) {
|
|
486
|
+
expect(result.tweet?.text).toContain("Article body from fallback");
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("handles debug article logging", async () => {
|
|
491
|
+
process.env.XFEED_DEBUG_ARTICLE = "1";
|
|
492
|
+
const client = new XClient({ cookies: validCookies });
|
|
493
|
+
const tweetResponse = {
|
|
494
|
+
data: {
|
|
495
|
+
tweetResult: {
|
|
496
|
+
result: {
|
|
497
|
+
rest_id: "123456",
|
|
498
|
+
article: {
|
|
499
|
+
article_results: { result: { title: "Debug Article" } },
|
|
500
|
+
},
|
|
501
|
+
core: {
|
|
502
|
+
user_results: {
|
|
503
|
+
result: {
|
|
504
|
+
rest_id: "user123",
|
|
505
|
+
legacy: { screen_name: "testuser", name: "Test User" },
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
globalThis.fetch = mock(() =>
|
|
515
|
+
Promise.resolve(mockResponse(tweetResponse))
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
const result = await client.getTweet("123456");
|
|
519
|
+
expect(result.success).toBe(true);
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
describe("tweet", () => {
|
|
524
|
+
it("posts tweet successfully", async () => {
|
|
525
|
+
const client = new XClient({ cookies: validCookies });
|
|
526
|
+
globalThis.fetch = mock(() =>
|
|
527
|
+
Promise.resolve(
|
|
528
|
+
mockResponse({
|
|
529
|
+
data: {
|
|
530
|
+
create_tweet: {
|
|
531
|
+
tweet_results: {
|
|
532
|
+
result: { rest_id: "new-tweet-123" },
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
})
|
|
537
|
+
)
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
const result = await client.tweet("Hello Twitter!");
|
|
541
|
+
expect(result.success).toBe(true);
|
|
542
|
+
if (result.success) {
|
|
543
|
+
expect(result.tweetId).toBe("new-tweet-123");
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("posts tweet with media IDs", async () => {
|
|
548
|
+
const client = new XClient({ cookies: validCookies });
|
|
549
|
+
globalThis.fetch = mock(() =>
|
|
550
|
+
Promise.resolve(
|
|
551
|
+
mockResponse({
|
|
552
|
+
data: {
|
|
553
|
+
create_tweet: {
|
|
554
|
+
tweet_results: {
|
|
555
|
+
result: { rest_id: "new-tweet-456" },
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
})
|
|
560
|
+
)
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
const result = await client.tweet("Tweet with media", [
|
|
564
|
+
"media1",
|
|
565
|
+
"media2",
|
|
566
|
+
]);
|
|
567
|
+
expect(result.success).toBe(true);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("returns error on HTTP failure", async () => {
|
|
571
|
+
const client = new XClient({ cookies: validCookies });
|
|
572
|
+
globalThis.fetch = mock(() =>
|
|
573
|
+
Promise.resolve(mockResponse("Error", { status: 403, ok: false }))
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
const result = await client.tweet("Hello");
|
|
577
|
+
expect(result.success).toBe(false);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("returns error on GraphQL errors", async () => {
|
|
581
|
+
const client = new XClient({ cookies: validCookies });
|
|
582
|
+
globalThis.fetch = mock(() =>
|
|
583
|
+
Promise.resolve(
|
|
584
|
+
mockResponse({
|
|
585
|
+
errors: [{ message: "Rate limited", code: 88 }],
|
|
586
|
+
})
|
|
587
|
+
)
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
const result = await client.tweet("Hello");
|
|
591
|
+
expect(result.success).toBe(false);
|
|
592
|
+
if (!result.success) {
|
|
593
|
+
expect(result.error).toContain("Rate limited");
|
|
594
|
+
expect(result.error).toContain("88");
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it("returns error if no tweet ID returned", async () => {
|
|
599
|
+
const client = new XClient({ cookies: validCookies });
|
|
600
|
+
globalThis.fetch = mock(() =>
|
|
601
|
+
Promise.resolve(
|
|
602
|
+
mockResponse({
|
|
603
|
+
data: { create_tweet: { tweet_results: { result: {} } } },
|
|
604
|
+
})
|
|
605
|
+
)
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
const result = await client.tweet("Hello");
|
|
609
|
+
expect(result.success).toBe(false);
|
|
610
|
+
if (!result.success) {
|
|
611
|
+
expect(result.error).toBe("Tweet created but no ID returned");
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("retries on 404 and succeeds", async () => {
|
|
616
|
+
const client = new XClient({ cookies: validCookies });
|
|
617
|
+
let callCount = 0;
|
|
618
|
+
|
|
619
|
+
globalThis.fetch = mock(() => {
|
|
620
|
+
callCount++;
|
|
621
|
+
if (callCount === 1) {
|
|
622
|
+
return Promise.resolve(
|
|
623
|
+
mockResponse("Not found", { status: 404, ok: false })
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
return Promise.resolve(
|
|
627
|
+
mockResponse({
|
|
628
|
+
data: {
|
|
629
|
+
create_tweet: {
|
|
630
|
+
tweet_results: {
|
|
631
|
+
result: { rest_id: "retry-tweet-123" },
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
},
|
|
635
|
+
})
|
|
636
|
+
);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
const result = await client.tweet("Hello");
|
|
640
|
+
expect(result.success).toBe(true);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it("falls back to status update on error 226", async () => {
|
|
644
|
+
const client = new XClient({ cookies: validCookies });
|
|
645
|
+
let callCount = 0;
|
|
646
|
+
|
|
647
|
+
globalThis.fetch = mock(() => {
|
|
648
|
+
callCount++;
|
|
649
|
+
if (callCount === 1) {
|
|
650
|
+
return Promise.resolve(
|
|
651
|
+
mockResponse({
|
|
652
|
+
errors: [{ message: "Automation detected", code: 226 }],
|
|
653
|
+
})
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
// Status update fallback
|
|
657
|
+
return Promise.resolve(
|
|
658
|
+
mockResponse({
|
|
659
|
+
id_str: "fallback-tweet-123",
|
|
660
|
+
})
|
|
661
|
+
);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
const result = await client.tweet("Hello");
|
|
665
|
+
expect(result.success).toBe(true);
|
|
666
|
+
if (result.success) {
|
|
667
|
+
expect(result.tweetId).toBe("fallback-tweet-123");
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("handles fetch exception", async () => {
|
|
672
|
+
const client = new XClient({ cookies: validCookies });
|
|
673
|
+
globalThis.fetch = mock(() =>
|
|
674
|
+
Promise.reject(new Error("Connection refused"))
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
const result = await client.tweet("Hello");
|
|
678
|
+
expect(result.success).toBe(false);
|
|
679
|
+
if (!result.success) {
|
|
680
|
+
expect(result.error).toBe("Connection refused");
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
describe("reply", () => {
|
|
686
|
+
it("posts reply successfully", async () => {
|
|
687
|
+
const client = new XClient({ cookies: validCookies });
|
|
688
|
+
globalThis.fetch = mock(() =>
|
|
689
|
+
Promise.resolve(
|
|
690
|
+
mockResponse({
|
|
691
|
+
data: {
|
|
692
|
+
create_tweet: {
|
|
693
|
+
tweet_results: {
|
|
694
|
+
result: { rest_id: "reply-123" },
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
},
|
|
698
|
+
})
|
|
699
|
+
)
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
const result = await client.reply(
|
|
703
|
+
"This is a reply",
|
|
704
|
+
"original-tweet-123"
|
|
705
|
+
);
|
|
706
|
+
expect(result.success).toBe(true);
|
|
707
|
+
if (result.success) {
|
|
708
|
+
expect(result.tweetId).toBe("reply-123");
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it("posts reply with media", async () => {
|
|
713
|
+
const client = new XClient({ cookies: validCookies });
|
|
714
|
+
globalThis.fetch = mock(() =>
|
|
715
|
+
Promise.resolve(
|
|
716
|
+
mockResponse({
|
|
717
|
+
data: {
|
|
718
|
+
create_tweet: {
|
|
719
|
+
tweet_results: {
|
|
720
|
+
result: { rest_id: "reply-456" },
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
},
|
|
724
|
+
})
|
|
725
|
+
)
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
const result = await client.reply("Reply with media", "original-123", [
|
|
729
|
+
"media1",
|
|
730
|
+
]);
|
|
731
|
+
expect(result.success).toBe(true);
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
describe("search", () => {
|
|
736
|
+
it("returns search results on success", async () => {
|
|
737
|
+
const client = new XClient({ cookies: validCookies });
|
|
738
|
+
globalThis.fetch = mock(() =>
|
|
739
|
+
Promise.resolve(
|
|
740
|
+
mockResponse({
|
|
741
|
+
data: {
|
|
742
|
+
search_by_raw_query: {
|
|
743
|
+
search_timeline: {
|
|
744
|
+
timeline: {
|
|
745
|
+
instructions: [
|
|
746
|
+
{
|
|
747
|
+
entries: [
|
|
748
|
+
{
|
|
749
|
+
content: {
|
|
750
|
+
itemContent: {
|
|
751
|
+
tweet_results: {
|
|
752
|
+
result: {
|
|
753
|
+
rest_id: "search-tweet-1",
|
|
754
|
+
legacy: { full_text: "Search result 1" },
|
|
755
|
+
core: {
|
|
756
|
+
user_results: {
|
|
757
|
+
result: {
|
|
758
|
+
rest_id: "user1",
|
|
759
|
+
legacy: {
|
|
760
|
+
screen_name: "user1",
|
|
761
|
+
name: "User 1",
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
},
|
|
768
|
+
},
|
|
769
|
+
},
|
|
770
|
+
},
|
|
771
|
+
],
|
|
772
|
+
},
|
|
773
|
+
],
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
},
|
|
778
|
+
})
|
|
779
|
+
)
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
const result = await client.search("test query");
|
|
783
|
+
expect(result.success).toBe(true);
|
|
784
|
+
if (result.success) {
|
|
785
|
+
expect(result.tweets?.length).toBeGreaterThan(0);
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it("returns error on HTTP failure", async () => {
|
|
790
|
+
const client = new XClient({ cookies: validCookies });
|
|
791
|
+
globalThis.fetch = mock(() =>
|
|
792
|
+
Promise.resolve(mockResponse("Error", { status: 500, ok: false }))
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
const result = await client.search("test");
|
|
796
|
+
expect(result.success).toBe(false);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it("returns error on GraphQL errors", async () => {
|
|
800
|
+
const client = new XClient({ cookies: validCookies });
|
|
801
|
+
globalThis.fetch = mock(() =>
|
|
802
|
+
Promise.resolve(
|
|
803
|
+
mockResponse({
|
|
804
|
+
errors: [{ message: "Search error" }],
|
|
805
|
+
})
|
|
806
|
+
)
|
|
807
|
+
);
|
|
808
|
+
|
|
809
|
+
const result = await client.search("test");
|
|
810
|
+
expect(result.success).toBe(false);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it("retries on 404", async () => {
|
|
814
|
+
const client = new XClient({ cookies: validCookies });
|
|
815
|
+
|
|
816
|
+
// All calls return 404 to test failure case
|
|
817
|
+
globalThis.fetch = mock(() =>
|
|
818
|
+
Promise.resolve(mockResponse("Not found", { status: 404, ok: false }))
|
|
819
|
+
);
|
|
820
|
+
|
|
821
|
+
const result = await client.search("test");
|
|
822
|
+
expect(result.success).toBe(false);
|
|
823
|
+
if (!result.success) {
|
|
824
|
+
expect(result.error).toContain("404");
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it("handles fetch exception", async () => {
|
|
829
|
+
const client = new XClient({ cookies: validCookies });
|
|
830
|
+
globalThis.fetch = mock(() =>
|
|
831
|
+
Promise.reject(new Error("Network timeout"))
|
|
832
|
+
);
|
|
833
|
+
|
|
834
|
+
const result = await client.search("test");
|
|
835
|
+
expect(result.success).toBe(false);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it("uses custom count parameter", async () => {
|
|
839
|
+
const client = new XClient({ cookies: validCookies });
|
|
840
|
+
globalThis.fetch = mock(() =>
|
|
841
|
+
Promise.resolve(
|
|
842
|
+
mockResponse({
|
|
843
|
+
data: {
|
|
844
|
+
search_by_raw_query: {
|
|
845
|
+
search_timeline: {
|
|
846
|
+
timeline: { instructions: [] },
|
|
847
|
+
},
|
|
848
|
+
},
|
|
849
|
+
},
|
|
850
|
+
})
|
|
851
|
+
)
|
|
852
|
+
);
|
|
853
|
+
|
|
854
|
+
const result = await client.search("test", 50);
|
|
855
|
+
expect(result.success).toBe(true);
|
|
856
|
+
});
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
describe("getCurrentUser", () => {
|
|
860
|
+
it("returns user from settings endpoint", async () => {
|
|
861
|
+
const client = new XClient({ cookies: validCookies });
|
|
862
|
+
globalThis.fetch = mock(() =>
|
|
863
|
+
Promise.resolve(
|
|
864
|
+
mockResponse({
|
|
865
|
+
screen_name: "currentuser",
|
|
866
|
+
name: "Current User",
|
|
867
|
+
user_id: "12345",
|
|
868
|
+
})
|
|
869
|
+
)
|
|
870
|
+
);
|
|
871
|
+
|
|
872
|
+
const result = await client.getCurrentUser();
|
|
873
|
+
expect(result.success).toBe(true);
|
|
874
|
+
if (result.success) {
|
|
875
|
+
expect(result.user?.username).toBe("currentuser");
|
|
876
|
+
expect(result.user?.id).toBe("12345");
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it("returns user from verify_credentials endpoint", async () => {
|
|
881
|
+
const client = new XClient({ cookies: validCookies });
|
|
882
|
+
let callCount = 0;
|
|
883
|
+
|
|
884
|
+
globalThis.fetch = mock(() => {
|
|
885
|
+
callCount++;
|
|
886
|
+
if (callCount <= 2) {
|
|
887
|
+
return Promise.resolve(
|
|
888
|
+
mockResponse("Error", { status: 401, ok: false })
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
return Promise.resolve(
|
|
892
|
+
mockResponse({
|
|
893
|
+
screen_name: "verifieduser",
|
|
894
|
+
name: "Verified User",
|
|
895
|
+
user_id_str: "67890",
|
|
896
|
+
})
|
|
897
|
+
);
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
const result = await client.getCurrentUser();
|
|
901
|
+
expect(result.success).toBe(true);
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
it("returns user with nested user object", async () => {
|
|
905
|
+
const client = new XClient({ cookies: validCookies });
|
|
906
|
+
globalThis.fetch = mock(() =>
|
|
907
|
+
Promise.resolve(
|
|
908
|
+
mockResponse({
|
|
909
|
+
user: {
|
|
910
|
+
screen_name: "nesteduser",
|
|
911
|
+
name: "Nested User",
|
|
912
|
+
id_str: "11111",
|
|
913
|
+
},
|
|
914
|
+
})
|
|
915
|
+
)
|
|
916
|
+
);
|
|
917
|
+
|
|
918
|
+
const result = await client.getCurrentUser();
|
|
919
|
+
expect(result.success).toBe(true);
|
|
920
|
+
if (result.success) {
|
|
921
|
+
expect(result.user?.username).toBe("nesteduser");
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
it("falls back to settings page HTML parsing", async () => {
|
|
926
|
+
const client = new XClient({ cookies: validCookies });
|
|
927
|
+
let callCount = 0;
|
|
928
|
+
|
|
929
|
+
globalThis.fetch = mock(() => {
|
|
930
|
+
callCount++;
|
|
931
|
+
if (callCount <= 4) {
|
|
932
|
+
return Promise.resolve(
|
|
933
|
+
mockResponse("Error", { status: 401, ok: false })
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
return Promise.resolve(
|
|
937
|
+
mockResponse(
|
|
938
|
+
`<html>some content "screen_name":"htmluser" and "user_id":"99999" with "name":"HTML User"</html>`
|
|
939
|
+
)
|
|
940
|
+
);
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
const result = await client.getCurrentUser();
|
|
944
|
+
expect(result.success).toBe(true);
|
|
945
|
+
if (result.success) {
|
|
946
|
+
expect(result.user?.username).toBe("htmluser");
|
|
947
|
+
expect(result.user?.id).toBe("99999");
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
it("returns error when all endpoints fail", async () => {
|
|
952
|
+
const client = new XClient({ cookies: validCookies });
|
|
953
|
+
globalThis.fetch = mock(() =>
|
|
954
|
+
Promise.resolve(mockResponse("Error", { status: 401, ok: false }))
|
|
955
|
+
);
|
|
956
|
+
|
|
957
|
+
const result = await client.getCurrentUser();
|
|
958
|
+
expect(result.success).toBe(false);
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
it("handles JSON parse error gracefully", async () => {
|
|
962
|
+
const client = new XClient({ cookies: validCookies });
|
|
963
|
+
let callCount = 0;
|
|
964
|
+
|
|
965
|
+
globalThis.fetch = mock(() => {
|
|
966
|
+
callCount++;
|
|
967
|
+
if (callCount === 1) {
|
|
968
|
+
return Promise.resolve({
|
|
969
|
+
ok: true,
|
|
970
|
+
status: 200,
|
|
971
|
+
text: () => Promise.resolve("not json"),
|
|
972
|
+
json: () => Promise.reject(new Error("Invalid JSON")),
|
|
973
|
+
} as Response);
|
|
974
|
+
}
|
|
975
|
+
return Promise.resolve(
|
|
976
|
+
mockResponse({
|
|
977
|
+
screen_name: "fallbackuser",
|
|
978
|
+
user_id: "12345",
|
|
979
|
+
})
|
|
980
|
+
);
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
const result = await client.getCurrentUser();
|
|
984
|
+
expect(result.success).toBe(true);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
it("handles fetch exception", async () => {
|
|
988
|
+
const client = new XClient({ cookies: validCookies });
|
|
989
|
+
globalThis.fetch = mock(() => Promise.reject(new Error("Network error")));
|
|
990
|
+
|
|
991
|
+
const result = await client.getCurrentUser();
|
|
992
|
+
expect(result.success).toBe(false);
|
|
993
|
+
});
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
describe("getReplies", () => {
|
|
997
|
+
it("returns replies to a tweet", async () => {
|
|
998
|
+
const client = new XClient({ cookies: validCookies });
|
|
999
|
+
globalThis.fetch = mock(() =>
|
|
1000
|
+
Promise.resolve(
|
|
1001
|
+
mockResponse({
|
|
1002
|
+
data: {
|
|
1003
|
+
threaded_conversation_with_injections_v2: {
|
|
1004
|
+
instructions: [
|
|
1005
|
+
{
|
|
1006
|
+
entries: [
|
|
1007
|
+
{
|
|
1008
|
+
content: {
|
|
1009
|
+
itemContent: {
|
|
1010
|
+
tweet_results: {
|
|
1011
|
+
result: {
|
|
1012
|
+
rest_id: "reply-1",
|
|
1013
|
+
legacy: {
|
|
1014
|
+
full_text: "This is a reply",
|
|
1015
|
+
in_reply_to_status_id_str: "original-123",
|
|
1016
|
+
},
|
|
1017
|
+
core: {
|
|
1018
|
+
user_results: {
|
|
1019
|
+
result: {
|
|
1020
|
+
rest_id: "user1",
|
|
1021
|
+
legacy: {
|
|
1022
|
+
screen_name: "replier",
|
|
1023
|
+
name: "Replier",
|
|
1024
|
+
},
|
|
1025
|
+
},
|
|
1026
|
+
},
|
|
1027
|
+
},
|
|
1028
|
+
},
|
|
1029
|
+
},
|
|
1030
|
+
},
|
|
1031
|
+
},
|
|
1032
|
+
},
|
|
1033
|
+
],
|
|
1034
|
+
},
|
|
1035
|
+
],
|
|
1036
|
+
},
|
|
1037
|
+
},
|
|
1038
|
+
})
|
|
1039
|
+
)
|
|
1040
|
+
);
|
|
1041
|
+
|
|
1042
|
+
const result = await client.getReplies("original-123");
|
|
1043
|
+
expect(result.success).toBe(true);
|
|
1044
|
+
if (result.success) {
|
|
1045
|
+
expect(result.tweets?.length).toBe(1);
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
it("returns error on failure", async () => {
|
|
1050
|
+
const client = new XClient({ cookies: validCookies });
|
|
1051
|
+
globalThis.fetch = mock(() =>
|
|
1052
|
+
Promise.resolve(mockResponse("Error", { status: 500, ok: false }))
|
|
1053
|
+
);
|
|
1054
|
+
|
|
1055
|
+
const result = await client.getReplies("123");
|
|
1056
|
+
expect(result.success).toBe(false);
|
|
1057
|
+
});
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
describe("getThread", () => {
|
|
1061
|
+
it("returns full thread sorted by time", async () => {
|
|
1062
|
+
const client = new XClient({ cookies: validCookies });
|
|
1063
|
+
globalThis.fetch = mock(() =>
|
|
1064
|
+
Promise.resolve(
|
|
1065
|
+
mockResponse({
|
|
1066
|
+
data: {
|
|
1067
|
+
threaded_conversation_with_injections_v2: {
|
|
1068
|
+
instructions: [
|
|
1069
|
+
{
|
|
1070
|
+
entries: [
|
|
1071
|
+
{
|
|
1072
|
+
content: {
|
|
1073
|
+
itemContent: {
|
|
1074
|
+
tweet_results: {
|
|
1075
|
+
result: {
|
|
1076
|
+
rest_id: "tweet-1",
|
|
1077
|
+
legacy: {
|
|
1078
|
+
full_text: "First tweet",
|
|
1079
|
+
created_at: "Wed Oct 10 10:00:00 +0000 2018",
|
|
1080
|
+
conversation_id_str: "conv-123",
|
|
1081
|
+
},
|
|
1082
|
+
core: {
|
|
1083
|
+
user_results: {
|
|
1084
|
+
result: {
|
|
1085
|
+
rest_id: "user1",
|
|
1086
|
+
legacy: {
|
|
1087
|
+
screen_name: "user1",
|
|
1088
|
+
name: "User 1",
|
|
1089
|
+
},
|
|
1090
|
+
},
|
|
1091
|
+
},
|
|
1092
|
+
},
|
|
1093
|
+
},
|
|
1094
|
+
},
|
|
1095
|
+
},
|
|
1096
|
+
},
|
|
1097
|
+
},
|
|
1098
|
+
{
|
|
1099
|
+
content: {
|
|
1100
|
+
itemContent: {
|
|
1101
|
+
tweet_results: {
|
|
1102
|
+
result: {
|
|
1103
|
+
rest_id: "tweet-2",
|
|
1104
|
+
legacy: {
|
|
1105
|
+
full_text: "Second tweet",
|
|
1106
|
+
created_at: "Wed Oct 10 11:00:00 +0000 2018",
|
|
1107
|
+
conversation_id_str: "conv-123",
|
|
1108
|
+
},
|
|
1109
|
+
core: {
|
|
1110
|
+
user_results: {
|
|
1111
|
+
result: {
|
|
1112
|
+
rest_id: "user1",
|
|
1113
|
+
legacy: {
|
|
1114
|
+
screen_name: "user1",
|
|
1115
|
+
name: "User 1",
|
|
1116
|
+
},
|
|
1117
|
+
},
|
|
1118
|
+
},
|
|
1119
|
+
},
|
|
1120
|
+
},
|
|
1121
|
+
},
|
|
1122
|
+
},
|
|
1123
|
+
},
|
|
1124
|
+
},
|
|
1125
|
+
],
|
|
1126
|
+
},
|
|
1127
|
+
],
|
|
1128
|
+
},
|
|
1129
|
+
},
|
|
1130
|
+
})
|
|
1131
|
+
)
|
|
1132
|
+
);
|
|
1133
|
+
|
|
1134
|
+
const result = await client.getThread("tweet-1");
|
|
1135
|
+
expect(result.success).toBe(true);
|
|
1136
|
+
if (result.success) {
|
|
1137
|
+
expect(result.tweets?.length).toBe(2);
|
|
1138
|
+
// Should be sorted by time
|
|
1139
|
+
expect(result.tweets?.[0]?.id).toBe("tweet-1");
|
|
1140
|
+
}
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
it("returns error on failure", async () => {
|
|
1144
|
+
const client = new XClient({ cookies: validCookies });
|
|
1145
|
+
globalThis.fetch = mock(() =>
|
|
1146
|
+
Promise.resolve(mockResponse("Error", { status: 500, ok: false }))
|
|
1147
|
+
);
|
|
1148
|
+
|
|
1149
|
+
const result = await client.getThread("123");
|
|
1150
|
+
expect(result.success).toBe(false);
|
|
1151
|
+
});
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
describe("getBookmarks", () => {
|
|
1155
|
+
it("returns bookmarks on success", async () => {
|
|
1156
|
+
const client = new XClient({ cookies: validCookies });
|
|
1157
|
+
globalThis.fetch = mock(() =>
|
|
1158
|
+
Promise.resolve(
|
|
1159
|
+
mockResponse({
|
|
1160
|
+
data: {
|
|
1161
|
+
bookmark_timeline_v2: {
|
|
1162
|
+
timeline: {
|
|
1163
|
+
instructions: [
|
|
1164
|
+
{
|
|
1165
|
+
entries: [
|
|
1166
|
+
{
|
|
1167
|
+
content: {
|
|
1168
|
+
itemContent: {
|
|
1169
|
+
tweet_results: {
|
|
1170
|
+
result: {
|
|
1171
|
+
rest_id: "bookmark-1",
|
|
1172
|
+
legacy: { full_text: "Bookmarked tweet" },
|
|
1173
|
+
core: {
|
|
1174
|
+
user_results: {
|
|
1175
|
+
result: {
|
|
1176
|
+
rest_id: "user1",
|
|
1177
|
+
legacy: {
|
|
1178
|
+
screen_name: "user1",
|
|
1179
|
+
name: "User 1",
|
|
1180
|
+
},
|
|
1181
|
+
},
|
|
1182
|
+
},
|
|
1183
|
+
},
|
|
1184
|
+
},
|
|
1185
|
+
},
|
|
1186
|
+
},
|
|
1187
|
+
},
|
|
1188
|
+
},
|
|
1189
|
+
],
|
|
1190
|
+
},
|
|
1191
|
+
],
|
|
1192
|
+
},
|
|
1193
|
+
},
|
|
1194
|
+
},
|
|
1195
|
+
})
|
|
1196
|
+
)
|
|
1197
|
+
);
|
|
1198
|
+
|
|
1199
|
+
const result = await client.getBookmarks();
|
|
1200
|
+
expect(result.success).toBe(true);
|
|
1201
|
+
if (result.success) {
|
|
1202
|
+
expect(result.tweets?.length).toBeGreaterThan(0);
|
|
1203
|
+
}
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
it("returns error on HTTP failure", async () => {
|
|
1207
|
+
const client = new XClient({ cookies: validCookies });
|
|
1208
|
+
globalThis.fetch = mock(() =>
|
|
1209
|
+
Promise.resolve(mockResponse("Error", { status: 500, ok: false }))
|
|
1210
|
+
);
|
|
1211
|
+
|
|
1212
|
+
const result = await client.getBookmarks();
|
|
1213
|
+
expect(result.success).toBe(false);
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
it("returns error on GraphQL errors", async () => {
|
|
1217
|
+
const client = new XClient({ cookies: validCookies });
|
|
1218
|
+
globalThis.fetch = mock(() =>
|
|
1219
|
+
Promise.resolve(
|
|
1220
|
+
mockResponse({
|
|
1221
|
+
errors: [{ message: "Bookmarks error" }],
|
|
1222
|
+
})
|
|
1223
|
+
)
|
|
1224
|
+
);
|
|
1225
|
+
|
|
1226
|
+
const result = await client.getBookmarks();
|
|
1227
|
+
expect(result.success).toBe(false);
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
it("retries on 404", async () => {
|
|
1231
|
+
const client = new XClient({ cookies: validCookies });
|
|
1232
|
+
|
|
1233
|
+
// All calls return 404 to test failure case
|
|
1234
|
+
globalThis.fetch = mock(() =>
|
|
1235
|
+
Promise.resolve(mockResponse("Not found", { status: 404, ok: false }))
|
|
1236
|
+
);
|
|
1237
|
+
|
|
1238
|
+
const result = await client.getBookmarks();
|
|
1239
|
+
expect(result.success).toBe(false);
|
|
1240
|
+
if (!result.success) {
|
|
1241
|
+
expect(result.error).toContain("404");
|
|
1242
|
+
}
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
it("uses custom count parameter", async () => {
|
|
1246
|
+
const client = new XClient({ cookies: validCookies });
|
|
1247
|
+
globalThis.fetch = mock(() =>
|
|
1248
|
+
Promise.resolve(
|
|
1249
|
+
mockResponse({
|
|
1250
|
+
data: {
|
|
1251
|
+
bookmark_timeline_v2: {
|
|
1252
|
+
timeline: { instructions: [] },
|
|
1253
|
+
},
|
|
1254
|
+
},
|
|
1255
|
+
})
|
|
1256
|
+
)
|
|
1257
|
+
);
|
|
1258
|
+
|
|
1259
|
+
const result = await client.getBookmarks(50);
|
|
1260
|
+
expect(result.success).toBe(true);
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
it("handles fetch exception", async () => {
|
|
1264
|
+
const client = new XClient({ cookies: validCookies });
|
|
1265
|
+
globalThis.fetch = mock(() => Promise.reject(new Error("Timeout")));
|
|
1266
|
+
|
|
1267
|
+
const result = await client.getBookmarks();
|
|
1268
|
+
expect(result.success).toBe(false);
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
it("accepts cursor parameter for pagination", async () => {
|
|
1272
|
+
const client = new XClient({ cookies: validCookies });
|
|
1273
|
+
let capturedUrl = "";
|
|
1274
|
+
globalThis.fetch = mock((url: string) => {
|
|
1275
|
+
capturedUrl = url;
|
|
1276
|
+
return Promise.resolve(
|
|
1277
|
+
mockResponse({
|
|
1278
|
+
data: {
|
|
1279
|
+
bookmark_timeline_v2: {
|
|
1280
|
+
timeline: { instructions: [] },
|
|
1281
|
+
},
|
|
1282
|
+
},
|
|
1283
|
+
})
|
|
1284
|
+
);
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
await client.getBookmarks(20, "test-bookmark-cursor");
|
|
1288
|
+
expect(capturedUrl).toContain("cursor");
|
|
1289
|
+
expect(capturedUrl).toContain("test-bookmark-cursor");
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
it("returns nextCursor from response", async () => {
|
|
1293
|
+
const client = new XClient({ cookies: validCookies });
|
|
1294
|
+
globalThis.fetch = mock(() =>
|
|
1295
|
+
Promise.resolve(
|
|
1296
|
+
mockResponse({
|
|
1297
|
+
data: {
|
|
1298
|
+
bookmark_timeline_v2: {
|
|
1299
|
+
timeline: {
|
|
1300
|
+
instructions: [
|
|
1301
|
+
{
|
|
1302
|
+
entries: [
|
|
1303
|
+
{
|
|
1304
|
+
entryId: "cursor-bottom",
|
|
1305
|
+
content: {
|
|
1306
|
+
cursorType: "Bottom",
|
|
1307
|
+
value: "bookmark-next-cursor-123",
|
|
1308
|
+
},
|
|
1309
|
+
},
|
|
1310
|
+
],
|
|
1311
|
+
},
|
|
1312
|
+
],
|
|
1313
|
+
},
|
|
1314
|
+
},
|
|
1315
|
+
},
|
|
1316
|
+
})
|
|
1317
|
+
)
|
|
1318
|
+
);
|
|
1319
|
+
|
|
1320
|
+
const result = await client.getBookmarks();
|
|
1321
|
+
expect(result.success).toBe(true);
|
|
1322
|
+
if (result.success) {
|
|
1323
|
+
expect(result.nextCursor).toBe("bookmark-next-cursor-123");
|
|
1324
|
+
}
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
it("returns undefined nextCursor when no cursor in response", async () => {
|
|
1328
|
+
const client = new XClient({ cookies: validCookies });
|
|
1329
|
+
globalThis.fetch = mock(() =>
|
|
1330
|
+
Promise.resolve(
|
|
1331
|
+
mockResponse({
|
|
1332
|
+
data: {
|
|
1333
|
+
bookmark_timeline_v2: {
|
|
1334
|
+
timeline: { instructions: [] },
|
|
1335
|
+
},
|
|
1336
|
+
},
|
|
1337
|
+
})
|
|
1338
|
+
)
|
|
1339
|
+
);
|
|
1340
|
+
|
|
1341
|
+
const result = await client.getBookmarks();
|
|
1342
|
+
expect(result.success).toBe(true);
|
|
1343
|
+
if (result.success) {
|
|
1344
|
+
expect(result.nextCursor).toBeUndefined();
|
|
1345
|
+
}
|
|
1346
|
+
});
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
describe("getHomeTimeline", () => {
|
|
1350
|
+
it("returns home timeline on success", async () => {
|
|
1351
|
+
const client = new XClient({ cookies: validCookies });
|
|
1352
|
+
globalThis.fetch = mock(() =>
|
|
1353
|
+
Promise.resolve(
|
|
1354
|
+
mockResponse({
|
|
1355
|
+
data: {
|
|
1356
|
+
home: {
|
|
1357
|
+
home_timeline_urt: {
|
|
1358
|
+
instructions: [
|
|
1359
|
+
{
|
|
1360
|
+
entries: [
|
|
1361
|
+
{
|
|
1362
|
+
content: {
|
|
1363
|
+
itemContent: {
|
|
1364
|
+
tweet_results: {
|
|
1365
|
+
result: {
|
|
1366
|
+
rest_id: "home-tweet-1",
|
|
1367
|
+
legacy: { full_text: "Home timeline tweet" },
|
|
1368
|
+
core: {
|
|
1369
|
+
user_results: {
|
|
1370
|
+
result: {
|
|
1371
|
+
rest_id: "user1",
|
|
1372
|
+
legacy: {
|
|
1373
|
+
screen_name: "user1",
|
|
1374
|
+
name: "User 1",
|
|
1375
|
+
},
|
|
1376
|
+
},
|
|
1377
|
+
},
|
|
1378
|
+
},
|
|
1379
|
+
},
|
|
1380
|
+
},
|
|
1381
|
+
},
|
|
1382
|
+
},
|
|
1383
|
+
},
|
|
1384
|
+
],
|
|
1385
|
+
},
|
|
1386
|
+
],
|
|
1387
|
+
},
|
|
1388
|
+
},
|
|
1389
|
+
},
|
|
1390
|
+
})
|
|
1391
|
+
)
|
|
1392
|
+
);
|
|
1393
|
+
|
|
1394
|
+
const result = await client.getHomeTimeline();
|
|
1395
|
+
expect(result.success).toBe(true);
|
|
1396
|
+
if (result.success) {
|
|
1397
|
+
expect(result.tweets?.length).toBeGreaterThan(0);
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
it("returns error on HTTP failure", async () => {
|
|
1402
|
+
const client = new XClient({ cookies: validCookies });
|
|
1403
|
+
globalThis.fetch = mock(() =>
|
|
1404
|
+
Promise.resolve(mockResponse("Error", { status: 500, ok: false }))
|
|
1405
|
+
);
|
|
1406
|
+
|
|
1407
|
+
const result = await client.getHomeTimeline();
|
|
1408
|
+
expect(result.success).toBe(false);
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
it("returns error on GraphQL errors", async () => {
|
|
1412
|
+
const client = new XClient({ cookies: validCookies });
|
|
1413
|
+
globalThis.fetch = mock(() =>
|
|
1414
|
+
Promise.resolve(
|
|
1415
|
+
mockResponse({
|
|
1416
|
+
errors: [{ message: "Timeline error" }],
|
|
1417
|
+
})
|
|
1418
|
+
)
|
|
1419
|
+
);
|
|
1420
|
+
|
|
1421
|
+
const result = await client.getHomeTimeline();
|
|
1422
|
+
expect(result.success).toBe(false);
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
it("handles fetch exception", async () => {
|
|
1426
|
+
const client = new XClient({ cookies: validCookies });
|
|
1427
|
+
globalThis.fetch = mock(() => Promise.reject(new Error("Network error")));
|
|
1428
|
+
|
|
1429
|
+
const result = await client.getHomeTimeline();
|
|
1430
|
+
expect(result.success).toBe(false);
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
it("uses custom count parameter", async () => {
|
|
1434
|
+
const client = new XClient({ cookies: validCookies });
|
|
1435
|
+
globalThis.fetch = mock(() =>
|
|
1436
|
+
Promise.resolve(
|
|
1437
|
+
mockResponse({
|
|
1438
|
+
data: {
|
|
1439
|
+
home: {
|
|
1440
|
+
home_timeline_urt: { instructions: [] },
|
|
1441
|
+
},
|
|
1442
|
+
},
|
|
1443
|
+
})
|
|
1444
|
+
)
|
|
1445
|
+
);
|
|
1446
|
+
|
|
1447
|
+
const result = await client.getHomeTimeline(50);
|
|
1448
|
+
expect(result.success).toBe(true);
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
it("returns nextCursor from response", async () => {
|
|
1452
|
+
const client = new XClient({ cookies: validCookies });
|
|
1453
|
+
globalThis.fetch = mock(() =>
|
|
1454
|
+
Promise.resolve(
|
|
1455
|
+
mockResponse({
|
|
1456
|
+
data: {
|
|
1457
|
+
home: {
|
|
1458
|
+
home_timeline_urt: {
|
|
1459
|
+
instructions: [
|
|
1460
|
+
{
|
|
1461
|
+
entries: [
|
|
1462
|
+
{
|
|
1463
|
+
entryId: "tweet-123",
|
|
1464
|
+
content: {
|
|
1465
|
+
itemContent: {
|
|
1466
|
+
tweet_results: {
|
|
1467
|
+
result: {
|
|
1468
|
+
rest_id: "123",
|
|
1469
|
+
legacy: { full_text: "Tweet" },
|
|
1470
|
+
core: {
|
|
1471
|
+
user_results: {
|
|
1472
|
+
result: {
|
|
1473
|
+
rest_id: "u1",
|
|
1474
|
+
legacy: {
|
|
1475
|
+
screen_name: "user",
|
|
1476
|
+
name: "User",
|
|
1477
|
+
},
|
|
1478
|
+
},
|
|
1479
|
+
},
|
|
1480
|
+
},
|
|
1481
|
+
},
|
|
1482
|
+
},
|
|
1483
|
+
},
|
|
1484
|
+
},
|
|
1485
|
+
},
|
|
1486
|
+
{
|
|
1487
|
+
entryId: "cursor-bottom-12345",
|
|
1488
|
+
content: {
|
|
1489
|
+
value: "DAABCgABG9oKYJ-NEXT-CURSOR",
|
|
1490
|
+
cursorType: "Bottom",
|
|
1491
|
+
},
|
|
1492
|
+
},
|
|
1493
|
+
],
|
|
1494
|
+
},
|
|
1495
|
+
],
|
|
1496
|
+
},
|
|
1497
|
+
},
|
|
1498
|
+
},
|
|
1499
|
+
})
|
|
1500
|
+
)
|
|
1501
|
+
);
|
|
1502
|
+
|
|
1503
|
+
const result = await client.getHomeTimeline();
|
|
1504
|
+
expect(result.success).toBe(true);
|
|
1505
|
+
if (result.success) {
|
|
1506
|
+
expect(result.nextCursor).toBe("DAABCgABG9oKYJ-NEXT-CURSOR");
|
|
1507
|
+
}
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
it("extracts cursor from TimelineReplaceEntry instruction", async () => {
|
|
1511
|
+
const client = new XClient({ cookies: validCookies });
|
|
1512
|
+
globalThis.fetch = mock(() =>
|
|
1513
|
+
Promise.resolve(
|
|
1514
|
+
mockResponse({
|
|
1515
|
+
data: {
|
|
1516
|
+
home: {
|
|
1517
|
+
home_timeline_urt: {
|
|
1518
|
+
instructions: [
|
|
1519
|
+
{
|
|
1520
|
+
type: "TimelineAddEntries",
|
|
1521
|
+
entries: [
|
|
1522
|
+
{
|
|
1523
|
+
entryId: "tweet-123",
|
|
1524
|
+
content: {
|
|
1525
|
+
itemContent: {
|
|
1526
|
+
tweet_results: {
|
|
1527
|
+
result: {
|
|
1528
|
+
rest_id: "123",
|
|
1529
|
+
legacy: { full_text: "Tweet" },
|
|
1530
|
+
core: {
|
|
1531
|
+
user_results: {
|
|
1532
|
+
result: {
|
|
1533
|
+
rest_id: "u1",
|
|
1534
|
+
legacy: {
|
|
1535
|
+
screen_name: "user",
|
|
1536
|
+
name: "User",
|
|
1537
|
+
},
|
|
1538
|
+
},
|
|
1539
|
+
},
|
|
1540
|
+
},
|
|
1541
|
+
},
|
|
1542
|
+
},
|
|
1543
|
+
},
|
|
1544
|
+
},
|
|
1545
|
+
},
|
|
1546
|
+
],
|
|
1547
|
+
},
|
|
1548
|
+
{
|
|
1549
|
+
type: "TimelineReplaceEntry",
|
|
1550
|
+
entry: {
|
|
1551
|
+
entryId: "cursor-bottom-refresh",
|
|
1552
|
+
content: {
|
|
1553
|
+
value: "REPLACE-ENTRY-CURSOR",
|
|
1554
|
+
cursorType: "Bottom",
|
|
1555
|
+
},
|
|
1556
|
+
},
|
|
1557
|
+
},
|
|
1558
|
+
],
|
|
1559
|
+
},
|
|
1560
|
+
},
|
|
1561
|
+
},
|
|
1562
|
+
})
|
|
1563
|
+
)
|
|
1564
|
+
);
|
|
1565
|
+
|
|
1566
|
+
const result = await client.getHomeTimeline();
|
|
1567
|
+
expect(result.success).toBe(true);
|
|
1568
|
+
if (result.success) {
|
|
1569
|
+
expect(result.nextCursor).toBe("REPLACE-ENTRY-CURSOR");
|
|
1570
|
+
}
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
it("accepts cursor parameter for pagination", async () => {
|
|
1574
|
+
const client = new XClient({ cookies: validCookies });
|
|
1575
|
+
let capturedUrl = "";
|
|
1576
|
+
globalThis.fetch = mock((url: string) => {
|
|
1577
|
+
capturedUrl = url;
|
|
1578
|
+
return Promise.resolve(
|
|
1579
|
+
mockResponse({
|
|
1580
|
+
data: {
|
|
1581
|
+
home: {
|
|
1582
|
+
home_timeline_urt: { instructions: [] },
|
|
1583
|
+
},
|
|
1584
|
+
},
|
|
1585
|
+
})
|
|
1586
|
+
);
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
await client.getHomeTimeline(20, "test-cursor-value");
|
|
1590
|
+
expect(capturedUrl).toContain("cursor");
|
|
1591
|
+
expect(capturedUrl).toContain("test-cursor-value");
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
it("retries on 404 and succeeds", async () => {
|
|
1595
|
+
const client = new XClient({ cookies: validCookies });
|
|
1596
|
+
let callCount = 0;
|
|
1597
|
+
|
|
1598
|
+
globalThis.fetch = mock(() => {
|
|
1599
|
+
callCount++;
|
|
1600
|
+
if (callCount <= 3) {
|
|
1601
|
+
return Promise.resolve(
|
|
1602
|
+
mockResponse("Not found", { status: 404, ok: false })
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
return Promise.resolve(
|
|
1606
|
+
mockResponse({
|
|
1607
|
+
data: {
|
|
1608
|
+
home: {
|
|
1609
|
+
home_timeline_urt: {
|
|
1610
|
+
instructions: [
|
|
1611
|
+
{
|
|
1612
|
+
entries: [
|
|
1613
|
+
{
|
|
1614
|
+
content: {
|
|
1615
|
+
itemContent: {
|
|
1616
|
+
tweet_results: {
|
|
1617
|
+
result: {
|
|
1618
|
+
rest_id: "retry-tweet",
|
|
1619
|
+
legacy: { full_text: "Success after retry" },
|
|
1620
|
+
core: {
|
|
1621
|
+
user_results: {
|
|
1622
|
+
result: {
|
|
1623
|
+
rest_id: "u1",
|
|
1624
|
+
legacy: {
|
|
1625
|
+
screen_name: "user",
|
|
1626
|
+
name: "User",
|
|
1627
|
+
},
|
|
1628
|
+
},
|
|
1629
|
+
},
|
|
1630
|
+
},
|
|
1631
|
+
},
|
|
1632
|
+
},
|
|
1633
|
+
},
|
|
1634
|
+
},
|
|
1635
|
+
},
|
|
1636
|
+
],
|
|
1637
|
+
},
|
|
1638
|
+
],
|
|
1639
|
+
},
|
|
1640
|
+
},
|
|
1641
|
+
},
|
|
1642
|
+
})
|
|
1643
|
+
);
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
const result = await client.getHomeTimeline();
|
|
1647
|
+
expect(result.success).toBe(true);
|
|
1648
|
+
});
|
|
1649
|
+
|
|
1650
|
+
it("returns error after all query IDs return 404", async () => {
|
|
1651
|
+
const client = new XClient({ cookies: validCookies });
|
|
1652
|
+
|
|
1653
|
+
globalThis.fetch = mock(() =>
|
|
1654
|
+
Promise.resolve(mockResponse("Not found", { status: 404, ok: false }))
|
|
1655
|
+
);
|
|
1656
|
+
|
|
1657
|
+
const result = await client.getHomeTimeline();
|
|
1658
|
+
expect(result.success).toBe(false);
|
|
1659
|
+
if (!result.success) {
|
|
1660
|
+
expect(result.error).toContain("404");
|
|
1661
|
+
}
|
|
1662
|
+
});
|
|
1663
|
+
});
|
|
1664
|
+
|
|
1665
|
+
describe("getHomeLatestTimeline", () => {
|
|
1666
|
+
it("returns latest timeline on success", async () => {
|
|
1667
|
+
const client = new XClient({ cookies: validCookies });
|
|
1668
|
+
globalThis.fetch = mock(() =>
|
|
1669
|
+
Promise.resolve(
|
|
1670
|
+
mockResponse({
|
|
1671
|
+
data: {
|
|
1672
|
+
home: {
|
|
1673
|
+
home_timeline_urt: {
|
|
1674
|
+
instructions: [
|
|
1675
|
+
{
|
|
1676
|
+
entries: [
|
|
1677
|
+
{
|
|
1678
|
+
content: {
|
|
1679
|
+
itemContent: {
|
|
1680
|
+
tweet_results: {
|
|
1681
|
+
result: {
|
|
1682
|
+
rest_id: "latest-tweet-1",
|
|
1683
|
+
legacy: {
|
|
1684
|
+
full_text: "Latest timeline tweet",
|
|
1685
|
+
},
|
|
1686
|
+
core: {
|
|
1687
|
+
user_results: {
|
|
1688
|
+
result: {
|
|
1689
|
+
rest_id: "user1",
|
|
1690
|
+
legacy: {
|
|
1691
|
+
screen_name: "user1",
|
|
1692
|
+
name: "User 1",
|
|
1693
|
+
},
|
|
1694
|
+
},
|
|
1695
|
+
},
|
|
1696
|
+
},
|
|
1697
|
+
},
|
|
1698
|
+
},
|
|
1699
|
+
},
|
|
1700
|
+
},
|
|
1701
|
+
},
|
|
1702
|
+
],
|
|
1703
|
+
},
|
|
1704
|
+
],
|
|
1705
|
+
},
|
|
1706
|
+
},
|
|
1707
|
+
},
|
|
1708
|
+
})
|
|
1709
|
+
)
|
|
1710
|
+
);
|
|
1711
|
+
|
|
1712
|
+
const result = await client.getHomeLatestTimeline();
|
|
1713
|
+
expect(result.success).toBe(true);
|
|
1714
|
+
if (result.success) {
|
|
1715
|
+
expect(result.tweets?.length).toBeGreaterThan(0);
|
|
1716
|
+
}
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
it("returns error on HTTP failure", async () => {
|
|
1720
|
+
const client = new XClient({ cookies: validCookies });
|
|
1721
|
+
globalThis.fetch = mock(() =>
|
|
1722
|
+
Promise.resolve(mockResponse("Error", { status: 500, ok: false }))
|
|
1723
|
+
);
|
|
1724
|
+
|
|
1725
|
+
const result = await client.getHomeLatestTimeline();
|
|
1726
|
+
expect(result.success).toBe(false);
|
|
1727
|
+
});
|
|
1728
|
+
|
|
1729
|
+
it("returns error on GraphQL errors", async () => {
|
|
1730
|
+
const client = new XClient({ cookies: validCookies });
|
|
1731
|
+
globalThis.fetch = mock(() =>
|
|
1732
|
+
Promise.resolve(
|
|
1733
|
+
mockResponse({
|
|
1734
|
+
errors: [{ message: "Latest timeline error" }],
|
|
1735
|
+
})
|
|
1736
|
+
)
|
|
1737
|
+
);
|
|
1738
|
+
|
|
1739
|
+
const result = await client.getHomeLatestTimeline();
|
|
1740
|
+
expect(result.success).toBe(false);
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
it("handles fetch exception", async () => {
|
|
1744
|
+
const client = new XClient({ cookies: validCookies });
|
|
1745
|
+
globalThis.fetch = mock(() =>
|
|
1746
|
+
Promise.reject(new Error("Connection error"))
|
|
1747
|
+
);
|
|
1748
|
+
|
|
1749
|
+
const result = await client.getHomeLatestTimeline();
|
|
1750
|
+
expect(result.success).toBe(false);
|
|
1751
|
+
});
|
|
1752
|
+
|
|
1753
|
+
it("uses custom count parameter", async () => {
|
|
1754
|
+
const client = new XClient({ cookies: validCookies });
|
|
1755
|
+
globalThis.fetch = mock(() =>
|
|
1756
|
+
Promise.resolve(
|
|
1757
|
+
mockResponse({
|
|
1758
|
+
data: {
|
|
1759
|
+
home: {
|
|
1760
|
+
home_timeline_urt: { instructions: [] },
|
|
1761
|
+
},
|
|
1762
|
+
},
|
|
1763
|
+
})
|
|
1764
|
+
)
|
|
1765
|
+
);
|
|
1766
|
+
|
|
1767
|
+
const result = await client.getHomeLatestTimeline(50);
|
|
1768
|
+
expect(result.success).toBe(true);
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
it("returns nextCursor from response", async () => {
|
|
1772
|
+
const client = new XClient({ cookies: validCookies });
|
|
1773
|
+
globalThis.fetch = mock(() =>
|
|
1774
|
+
Promise.resolve(
|
|
1775
|
+
mockResponse({
|
|
1776
|
+
data: {
|
|
1777
|
+
home: {
|
|
1778
|
+
home_timeline_urt: {
|
|
1779
|
+
instructions: [
|
|
1780
|
+
{
|
|
1781
|
+
entries: [
|
|
1782
|
+
{
|
|
1783
|
+
entryId: "tweet-456",
|
|
1784
|
+
content: {
|
|
1785
|
+
itemContent: {
|
|
1786
|
+
tweet_results: {
|
|
1787
|
+
result: {
|
|
1788
|
+
rest_id: "456",
|
|
1789
|
+
legacy: { full_text: "Latest tweet" },
|
|
1790
|
+
core: {
|
|
1791
|
+
user_results: {
|
|
1792
|
+
result: {
|
|
1793
|
+
rest_id: "u1",
|
|
1794
|
+
legacy: {
|
|
1795
|
+
screen_name: "user",
|
|
1796
|
+
name: "User",
|
|
1797
|
+
},
|
|
1798
|
+
},
|
|
1799
|
+
},
|
|
1800
|
+
},
|
|
1801
|
+
},
|
|
1802
|
+
},
|
|
1803
|
+
},
|
|
1804
|
+
},
|
|
1805
|
+
},
|
|
1806
|
+
{
|
|
1807
|
+
entryId: "cursor-bottom-67890",
|
|
1808
|
+
content: {
|
|
1809
|
+
value: "DAABCgABG9oKYJ-LATEST-CURSOR",
|
|
1810
|
+
cursorType: "Bottom",
|
|
1811
|
+
},
|
|
1812
|
+
},
|
|
1813
|
+
],
|
|
1814
|
+
},
|
|
1815
|
+
],
|
|
1816
|
+
},
|
|
1817
|
+
},
|
|
1818
|
+
},
|
|
1819
|
+
})
|
|
1820
|
+
)
|
|
1821
|
+
);
|
|
1822
|
+
|
|
1823
|
+
const result = await client.getHomeLatestTimeline();
|
|
1824
|
+
expect(result.success).toBe(true);
|
|
1825
|
+
if (result.success) {
|
|
1826
|
+
expect(result.nextCursor).toBe("DAABCgABG9oKYJ-LATEST-CURSOR");
|
|
1827
|
+
}
|
|
1828
|
+
});
|
|
1829
|
+
|
|
1830
|
+
it("accepts cursor parameter for pagination", async () => {
|
|
1831
|
+
const client = new XClient({ cookies: validCookies });
|
|
1832
|
+
let capturedUrl = "";
|
|
1833
|
+
globalThis.fetch = mock((url: string) => {
|
|
1834
|
+
capturedUrl = url;
|
|
1835
|
+
return Promise.resolve(
|
|
1836
|
+
mockResponse({
|
|
1837
|
+
data: {
|
|
1838
|
+
home: {
|
|
1839
|
+
home_timeline_urt: { instructions: [] },
|
|
1840
|
+
},
|
|
1841
|
+
},
|
|
1842
|
+
})
|
|
1843
|
+
);
|
|
1844
|
+
});
|
|
1845
|
+
|
|
1846
|
+
await client.getHomeLatestTimeline(20, "latest-cursor-value");
|
|
1847
|
+
expect(capturedUrl).toContain("cursor");
|
|
1848
|
+
expect(capturedUrl).toContain("latest-cursor-value");
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
it("retries on 404 and succeeds", async () => {
|
|
1852
|
+
const client = new XClient({ cookies: validCookies });
|
|
1853
|
+
let callCount = 0;
|
|
1854
|
+
|
|
1855
|
+
// HomeLatestTimeline has only 1 unique query ID in tests (fallback == primary)
|
|
1856
|
+
// First tryOnce fails with 404, second tryOnce succeeds
|
|
1857
|
+
globalThis.fetch = mock(() => {
|
|
1858
|
+
callCount++;
|
|
1859
|
+
if (callCount < 2) {
|
|
1860
|
+
return Promise.resolve(
|
|
1861
|
+
mockResponse("Not found", { status: 404, ok: false })
|
|
1862
|
+
);
|
|
1863
|
+
}
|
|
1864
|
+
return Promise.resolve(
|
|
1865
|
+
mockResponse({
|
|
1866
|
+
data: {
|
|
1867
|
+
home: {
|
|
1868
|
+
home_timeline_urt: {
|
|
1869
|
+
instructions: [
|
|
1870
|
+
{
|
|
1871
|
+
entries: [
|
|
1872
|
+
{
|
|
1873
|
+
content: {
|
|
1874
|
+
itemContent: {
|
|
1875
|
+
tweet_results: {
|
|
1876
|
+
result: {
|
|
1877
|
+
rest_id: "retry-latest",
|
|
1878
|
+
legacy: { full_text: "Latest after retry" },
|
|
1879
|
+
core: {
|
|
1880
|
+
user_results: {
|
|
1881
|
+
result: {
|
|
1882
|
+
rest_id: "u1",
|
|
1883
|
+
legacy: {
|
|
1884
|
+
screen_name: "user",
|
|
1885
|
+
name: "User",
|
|
1886
|
+
},
|
|
1887
|
+
},
|
|
1888
|
+
},
|
|
1889
|
+
},
|
|
1890
|
+
},
|
|
1891
|
+
},
|
|
1892
|
+
},
|
|
1893
|
+
},
|
|
1894
|
+
},
|
|
1895
|
+
],
|
|
1896
|
+
},
|
|
1897
|
+
],
|
|
1898
|
+
},
|
|
1899
|
+
},
|
|
1900
|
+
},
|
|
1901
|
+
})
|
|
1902
|
+
);
|
|
1903
|
+
});
|
|
1904
|
+
|
|
1905
|
+
const result = await client.getHomeLatestTimeline();
|
|
1906
|
+
expect(result.success).toBe(true);
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
it("returns error after all query IDs return 404", async () => {
|
|
1910
|
+
const client = new XClient({ cookies: validCookies });
|
|
1911
|
+
|
|
1912
|
+
globalThis.fetch = mock(() =>
|
|
1913
|
+
Promise.resolve(mockResponse("Not found", { status: 404, ok: false }))
|
|
1914
|
+
);
|
|
1915
|
+
|
|
1916
|
+
const result = await client.getHomeLatestTimeline();
|
|
1917
|
+
expect(result.success).toBe(false);
|
|
1918
|
+
if (!result.success) {
|
|
1919
|
+
expect(result.error).toContain("404");
|
|
1920
|
+
}
|
|
1921
|
+
});
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
describe("uploadMedia", () => {
|
|
1925
|
+
it("uploads image successfully", async () => {
|
|
1926
|
+
const client = new XClient({ cookies: validCookies });
|
|
1927
|
+
let callCount = 0;
|
|
1928
|
+
|
|
1929
|
+
globalThis.fetch = mock(() => {
|
|
1930
|
+
callCount++;
|
|
1931
|
+
if (callCount === 1) {
|
|
1932
|
+
// INIT
|
|
1933
|
+
return Promise.resolve(
|
|
1934
|
+
mockResponse({ media_id_string: "media-123" })
|
|
1935
|
+
);
|
|
1936
|
+
}
|
|
1937
|
+
if (callCount === 2) {
|
|
1938
|
+
// APPEND
|
|
1939
|
+
return Promise.resolve(mockResponse({}));
|
|
1940
|
+
}
|
|
1941
|
+
// FINALIZE
|
|
1942
|
+
return Promise.resolve(
|
|
1943
|
+
mockResponse({ processing_info: { state: "succeeded" } })
|
|
1944
|
+
);
|
|
1945
|
+
});
|
|
1946
|
+
|
|
1947
|
+
const result = await client.uploadMedia({
|
|
1948
|
+
data: new Uint8Array([1, 2, 3]),
|
|
1949
|
+
mimeType: "image/png",
|
|
1950
|
+
});
|
|
1951
|
+
expect(result.success).toBe(true);
|
|
1952
|
+
expect(result.mediaId).toBe("media-123");
|
|
1953
|
+
});
|
|
1954
|
+
|
|
1955
|
+
it("uploads GIF successfully", async () => {
|
|
1956
|
+
const client = new XClient({ cookies: validCookies });
|
|
1957
|
+
globalThis.fetch = mock(() =>
|
|
1958
|
+
Promise.resolve(mockResponse({ media_id_string: "gif-123" }))
|
|
1959
|
+
);
|
|
1960
|
+
|
|
1961
|
+
const result = await client.uploadMedia({
|
|
1962
|
+
data: new Uint8Array([1, 2, 3]),
|
|
1963
|
+
mimeType: "image/gif",
|
|
1964
|
+
});
|
|
1965
|
+
expect(result.success).toBe(true);
|
|
1966
|
+
});
|
|
1967
|
+
|
|
1968
|
+
it("uploads video successfully", async () => {
|
|
1969
|
+
const client = new XClient({ cookies: validCookies });
|
|
1970
|
+
let callCount = 0;
|
|
1971
|
+
|
|
1972
|
+
globalThis.fetch = mock(() => {
|
|
1973
|
+
callCount++;
|
|
1974
|
+
if (callCount === 1) {
|
|
1975
|
+
// INIT
|
|
1976
|
+
return Promise.resolve(
|
|
1977
|
+
mockResponse({ media_id_string: "video-123" })
|
|
1978
|
+
);
|
|
1979
|
+
}
|
|
1980
|
+
if (callCount === 2) {
|
|
1981
|
+
// APPEND
|
|
1982
|
+
return Promise.resolve(mockResponse({}));
|
|
1983
|
+
}
|
|
1984
|
+
// FINALIZE - return succeeded immediately
|
|
1985
|
+
return Promise.resolve(
|
|
1986
|
+
mockResponse({
|
|
1987
|
+
processing_info: { state: "succeeded" },
|
|
1988
|
+
})
|
|
1989
|
+
);
|
|
1990
|
+
});
|
|
1991
|
+
|
|
1992
|
+
const result = await client.uploadMedia({
|
|
1993
|
+
data: new Uint8Array([1, 2, 3]),
|
|
1994
|
+
mimeType: "video/mp4",
|
|
1995
|
+
});
|
|
1996
|
+
expect(result.success).toBe(true);
|
|
1997
|
+
expect(result.mediaId).toBe("video-123");
|
|
1998
|
+
});
|
|
1999
|
+
|
|
2000
|
+
it("polls for video processing when state is pending", async () => {
|
|
2001
|
+
const client = new XClient({ cookies: validCookies });
|
|
2002
|
+
let callCount = 0;
|
|
2003
|
+
|
|
2004
|
+
globalThis.fetch = mock(() => {
|
|
2005
|
+
callCount++;
|
|
2006
|
+
if (callCount === 1) {
|
|
2007
|
+
// INIT
|
|
2008
|
+
return Promise.resolve(
|
|
2009
|
+
mockResponse({ media_id_string: "video-poll-123" })
|
|
2010
|
+
);
|
|
2011
|
+
}
|
|
2012
|
+
if (callCount === 2) {
|
|
2013
|
+
// APPEND
|
|
2014
|
+
return Promise.resolve(mockResponse({}));
|
|
2015
|
+
}
|
|
2016
|
+
if (callCount === 3) {
|
|
2017
|
+
// FINALIZE - return pending to trigger polling
|
|
2018
|
+
return Promise.resolve(
|
|
2019
|
+
mockResponse({
|
|
2020
|
+
processing_info: { state: "pending", check_after_secs: 0.001 },
|
|
2021
|
+
})
|
|
2022
|
+
);
|
|
2023
|
+
}
|
|
2024
|
+
// STATUS check - return succeeded
|
|
2025
|
+
return Promise.resolve(
|
|
2026
|
+
mockResponse({
|
|
2027
|
+
processing_info: { state: "succeeded" },
|
|
2028
|
+
})
|
|
2029
|
+
);
|
|
2030
|
+
});
|
|
2031
|
+
|
|
2032
|
+
const result = await client.uploadMedia({
|
|
2033
|
+
data: new Uint8Array([1, 2, 3]),
|
|
2034
|
+
mimeType: "video/mp4",
|
|
2035
|
+
});
|
|
2036
|
+
expect(result.success).toBe(true);
|
|
2037
|
+
expect(result.mediaId).toBe("video-poll-123");
|
|
2038
|
+
// Verify polling happened (INIT + APPEND + FINALIZE + STATUS = 4 calls)
|
|
2039
|
+
expect(callCount).toBe(4);
|
|
2040
|
+
});
|
|
2041
|
+
|
|
2042
|
+
it("returns error for unsupported media type", async () => {
|
|
2043
|
+
const client = new XClient({ cookies: validCookies });
|
|
2044
|
+
|
|
2045
|
+
const result = await client.uploadMedia({
|
|
2046
|
+
data: new Uint8Array([1, 2, 3]),
|
|
2047
|
+
mimeType: "application/pdf",
|
|
2048
|
+
});
|
|
2049
|
+
expect(result.success).toBe(false);
|
|
2050
|
+
expect(result.error).toContain("Unsupported media type");
|
|
2051
|
+
});
|
|
2052
|
+
|
|
2053
|
+
it("returns error on INIT failure", async () => {
|
|
2054
|
+
const client = new XClient({ cookies: validCookies });
|
|
2055
|
+
globalThis.fetch = mock(() =>
|
|
2056
|
+
Promise.resolve(mockResponse("Error", { status: 400, ok: false }))
|
|
2057
|
+
);
|
|
2058
|
+
|
|
2059
|
+
const result = await client.uploadMedia({
|
|
2060
|
+
data: new Uint8Array([1, 2, 3]),
|
|
2061
|
+
mimeType: "image/png",
|
|
2062
|
+
});
|
|
2063
|
+
expect(result.success).toBe(false);
|
|
2064
|
+
expect(result.error).toContain("HTTP 400");
|
|
2065
|
+
});
|
|
2066
|
+
|
|
2067
|
+
it("returns error if INIT returns no media_id", async () => {
|
|
2068
|
+
const client = new XClient({ cookies: validCookies });
|
|
2069
|
+
globalThis.fetch = mock(() => Promise.resolve(mockResponse({})));
|
|
2070
|
+
|
|
2071
|
+
const result = await client.uploadMedia({
|
|
2072
|
+
data: new Uint8Array([1, 2, 3]),
|
|
2073
|
+
mimeType: "image/png",
|
|
2074
|
+
});
|
|
2075
|
+
expect(result.success).toBe(false);
|
|
2076
|
+
expect(result.error).toContain("did not return media_id");
|
|
2077
|
+
});
|
|
2078
|
+
|
|
2079
|
+
it("returns error on APPEND failure", async () => {
|
|
2080
|
+
const client = new XClient({ cookies: validCookies });
|
|
2081
|
+
let callCount = 0;
|
|
2082
|
+
|
|
2083
|
+
globalThis.fetch = mock(() => {
|
|
2084
|
+
callCount++;
|
|
2085
|
+
if (callCount === 1) {
|
|
2086
|
+
return Promise.resolve(
|
|
2087
|
+
mockResponse({ media_id_string: "media-123" })
|
|
2088
|
+
);
|
|
2089
|
+
}
|
|
2090
|
+
return Promise.resolve(
|
|
2091
|
+
mockResponse("Error", { status: 400, ok: false })
|
|
2092
|
+
);
|
|
2093
|
+
});
|
|
2094
|
+
|
|
2095
|
+
const result = await client.uploadMedia({
|
|
2096
|
+
data: new Uint8Array([1, 2, 3]),
|
|
2097
|
+
mimeType: "image/png",
|
|
2098
|
+
});
|
|
2099
|
+
expect(result.success).toBe(false);
|
|
2100
|
+
});
|
|
2101
|
+
|
|
2102
|
+
it("returns error on FINALIZE failure", async () => {
|
|
2103
|
+
const client = new XClient({ cookies: validCookies });
|
|
2104
|
+
let callCount = 0;
|
|
2105
|
+
|
|
2106
|
+
globalThis.fetch = mock(() => {
|
|
2107
|
+
callCount++;
|
|
2108
|
+
if (callCount === 1) {
|
|
2109
|
+
return Promise.resolve(
|
|
2110
|
+
mockResponse({ media_id_string: "media-123" })
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2113
|
+
if (callCount === 2) {
|
|
2114
|
+
return Promise.resolve(mockResponse({}));
|
|
2115
|
+
}
|
|
2116
|
+
return Promise.resolve(
|
|
2117
|
+
mockResponse("Error", { status: 400, ok: false })
|
|
2118
|
+
);
|
|
2119
|
+
});
|
|
2120
|
+
|
|
2121
|
+
const result = await client.uploadMedia({
|
|
2122
|
+
data: new Uint8Array([1, 2, 3]),
|
|
2123
|
+
mimeType: "image/png",
|
|
2124
|
+
});
|
|
2125
|
+
expect(result.success).toBe(false);
|
|
2126
|
+
});
|
|
2127
|
+
|
|
2128
|
+
it("handles processing failure state", async () => {
|
|
2129
|
+
const client = new XClient({ cookies: validCookies });
|
|
2130
|
+
let callCount = 0;
|
|
2131
|
+
|
|
2132
|
+
globalThis.fetch = mock(() => {
|
|
2133
|
+
callCount++;
|
|
2134
|
+
if (callCount === 1) {
|
|
2135
|
+
return Promise.resolve(
|
|
2136
|
+
mockResponse({ media_id_string: "media-123" })
|
|
2137
|
+
);
|
|
2138
|
+
}
|
|
2139
|
+
if (callCount === 2) {
|
|
2140
|
+
return Promise.resolve(mockResponse({}));
|
|
2141
|
+
}
|
|
2142
|
+
return Promise.resolve(
|
|
2143
|
+
mockResponse({
|
|
2144
|
+
processing_info: {
|
|
2145
|
+
state: "failed",
|
|
2146
|
+
error: { message: "Processing failed" },
|
|
2147
|
+
},
|
|
2148
|
+
})
|
|
2149
|
+
);
|
|
2150
|
+
});
|
|
2151
|
+
|
|
2152
|
+
const result = await client.uploadMedia({
|
|
2153
|
+
data: new Uint8Array([1, 2, 3]),
|
|
2154
|
+
mimeType: "image/png",
|
|
2155
|
+
});
|
|
2156
|
+
expect(result.success).toBe(false);
|
|
2157
|
+
expect(result.error).toContain("Processing failed");
|
|
2158
|
+
});
|
|
2159
|
+
|
|
2160
|
+
it("adds alt text for images", async () => {
|
|
2161
|
+
const client = new XClient({ cookies: validCookies });
|
|
2162
|
+
let callCount = 0;
|
|
2163
|
+
|
|
2164
|
+
globalThis.fetch = mock(() => {
|
|
2165
|
+
callCount++;
|
|
2166
|
+
if (callCount <= 3) {
|
|
2167
|
+
return Promise.resolve(
|
|
2168
|
+
mockResponse({ media_id_string: "media-123" })
|
|
2169
|
+
);
|
|
2170
|
+
}
|
|
2171
|
+
// Alt text request
|
|
2172
|
+
return Promise.resolve(mockResponse({}));
|
|
2173
|
+
});
|
|
2174
|
+
|
|
2175
|
+
const result = await client.uploadMedia({
|
|
2176
|
+
data: new Uint8Array([1, 2, 3]),
|
|
2177
|
+
mimeType: "image/png",
|
|
2178
|
+
alt: "Image description",
|
|
2179
|
+
});
|
|
2180
|
+
expect(result.success).toBe(true);
|
|
2181
|
+
});
|
|
2182
|
+
|
|
2183
|
+
it("returns error on alt text failure", async () => {
|
|
2184
|
+
const client = new XClient({ cookies: validCookies });
|
|
2185
|
+
let callCount = 0;
|
|
2186
|
+
|
|
2187
|
+
globalThis.fetch = mock(() => {
|
|
2188
|
+
callCount++;
|
|
2189
|
+
if (callCount <= 3) {
|
|
2190
|
+
return Promise.resolve(
|
|
2191
|
+
mockResponse({ media_id_string: "media-123" })
|
|
2192
|
+
);
|
|
2193
|
+
}
|
|
2194
|
+
return Promise.resolve(
|
|
2195
|
+
mockResponse("Error", { status: 400, ok: false })
|
|
2196
|
+
);
|
|
2197
|
+
});
|
|
2198
|
+
|
|
2199
|
+
const result = await client.uploadMedia({
|
|
2200
|
+
data: new Uint8Array([1, 2, 3]),
|
|
2201
|
+
mimeType: "image/png",
|
|
2202
|
+
alt: "Image description",
|
|
2203
|
+
});
|
|
2204
|
+
expect(result.success).toBe(false);
|
|
2205
|
+
});
|
|
2206
|
+
|
|
2207
|
+
it("handles fetch exception", async () => {
|
|
2208
|
+
const client = new XClient({ cookies: validCookies });
|
|
2209
|
+
globalThis.fetch = mock(() => Promise.reject(new Error("Upload failed")));
|
|
2210
|
+
|
|
2211
|
+
const result = await client.uploadMedia({
|
|
2212
|
+
data: new Uint8Array([1, 2, 3]),
|
|
2213
|
+
mimeType: "image/png",
|
|
2214
|
+
});
|
|
2215
|
+
expect(result.success).toBe(false);
|
|
2216
|
+
expect(result.error).toBe("Upload failed");
|
|
2217
|
+
});
|
|
2218
|
+
|
|
2219
|
+
it("handles chunked upload for large files", async () => {
|
|
2220
|
+
const client = new XClient({ cookies: validCookies });
|
|
2221
|
+
const largeData = new Uint8Array(10 * 1024 * 1024); // 10MB
|
|
2222
|
+
let appendCount = 0;
|
|
2223
|
+
|
|
2224
|
+
globalThis.fetch = mock((url: string, init?: RequestInit) => {
|
|
2225
|
+
const body = init?.body;
|
|
2226
|
+
if (body instanceof URLSearchParams) {
|
|
2227
|
+
const command = body.get("command");
|
|
2228
|
+
if (command === "INIT") {
|
|
2229
|
+
return Promise.resolve(
|
|
2230
|
+
mockResponse({ media_id_string: "large-media-123" })
|
|
2231
|
+
);
|
|
2232
|
+
}
|
|
2233
|
+
if (command === "FINALIZE") {
|
|
2234
|
+
return Promise.resolve(mockResponse({}));
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
if (body instanceof FormData) {
|
|
2238
|
+
appendCount++;
|
|
2239
|
+
return Promise.resolve(mockResponse({}));
|
|
2240
|
+
}
|
|
2241
|
+
return Promise.resolve(mockResponse({}));
|
|
2242
|
+
});
|
|
2243
|
+
|
|
2244
|
+
const result = await client.uploadMedia({
|
|
2245
|
+
data: largeData,
|
|
2246
|
+
mimeType: "video/mp4",
|
|
2247
|
+
});
|
|
2248
|
+
expect(result.success).toBe(true);
|
|
2249
|
+
expect(appendCount).toBe(2); // 10MB / 5MB = 2 chunks
|
|
2250
|
+
});
|
|
2251
|
+
|
|
2252
|
+
it("uses media_id when media_id_string is missing", async () => {
|
|
2253
|
+
const client = new XClient({ cookies: validCookies });
|
|
2254
|
+
let callCount = 0;
|
|
2255
|
+
|
|
2256
|
+
globalThis.fetch = mock(() => {
|
|
2257
|
+
callCount++;
|
|
2258
|
+
if (callCount === 1) {
|
|
2259
|
+
return Promise.resolve(mockResponse({ media_id: 12345 }));
|
|
2260
|
+
}
|
|
2261
|
+
return Promise.resolve(mockResponse({}));
|
|
2262
|
+
});
|
|
2263
|
+
|
|
2264
|
+
const result = await client.uploadMedia({
|
|
2265
|
+
data: new Uint8Array([1, 2, 3]),
|
|
2266
|
+
mimeType: "image/png",
|
|
2267
|
+
});
|
|
2268
|
+
expect(result.success).toBe(true);
|
|
2269
|
+
expect(result.mediaId).toBe("12345");
|
|
2270
|
+
});
|
|
2271
|
+
});
|
|
2272
|
+
|
|
2273
|
+
describe("timeout handling", () => {
|
|
2274
|
+
it("respects timeout setting", async () => {
|
|
2275
|
+
const client = new XClient({
|
|
2276
|
+
cookies: validCookies,
|
|
2277
|
+
timeoutMs: 100,
|
|
2278
|
+
});
|
|
2279
|
+
|
|
2280
|
+
globalThis.fetch = mock(
|
|
2281
|
+
() =>
|
|
2282
|
+
new Promise((resolve) => {
|
|
2283
|
+
setTimeout(() => resolve(mockResponse({ data: {} })), 200);
|
|
2284
|
+
})
|
|
2285
|
+
);
|
|
2286
|
+
|
|
2287
|
+
const result = await client.getTweet("123");
|
|
2288
|
+
// Should abort before completion
|
|
2289
|
+
expect(result.success).toBe(false);
|
|
2290
|
+
});
|
|
2291
|
+
|
|
2292
|
+
it("does not use timeout when set to 0", async () => {
|
|
2293
|
+
const client = new XClient({
|
|
2294
|
+
cookies: validCookies,
|
|
2295
|
+
timeoutMs: 0,
|
|
2296
|
+
});
|
|
2297
|
+
|
|
2298
|
+
globalThis.fetch = mock(() =>
|
|
2299
|
+
Promise.resolve(mockResponse({ data: {} }))
|
|
2300
|
+
);
|
|
2301
|
+
|
|
2302
|
+
const result = await client.getTweet("123");
|
|
2303
|
+
expect(result.success).toBe(false); // No tweet in response
|
|
2304
|
+
});
|
|
2305
|
+
});
|
|
2306
|
+
|
|
2307
|
+
describe("tweet result parsing edge cases", () => {
|
|
2308
|
+
it("handles tweet with core.screen_name instead of legacy.screen_name", async () => {
|
|
2309
|
+
const client = new XClient({ cookies: validCookies });
|
|
2310
|
+
globalThis.fetch = mock(() =>
|
|
2311
|
+
Promise.resolve(
|
|
2312
|
+
mockResponse({
|
|
2313
|
+
data: {
|
|
2314
|
+
tweetResult: {
|
|
2315
|
+
result: {
|
|
2316
|
+
rest_id: "123456",
|
|
2317
|
+
legacy: { full_text: "Tweet text" },
|
|
2318
|
+
core: {
|
|
2319
|
+
user_results: {
|
|
2320
|
+
result: {
|
|
2321
|
+
rest_id: "user123",
|
|
2322
|
+
core: {
|
|
2323
|
+
screen_name: "coreuser",
|
|
2324
|
+
name: "Core User",
|
|
2325
|
+
},
|
|
2326
|
+
},
|
|
2327
|
+
},
|
|
2328
|
+
},
|
|
2329
|
+
},
|
|
2330
|
+
},
|
|
2331
|
+
},
|
|
2332
|
+
})
|
|
2333
|
+
)
|
|
2334
|
+
);
|
|
2335
|
+
|
|
2336
|
+
const result = await client.getTweet("123456");
|
|
2337
|
+
expect(result.success).toBe(true);
|
|
2338
|
+
if (result.success) {
|
|
2339
|
+
expect(result.tweet?.author.username).toBe("coreuser");
|
|
2340
|
+
}
|
|
2341
|
+
});
|
|
2342
|
+
|
|
2343
|
+
it("handles tweet with missing text fields gracefully", async () => {
|
|
2344
|
+
const client = new XClient({ cookies: validCookies });
|
|
2345
|
+
globalThis.fetch = mock(() =>
|
|
2346
|
+
Promise.resolve(
|
|
2347
|
+
mockResponse({
|
|
2348
|
+
data: {
|
|
2349
|
+
tweetResult: {
|
|
2350
|
+
result: {
|
|
2351
|
+
rest_id: "123456",
|
|
2352
|
+
core: {
|
|
2353
|
+
user_results: {
|
|
2354
|
+
result: {
|
|
2355
|
+
rest_id: "user123",
|
|
2356
|
+
legacy: { screen_name: "testuser" },
|
|
2357
|
+
},
|
|
2358
|
+
},
|
|
2359
|
+
},
|
|
2360
|
+
},
|
|
2361
|
+
},
|
|
2362
|
+
},
|
|
2363
|
+
})
|
|
2364
|
+
)
|
|
2365
|
+
);
|
|
2366
|
+
|
|
2367
|
+
const result = await client.getTweet("123456");
|
|
2368
|
+
// Should fail because no text was found
|
|
2369
|
+
expect(result.success).toBe(false);
|
|
2370
|
+
});
|
|
2371
|
+
|
|
2372
|
+
it("handles items array in instructions via getThread", async () => {
|
|
2373
|
+
const client = new XClient({ cookies: validCookies });
|
|
2374
|
+
globalThis.fetch = mock(() =>
|
|
2375
|
+
Promise.resolve(
|
|
2376
|
+
mockResponse({
|
|
2377
|
+
data: {
|
|
2378
|
+
threaded_conversation_with_injections_v2: {
|
|
2379
|
+
instructions: [
|
|
2380
|
+
{
|
|
2381
|
+
entries: [
|
|
2382
|
+
{
|
|
2383
|
+
content: {
|
|
2384
|
+
items: [
|
|
2385
|
+
{
|
|
2386
|
+
item: {
|
|
2387
|
+
itemContent: {
|
|
2388
|
+
tweet_results: {
|
|
2389
|
+
result: {
|
|
2390
|
+
rest_id: "nested-tweet-1",
|
|
2391
|
+
legacy: {
|
|
2392
|
+
full_text: "Nested tweet",
|
|
2393
|
+
conversation_id_str: "conv-123",
|
|
2394
|
+
},
|
|
2395
|
+
core: {
|
|
2396
|
+
user_results: {
|
|
2397
|
+
result: {
|
|
2398
|
+
rest_id: "user1",
|
|
2399
|
+
legacy: {
|
|
2400
|
+
screen_name: "user1",
|
|
2401
|
+
name: "User",
|
|
2402
|
+
},
|
|
2403
|
+
},
|
|
2404
|
+
},
|
|
2405
|
+
},
|
|
2406
|
+
},
|
|
2407
|
+
},
|
|
2408
|
+
},
|
|
2409
|
+
},
|
|
2410
|
+
},
|
|
2411
|
+
],
|
|
2412
|
+
},
|
|
2413
|
+
},
|
|
2414
|
+
],
|
|
2415
|
+
},
|
|
2416
|
+
],
|
|
2417
|
+
},
|
|
2418
|
+
},
|
|
2419
|
+
})
|
|
2420
|
+
)
|
|
2421
|
+
);
|
|
2422
|
+
|
|
2423
|
+
// getThread uses parseTweetsFromInstructions which checks items array
|
|
2424
|
+
const result = await client.getThread("nested-tweet-1");
|
|
2425
|
+
expect(result.success).toBe(true);
|
|
2426
|
+
if (result.success) {
|
|
2427
|
+
expect(result.tweets?.length).toBe(1);
|
|
2428
|
+
expect(result.tweets?.[0]?.id).toBe("nested-tweet-1");
|
|
2429
|
+
}
|
|
2430
|
+
});
|
|
2431
|
+
|
|
2432
|
+
it("deduplicates tweets by ID", async () => {
|
|
2433
|
+
const client = new XClient({ cookies: validCookies });
|
|
2434
|
+
globalThis.fetch = mock(() =>
|
|
2435
|
+
Promise.resolve(
|
|
2436
|
+
mockResponse({
|
|
2437
|
+
data: {
|
|
2438
|
+
search_by_raw_query: {
|
|
2439
|
+
search_timeline: {
|
|
2440
|
+
timeline: {
|
|
2441
|
+
instructions: [
|
|
2442
|
+
{
|
|
2443
|
+
entries: [
|
|
2444
|
+
{
|
|
2445
|
+
content: {
|
|
2446
|
+
itemContent: {
|
|
2447
|
+
tweet_results: {
|
|
2448
|
+
result: {
|
|
2449
|
+
rest_id: "same-id",
|
|
2450
|
+
legacy: { full_text: "First occurrence" },
|
|
2451
|
+
core: {
|
|
2452
|
+
user_results: {
|
|
2453
|
+
result: {
|
|
2454
|
+
rest_id: "user1",
|
|
2455
|
+
legacy: {
|
|
2456
|
+
screen_name: "user1",
|
|
2457
|
+
name: "User",
|
|
2458
|
+
},
|
|
2459
|
+
},
|
|
2460
|
+
},
|
|
2461
|
+
},
|
|
2462
|
+
},
|
|
2463
|
+
},
|
|
2464
|
+
},
|
|
2465
|
+
},
|
|
2466
|
+
},
|
|
2467
|
+
{
|
|
2468
|
+
content: {
|
|
2469
|
+
itemContent: {
|
|
2470
|
+
tweet_results: {
|
|
2471
|
+
result: {
|
|
2472
|
+
rest_id: "same-id",
|
|
2473
|
+
legacy: { full_text: "Duplicate" },
|
|
2474
|
+
core: {
|
|
2475
|
+
user_results: {
|
|
2476
|
+
result: {
|
|
2477
|
+
rest_id: "user1",
|
|
2478
|
+
legacy: {
|
|
2479
|
+
screen_name: "user1",
|
|
2480
|
+
name: "User",
|
|
2481
|
+
},
|
|
2482
|
+
},
|
|
2483
|
+
},
|
|
2484
|
+
},
|
|
2485
|
+
},
|
|
2486
|
+
},
|
|
2487
|
+
},
|
|
2488
|
+
},
|
|
2489
|
+
},
|
|
2490
|
+
],
|
|
2491
|
+
},
|
|
2492
|
+
],
|
|
2493
|
+
},
|
|
2494
|
+
},
|
|
2495
|
+
},
|
|
2496
|
+
},
|
|
2497
|
+
})
|
|
2498
|
+
)
|
|
2499
|
+
);
|
|
2500
|
+
|
|
2501
|
+
const result = await client.search("test");
|
|
2502
|
+
expect(result.success).toBe(true);
|
|
2503
|
+
if (result.success) {
|
|
2504
|
+
expect(result.tweets?.length).toBe(1);
|
|
2505
|
+
}
|
|
2506
|
+
});
|
|
2507
|
+
|
|
2508
|
+
it("extracts favorited and bookmarked state from legacy", async () => {
|
|
2509
|
+
const client = new XClient({ cookies: validCookies });
|
|
2510
|
+
globalThis.fetch = mock(() =>
|
|
2511
|
+
Promise.resolve(
|
|
2512
|
+
mockResponse({
|
|
2513
|
+
data: {
|
|
2514
|
+
tweetResult: {
|
|
2515
|
+
result: {
|
|
2516
|
+
rest_id: "123456",
|
|
2517
|
+
legacy: {
|
|
2518
|
+
full_text: "Liked and bookmarked tweet",
|
|
2519
|
+
favorited: true,
|
|
2520
|
+
bookmarked: true,
|
|
2521
|
+
},
|
|
2522
|
+
core: {
|
|
2523
|
+
user_results: {
|
|
2524
|
+
result: {
|
|
2525
|
+
rest_id: "user123",
|
|
2526
|
+
legacy: { screen_name: "testuser", name: "Test User" },
|
|
2527
|
+
},
|
|
2528
|
+
},
|
|
2529
|
+
},
|
|
2530
|
+
},
|
|
2531
|
+
},
|
|
2532
|
+
},
|
|
2533
|
+
})
|
|
2534
|
+
)
|
|
2535
|
+
);
|
|
2536
|
+
|
|
2537
|
+
const result = await client.getTweet("123456");
|
|
2538
|
+
expect(result.success).toBe(true);
|
|
2539
|
+
if (result.success) {
|
|
2540
|
+
expect(result.tweet?.favorited).toBe(true);
|
|
2541
|
+
expect(result.tweet?.bookmarked).toBe(true);
|
|
2542
|
+
}
|
|
2543
|
+
});
|
|
2544
|
+
|
|
2545
|
+
it("defaults favorited and bookmarked to false when missing", async () => {
|
|
2546
|
+
const client = new XClient({ cookies: validCookies });
|
|
2547
|
+
globalThis.fetch = mock(() =>
|
|
2548
|
+
Promise.resolve(
|
|
2549
|
+
mockResponse({
|
|
2550
|
+
data: {
|
|
2551
|
+
tweetResult: {
|
|
2552
|
+
result: {
|
|
2553
|
+
rest_id: "123456",
|
|
2554
|
+
legacy: {
|
|
2555
|
+
full_text: "Tweet without interaction state",
|
|
2556
|
+
},
|
|
2557
|
+
core: {
|
|
2558
|
+
user_results: {
|
|
2559
|
+
result: {
|
|
2560
|
+
rest_id: "user123",
|
|
2561
|
+
legacy: { screen_name: "testuser", name: "Test User" },
|
|
2562
|
+
},
|
|
2563
|
+
},
|
|
2564
|
+
},
|
|
2565
|
+
},
|
|
2566
|
+
},
|
|
2567
|
+
},
|
|
2568
|
+
})
|
|
2569
|
+
)
|
|
2570
|
+
);
|
|
2571
|
+
|
|
2572
|
+
const result = await client.getTweet("123456");
|
|
2573
|
+
expect(result.success).toBe(true);
|
|
2574
|
+
if (result.success) {
|
|
2575
|
+
expect(result.tweet?.favorited).toBe(false);
|
|
2576
|
+
expect(result.tweet?.bookmarked).toBe(false);
|
|
2577
|
+
}
|
|
2578
|
+
});
|
|
2579
|
+
|
|
2580
|
+
it("extracts partial interaction state (only favorited)", async () => {
|
|
2581
|
+
const client = new XClient({ cookies: validCookies });
|
|
2582
|
+
globalThis.fetch = mock(() =>
|
|
2583
|
+
Promise.resolve(
|
|
2584
|
+
mockResponse({
|
|
2585
|
+
data: {
|
|
2586
|
+
tweetResult: {
|
|
2587
|
+
result: {
|
|
2588
|
+
rest_id: "123456",
|
|
2589
|
+
legacy: {
|
|
2590
|
+
full_text: "Only liked tweet",
|
|
2591
|
+
favorited: true,
|
|
2592
|
+
},
|
|
2593
|
+
core: {
|
|
2594
|
+
user_results: {
|
|
2595
|
+
result: {
|
|
2596
|
+
rest_id: "user123",
|
|
2597
|
+
legacy: { screen_name: "testuser", name: "Test User" },
|
|
2598
|
+
},
|
|
2599
|
+
},
|
|
2600
|
+
},
|
|
2601
|
+
},
|
|
2602
|
+
},
|
|
2603
|
+
},
|
|
2604
|
+
})
|
|
2605
|
+
)
|
|
2606
|
+
);
|
|
2607
|
+
|
|
2608
|
+
const result = await client.getTweet("123456");
|
|
2609
|
+
expect(result.success).toBe(true);
|
|
2610
|
+
if (result.success) {
|
|
2611
|
+
expect(result.tweet?.favorited).toBe(true);
|
|
2612
|
+
expect(result.tweet?.bookmarked).toBe(false);
|
|
2613
|
+
}
|
|
2614
|
+
});
|
|
2615
|
+
});
|
|
2616
|
+
|
|
2617
|
+
describe("article text extraction edge cases", () => {
|
|
2618
|
+
it("extracts text from richtext field", async () => {
|
|
2619
|
+
const client = new XClient({ cookies: validCookies });
|
|
2620
|
+
globalThis.fetch = mock(() =>
|
|
2621
|
+
Promise.resolve(
|
|
2622
|
+
mockResponse({
|
|
2623
|
+
data: {
|
|
2624
|
+
tweetResult: {
|
|
2625
|
+
result: {
|
|
2626
|
+
rest_id: "123456",
|
|
2627
|
+
note_tweet: {
|
|
2628
|
+
note_tweet_results: {
|
|
2629
|
+
result: {
|
|
2630
|
+
richtext: { text: "Richtext content" },
|
|
2631
|
+
},
|
|
2632
|
+
},
|
|
2633
|
+
},
|
|
2634
|
+
core: {
|
|
2635
|
+
user_results: {
|
|
2636
|
+
result: {
|
|
2637
|
+
rest_id: "user123",
|
|
2638
|
+
legacy: { screen_name: "testuser", name: "Test" },
|
|
2639
|
+
},
|
|
2640
|
+
},
|
|
2641
|
+
},
|
|
2642
|
+
},
|
|
2643
|
+
},
|
|
2644
|
+
},
|
|
2645
|
+
})
|
|
2646
|
+
)
|
|
2647
|
+
);
|
|
2648
|
+
|
|
2649
|
+
const result = await client.getTweet("123456");
|
|
2650
|
+
expect(result.success).toBe(true);
|
|
2651
|
+
if (result.success) {
|
|
2652
|
+
expect(result.tweet?.text).toBe("Richtext content");
|
|
2653
|
+
}
|
|
2654
|
+
});
|
|
2655
|
+
|
|
2656
|
+
it("extracts text from content.richtext field", async () => {
|
|
2657
|
+
const client = new XClient({ cookies: validCookies });
|
|
2658
|
+
globalThis.fetch = mock(() =>
|
|
2659
|
+
Promise.resolve(
|
|
2660
|
+
mockResponse({
|
|
2661
|
+
data: {
|
|
2662
|
+
tweetResult: {
|
|
2663
|
+
result: {
|
|
2664
|
+
rest_id: "123456",
|
|
2665
|
+
note_tweet: {
|
|
2666
|
+
note_tweet_results: {
|
|
2667
|
+
result: {
|
|
2668
|
+
content: {
|
|
2669
|
+
richtext: { text: "Content richtext" },
|
|
2670
|
+
},
|
|
2671
|
+
},
|
|
2672
|
+
},
|
|
2673
|
+
},
|
|
2674
|
+
core: {
|
|
2675
|
+
user_results: {
|
|
2676
|
+
result: {
|
|
2677
|
+
rest_id: "user123",
|
|
2678
|
+
legacy: { screen_name: "testuser", name: "Test" },
|
|
2679
|
+
},
|
|
2680
|
+
},
|
|
2681
|
+
},
|
|
2682
|
+
},
|
|
2683
|
+
},
|
|
2684
|
+
},
|
|
2685
|
+
})
|
|
2686
|
+
)
|
|
2687
|
+
);
|
|
2688
|
+
|
|
2689
|
+
const result = await client.getTweet("123456");
|
|
2690
|
+
expect(result.success).toBe(true);
|
|
2691
|
+
if (result.success) {
|
|
2692
|
+
expect(result.tweet?.text).toBe("Content richtext");
|
|
2693
|
+
}
|
|
2694
|
+
});
|
|
2695
|
+
|
|
2696
|
+
it("collects text fields from nested article structure", async () => {
|
|
2697
|
+
const client = new XClient({ cookies: validCookies });
|
|
2698
|
+
globalThis.fetch = mock(() =>
|
|
2699
|
+
Promise.resolve(
|
|
2700
|
+
mockResponse({
|
|
2701
|
+
data: {
|
|
2702
|
+
tweetResult: {
|
|
2703
|
+
result: {
|
|
2704
|
+
rest_id: "123456",
|
|
2705
|
+
article: {
|
|
2706
|
+
article_results: {
|
|
2707
|
+
result: {
|
|
2708
|
+
title: "Same Title",
|
|
2709
|
+
sections: [
|
|
2710
|
+
{
|
|
2711
|
+
items: [{ text: "Section item text" }],
|
|
2712
|
+
},
|
|
2713
|
+
],
|
|
2714
|
+
},
|
|
2715
|
+
},
|
|
2716
|
+
title: "Same Title",
|
|
2717
|
+
},
|
|
2718
|
+
core: {
|
|
2719
|
+
user_results: {
|
|
2720
|
+
result: {
|
|
2721
|
+
rest_id: "user123",
|
|
2722
|
+
legacy: { screen_name: "testuser", name: "Test" },
|
|
2723
|
+
},
|
|
2724
|
+
},
|
|
2725
|
+
},
|
|
2726
|
+
},
|
|
2727
|
+
},
|
|
2728
|
+
},
|
|
2729
|
+
})
|
|
2730
|
+
)
|
|
2731
|
+
);
|
|
2732
|
+
|
|
2733
|
+
const result = await client.getTweet("123456");
|
|
2734
|
+
expect(result.success).toBe(true);
|
|
2735
|
+
});
|
|
2736
|
+
});
|
|
2737
|
+
|
|
2738
|
+
describe("status update fallback", () => {
|
|
2739
|
+
it("handles fallback with reply", async () => {
|
|
2740
|
+
const client = new XClient({ cookies: validCookies });
|
|
2741
|
+
let callCount = 0;
|
|
2742
|
+
|
|
2743
|
+
globalThis.fetch = mock(() => {
|
|
2744
|
+
callCount++;
|
|
2745
|
+
if (callCount === 1) {
|
|
2746
|
+
return Promise.resolve(
|
|
2747
|
+
mockResponse({
|
|
2748
|
+
errors: [{ message: "Automation", code: 226 }],
|
|
2749
|
+
})
|
|
2750
|
+
);
|
|
2751
|
+
}
|
|
2752
|
+
return Promise.resolve(mockResponse({ id_str: "fallback-reply-123" }));
|
|
2753
|
+
});
|
|
2754
|
+
|
|
2755
|
+
const result = await client.reply("Reply text", "original-123");
|
|
2756
|
+
expect(result.success).toBe(true);
|
|
2757
|
+
});
|
|
2758
|
+
|
|
2759
|
+
it("returns combined error when fallback also fails", async () => {
|
|
2760
|
+
const client = new XClient({ cookies: validCookies });
|
|
2761
|
+
let callCount = 0;
|
|
2762
|
+
|
|
2763
|
+
globalThis.fetch = mock(() => {
|
|
2764
|
+
callCount++;
|
|
2765
|
+
if (callCount === 1) {
|
|
2766
|
+
return Promise.resolve(
|
|
2767
|
+
mockResponse({
|
|
2768
|
+
errors: [{ message: "Automation", code: 226 }],
|
|
2769
|
+
})
|
|
2770
|
+
);
|
|
2771
|
+
}
|
|
2772
|
+
return Promise.resolve(
|
|
2773
|
+
mockResponse({
|
|
2774
|
+
errors: [{ message: "Rate limited" }],
|
|
2775
|
+
})
|
|
2776
|
+
);
|
|
2777
|
+
});
|
|
2778
|
+
|
|
2779
|
+
const result = await client.tweet("Hello");
|
|
2780
|
+
expect(result.success).toBe(false);
|
|
2781
|
+
if (!result.success) {
|
|
2782
|
+
expect(result.error).toContain("Automation");
|
|
2783
|
+
expect(result.error).toContain("fallback");
|
|
2784
|
+
}
|
|
2785
|
+
});
|
|
2786
|
+
|
|
2787
|
+
it("handles fallback HTTP error", async () => {
|
|
2788
|
+
const client = new XClient({ cookies: validCookies });
|
|
2789
|
+
let callCount = 0;
|
|
2790
|
+
|
|
2791
|
+
globalThis.fetch = mock(() => {
|
|
2792
|
+
callCount++;
|
|
2793
|
+
if (callCount === 1) {
|
|
2794
|
+
return Promise.resolve(
|
|
2795
|
+
mockResponse({
|
|
2796
|
+
errors: [{ message: "Automation", code: 226 }],
|
|
2797
|
+
})
|
|
2798
|
+
);
|
|
2799
|
+
}
|
|
2800
|
+
return Promise.resolve(
|
|
2801
|
+
mockResponse("Error", { status: 403, ok: false })
|
|
2802
|
+
);
|
|
2803
|
+
});
|
|
2804
|
+
|
|
2805
|
+
const result = await client.tweet("Hello");
|
|
2806
|
+
expect(result.success).toBe(false);
|
|
2807
|
+
});
|
|
2808
|
+
|
|
2809
|
+
it("handles fallback with media IDs", async () => {
|
|
2810
|
+
const client = new XClient({ cookies: validCookies });
|
|
2811
|
+
let callCount = 0;
|
|
2812
|
+
|
|
2813
|
+
globalThis.fetch = mock(() => {
|
|
2814
|
+
callCount++;
|
|
2815
|
+
if (callCount === 1) {
|
|
2816
|
+
return Promise.resolve(
|
|
2817
|
+
mockResponse({
|
|
2818
|
+
errors: [{ message: "Automation", code: 226 }],
|
|
2819
|
+
})
|
|
2820
|
+
);
|
|
2821
|
+
}
|
|
2822
|
+
return Promise.resolve(mockResponse({ id: 123456789 }));
|
|
2823
|
+
});
|
|
2824
|
+
|
|
2825
|
+
const result = await client.tweet("With media", ["media1", "media2"]);
|
|
2826
|
+
expect(result.success).toBe(true);
|
|
2827
|
+
if (result.success) {
|
|
2828
|
+
expect(result.tweetId).toBe("123456789");
|
|
2829
|
+
}
|
|
2830
|
+
});
|
|
2831
|
+
|
|
2832
|
+
it("returns null from status update parsing if no text", async () => {
|
|
2833
|
+
const client = new XClient({ cookies: validCookies });
|
|
2834
|
+
globalThis.fetch = mock(() =>
|
|
2835
|
+
Promise.resolve(
|
|
2836
|
+
mockResponse({
|
|
2837
|
+
errors: [{ message: "Automation", code: 226 }],
|
|
2838
|
+
})
|
|
2839
|
+
)
|
|
2840
|
+
);
|
|
2841
|
+
|
|
2842
|
+
// Internally the fallback won't work if tweet_text is not a string
|
|
2843
|
+
// but we can't easily trigger this path from public API
|
|
2844
|
+
const result = await client.tweet("Valid text");
|
|
2845
|
+
expect(result.success).toBe(false);
|
|
2846
|
+
});
|
|
2847
|
+
});
|
|
2848
|
+
|
|
2849
|
+
describe("URL extraction edge cases", () => {
|
|
2850
|
+
it("handles tweets with undefined expanded_url in entities", async () => {
|
|
2851
|
+
const client = new XClient({ cookies: validCookies });
|
|
2852
|
+
globalThis.fetch = mock(() =>
|
|
2853
|
+
Promise.resolve(
|
|
2854
|
+
mockResponse({
|
|
2855
|
+
data: {
|
|
2856
|
+
tweetResult: {
|
|
2857
|
+
result: {
|
|
2858
|
+
rest_id: "tweet-123",
|
|
2859
|
+
legacy: {
|
|
2860
|
+
full_text: "Tweet with broken URL https://t.co/abc",
|
|
2861
|
+
entities: {
|
|
2862
|
+
urls: [
|
|
2863
|
+
{
|
|
2864
|
+
url: "https://t.co/abc",
|
|
2865
|
+
expanded_url: undefined,
|
|
2866
|
+
display_url: "example.com",
|
|
2867
|
+
indices: [25, 48],
|
|
2868
|
+
},
|
|
2869
|
+
],
|
|
2870
|
+
},
|
|
2871
|
+
},
|
|
2872
|
+
core: {
|
|
2873
|
+
user_results: {
|
|
2874
|
+
result: {
|
|
2875
|
+
rest_id: "user1",
|
|
2876
|
+
legacy: { screen_name: "user1", name: "User 1" },
|
|
2877
|
+
},
|
|
2878
|
+
},
|
|
2879
|
+
},
|
|
2880
|
+
},
|
|
2881
|
+
},
|
|
2882
|
+
},
|
|
2883
|
+
})
|
|
2884
|
+
)
|
|
2885
|
+
);
|
|
2886
|
+
|
|
2887
|
+
const result = await client.getTweet("tweet-123");
|
|
2888
|
+
expect(result.success).toBe(true);
|
|
2889
|
+
// Should not crash and should handle gracefully
|
|
2890
|
+
});
|
|
2891
|
+
|
|
2892
|
+
it("filters out urls with undefined expanded_url in timeline", async () => {
|
|
2893
|
+
const client = new XClient({ cookies: validCookies });
|
|
2894
|
+
globalThis.fetch = mock(() =>
|
|
2895
|
+
Promise.resolve(
|
|
2896
|
+
mockResponse({
|
|
2897
|
+
data: {
|
|
2898
|
+
home: {
|
|
2899
|
+
home_timeline_urt: {
|
|
2900
|
+
instructions: [
|
|
2901
|
+
{
|
|
2902
|
+
entries: [
|
|
2903
|
+
{
|
|
2904
|
+
content: {
|
|
2905
|
+
itemContent: {
|
|
2906
|
+
tweet_results: {
|
|
2907
|
+
result: {
|
|
2908
|
+
rest_id: "tweet-456",
|
|
2909
|
+
legacy: {
|
|
2910
|
+
full_text: "Tweet with mixed URLs",
|
|
2911
|
+
entities: {
|
|
2912
|
+
urls: [
|
|
2913
|
+
{
|
|
2914
|
+
url: "https://t.co/good",
|
|
2915
|
+
expanded_url: "https://example.com",
|
|
2916
|
+
display_url: "example.com",
|
|
2917
|
+
indices: [0, 23],
|
|
2918
|
+
},
|
|
2919
|
+
{
|
|
2920
|
+
url: "https://t.co/bad",
|
|
2921
|
+
expanded_url: undefined,
|
|
2922
|
+
display_url: "broken.com",
|
|
2923
|
+
indices: [24, 47],
|
|
2924
|
+
},
|
|
2925
|
+
],
|
|
2926
|
+
},
|
|
2927
|
+
},
|
|
2928
|
+
core: {
|
|
2929
|
+
user_results: {
|
|
2930
|
+
result: {
|
|
2931
|
+
rest_id: "u1",
|
|
2932
|
+
legacy: {
|
|
2933
|
+
screen_name: "user",
|
|
2934
|
+
name: "User",
|
|
2935
|
+
},
|
|
2936
|
+
},
|
|
2937
|
+
},
|
|
2938
|
+
},
|
|
2939
|
+
},
|
|
2940
|
+
},
|
|
2941
|
+
},
|
|
2942
|
+
},
|
|
2943
|
+
},
|
|
2944
|
+
],
|
|
2945
|
+
},
|
|
2946
|
+
],
|
|
2947
|
+
},
|
|
2948
|
+
},
|
|
2949
|
+
},
|
|
2950
|
+
})
|
|
2951
|
+
)
|
|
2952
|
+
);
|
|
2953
|
+
|
|
2954
|
+
const result = await client.getHomeTimeline();
|
|
2955
|
+
expect(result.success).toBe(true);
|
|
2956
|
+
if (result.success && result.tweets.length > 0) {
|
|
2957
|
+
const tweet = result.tweets[0];
|
|
2958
|
+
// Should only have the valid URL, not the undefined one
|
|
2959
|
+
expect(tweet?.urls?.length).toBe(1);
|
|
2960
|
+
expect(tweet?.urls?.[0]?.expandedUrl).toBe("https://example.com");
|
|
2961
|
+
}
|
|
2962
|
+
});
|
|
2963
|
+
|
|
2964
|
+
it("handles media urls with undefined expanded_url", async () => {
|
|
2965
|
+
const client = new XClient({ cookies: validCookies });
|
|
2966
|
+
globalThis.fetch = mock(() =>
|
|
2967
|
+
Promise.resolve(
|
|
2968
|
+
mockResponse({
|
|
2969
|
+
data: {
|
|
2970
|
+
home: {
|
|
2971
|
+
home_timeline_urt: {
|
|
2972
|
+
instructions: [
|
|
2973
|
+
{
|
|
2974
|
+
entries: [
|
|
2975
|
+
{
|
|
2976
|
+
content: {
|
|
2977
|
+
itemContent: {
|
|
2978
|
+
tweet_results: {
|
|
2979
|
+
result: {
|
|
2980
|
+
rest_id: "tweet-789",
|
|
2981
|
+
legacy: {
|
|
2982
|
+
full_text:
|
|
2983
|
+
"Tweet with media URL https://t.co/media",
|
|
2984
|
+
entities: {
|
|
2985
|
+
urls: [
|
|
2986
|
+
{
|
|
2987
|
+
url: "https://t.co/media",
|
|
2988
|
+
expanded_url: undefined,
|
|
2989
|
+
display_url: "pic.twitter.com/abc",
|
|
2990
|
+
indices: [22, 45],
|
|
2991
|
+
},
|
|
2992
|
+
],
|
|
2993
|
+
},
|
|
2994
|
+
},
|
|
2995
|
+
core: {
|
|
2996
|
+
user_results: {
|
|
2997
|
+
result: {
|
|
2998
|
+
rest_id: "u2",
|
|
2999
|
+
legacy: {
|
|
3000
|
+
screen_name: "user2",
|
|
3001
|
+
name: "User 2",
|
|
3002
|
+
},
|
|
3003
|
+
},
|
|
3004
|
+
},
|
|
3005
|
+
},
|
|
3006
|
+
},
|
|
3007
|
+
},
|
|
3008
|
+
},
|
|
3009
|
+
},
|
|
3010
|
+
},
|
|
3011
|
+
],
|
|
3012
|
+
},
|
|
3013
|
+
],
|
|
3014
|
+
},
|
|
3015
|
+
},
|
|
3016
|
+
},
|
|
3017
|
+
})
|
|
3018
|
+
)
|
|
3019
|
+
);
|
|
3020
|
+
|
|
3021
|
+
const result = await client.getHomeTimeline();
|
|
3022
|
+
expect(result.success).toBe(true);
|
|
3023
|
+
// Should not crash even when isMediaUrl is called with undefined
|
|
3024
|
+
});
|
|
3025
|
+
});
|
|
3026
|
+
|
|
3027
|
+
describe("getNotifications", () => {
|
|
3028
|
+
it("returns notifications on success", async () => {
|
|
3029
|
+
const client = new XClient({ cookies: validCookies });
|
|
3030
|
+
globalThis.fetch = mock(() =>
|
|
3031
|
+
Promise.resolve(
|
|
3032
|
+
mockResponse({
|
|
3033
|
+
data: {
|
|
3034
|
+
viewer_v2: {
|
|
3035
|
+
user_results: {
|
|
3036
|
+
result: {
|
|
3037
|
+
notification_timeline: {
|
|
3038
|
+
timeline: {
|
|
3039
|
+
instructions: [
|
|
3040
|
+
{
|
|
3041
|
+
type: "TimelineAddEntries",
|
|
3042
|
+
entries: [
|
|
3043
|
+
{
|
|
3044
|
+
entryId: "notification-1",
|
|
3045
|
+
sortIndex: "1700000000000",
|
|
3046
|
+
content: {
|
|
3047
|
+
itemContent: {
|
|
3048
|
+
itemType: "TimelineNotification",
|
|
3049
|
+
id: "notif-1",
|
|
3050
|
+
notification_icon: "heart_icon",
|
|
3051
|
+
rich_message: {
|
|
3052
|
+
text: "User liked your post",
|
|
3053
|
+
},
|
|
3054
|
+
notification_url: {
|
|
3055
|
+
url: "https://x.com/i/notification",
|
|
3056
|
+
},
|
|
3057
|
+
timestamp_ms: "2024-01-01T00:00:00Z",
|
|
3058
|
+
},
|
|
3059
|
+
},
|
|
3060
|
+
},
|
|
3061
|
+
],
|
|
3062
|
+
},
|
|
3063
|
+
],
|
|
3064
|
+
},
|
|
3065
|
+
},
|
|
3066
|
+
},
|
|
3067
|
+
},
|
|
3068
|
+
},
|
|
3069
|
+
},
|
|
3070
|
+
})
|
|
3071
|
+
)
|
|
3072
|
+
);
|
|
3073
|
+
|
|
3074
|
+
const result = await client.getNotifications();
|
|
3075
|
+
expect(result.success).toBe(true);
|
|
3076
|
+
if (result.success) {
|
|
3077
|
+
expect(result.notifications.length).toBe(1);
|
|
3078
|
+
expect(result.notifications[0]?.icon).toBe("heart_icon");
|
|
3079
|
+
expect(result.notifications[0]?.message).toBe("User liked your post");
|
|
3080
|
+
}
|
|
3081
|
+
});
|
|
3082
|
+
|
|
3083
|
+
it("returns error on HTTP failure", async () => {
|
|
3084
|
+
const client = new XClient({ cookies: validCookies });
|
|
3085
|
+
globalThis.fetch = mock(() =>
|
|
3086
|
+
Promise.resolve(mockResponse("Error", { status: 500, ok: false }))
|
|
3087
|
+
);
|
|
3088
|
+
|
|
3089
|
+
const result = await client.getNotifications();
|
|
3090
|
+
expect(result.success).toBe(false);
|
|
3091
|
+
if (!result.success) {
|
|
3092
|
+
expect(result.error.message.length).toBeGreaterThan(0);
|
|
3093
|
+
}
|
|
3094
|
+
});
|
|
3095
|
+
|
|
3096
|
+
it("returns error on GraphQL errors", async () => {
|
|
3097
|
+
const client = new XClient({ cookies: validCookies });
|
|
3098
|
+
globalThis.fetch = mock(() =>
|
|
3099
|
+
Promise.resolve(
|
|
3100
|
+
mockResponse({
|
|
3101
|
+
errors: [{ message: "Notifications error" }],
|
|
3102
|
+
})
|
|
3103
|
+
)
|
|
3104
|
+
);
|
|
3105
|
+
|
|
3106
|
+
const result = await client.getNotifications();
|
|
3107
|
+
expect(result.success).toBe(false);
|
|
3108
|
+
if (!result.success) {
|
|
3109
|
+
expect(result.error.message).toContain("Notifications error");
|
|
3110
|
+
}
|
|
3111
|
+
});
|
|
3112
|
+
|
|
3113
|
+
it("retries on 404", async () => {
|
|
3114
|
+
const client = new XClient({ cookies: validCookies });
|
|
3115
|
+
globalThis.fetch = mock(() =>
|
|
3116
|
+
Promise.resolve(mockResponse("Not found", { status: 404, ok: false }))
|
|
3117
|
+
);
|
|
3118
|
+
|
|
3119
|
+
const result = await client.getNotifications();
|
|
3120
|
+
expect(result.success).toBe(false);
|
|
3121
|
+
if (!result.success) {
|
|
3122
|
+
expect(result.error.message).toContain("404");
|
|
3123
|
+
}
|
|
3124
|
+
});
|
|
3125
|
+
|
|
3126
|
+
it("uses custom count parameter", async () => {
|
|
3127
|
+
const client = new XClient({ cookies: validCookies });
|
|
3128
|
+
let capturedUrl = "";
|
|
3129
|
+
globalThis.fetch = mock((url: string) => {
|
|
3130
|
+
capturedUrl = url;
|
|
3131
|
+
return Promise.resolve(
|
|
3132
|
+
mockResponse({
|
|
3133
|
+
data: {
|
|
3134
|
+
viewer_v2: {
|
|
3135
|
+
user_results: {
|
|
3136
|
+
result: {
|
|
3137
|
+
notification_timeline: {
|
|
3138
|
+
timeline: { instructions: [] },
|
|
3139
|
+
},
|
|
3140
|
+
},
|
|
3141
|
+
},
|
|
3142
|
+
},
|
|
3143
|
+
},
|
|
3144
|
+
})
|
|
3145
|
+
);
|
|
3146
|
+
});
|
|
3147
|
+
|
|
3148
|
+
await client.getNotifications(50);
|
|
3149
|
+
const decodedUrl = decodeURIComponent(capturedUrl);
|
|
3150
|
+
expect(decodedUrl).toContain('"count":50');
|
|
3151
|
+
});
|
|
3152
|
+
|
|
3153
|
+
it("handles fetch exception", async () => {
|
|
3154
|
+
const client = new XClient({ cookies: validCookies });
|
|
3155
|
+
globalThis.fetch = mock(() =>
|
|
3156
|
+
Promise.reject(new Error("Network timeout"))
|
|
3157
|
+
);
|
|
3158
|
+
|
|
3159
|
+
const result = await client.getNotifications();
|
|
3160
|
+
expect(result.success).toBe(false);
|
|
3161
|
+
if (!result.success) {
|
|
3162
|
+
expect(result.error.message).toBe("Network timeout");
|
|
3163
|
+
}
|
|
3164
|
+
});
|
|
3165
|
+
|
|
3166
|
+
it("extracts unread sort index from instructions", async () => {
|
|
3167
|
+
const client = new XClient({ cookies: validCookies });
|
|
3168
|
+
globalThis.fetch = mock(() =>
|
|
3169
|
+
Promise.resolve(
|
|
3170
|
+
mockResponse({
|
|
3171
|
+
data: {
|
|
3172
|
+
viewer_v2: {
|
|
3173
|
+
user_results: {
|
|
3174
|
+
result: {
|
|
3175
|
+
notification_timeline: {
|
|
3176
|
+
timeline: {
|
|
3177
|
+
instructions: [
|
|
3178
|
+
{
|
|
3179
|
+
type: "TimelineAddEntries",
|
|
3180
|
+
entries: [
|
|
3181
|
+
{
|
|
3182
|
+
entryId: "notification-1",
|
|
3183
|
+
sortIndex: "1700000000000",
|
|
3184
|
+
content: {
|
|
3185
|
+
itemContent: {
|
|
3186
|
+
itemType: "TimelineNotification",
|
|
3187
|
+
id: "notif-1",
|
|
3188
|
+
notification_icon: "heart_icon",
|
|
3189
|
+
rich_message: { text: "Liked" },
|
|
3190
|
+
notification_url: { url: "" },
|
|
3191
|
+
timestamp_ms: "",
|
|
3192
|
+
},
|
|
3193
|
+
},
|
|
3194
|
+
},
|
|
3195
|
+
],
|
|
3196
|
+
},
|
|
3197
|
+
{
|
|
3198
|
+
type: "TimelineMarkEntriesUnreadGreaterThanSortIndex",
|
|
3199
|
+
sort_index: "1699999999999",
|
|
3200
|
+
},
|
|
3201
|
+
],
|
|
3202
|
+
},
|
|
3203
|
+
},
|
|
3204
|
+
},
|
|
3205
|
+
},
|
|
3206
|
+
},
|
|
3207
|
+
},
|
|
3208
|
+
})
|
|
3209
|
+
)
|
|
3210
|
+
);
|
|
3211
|
+
|
|
3212
|
+
const result = await client.getNotifications();
|
|
3213
|
+
expect(result.success).toBe(true);
|
|
3214
|
+
if (result.success) {
|
|
3215
|
+
expect(result.unreadSortIndex).toBe("1699999999999");
|
|
3216
|
+
}
|
|
3217
|
+
});
|
|
3218
|
+
|
|
3219
|
+
it("extracts cursors from entries", async () => {
|
|
3220
|
+
const client = new XClient({ cookies: validCookies });
|
|
3221
|
+
globalThis.fetch = mock(() =>
|
|
3222
|
+
Promise.resolve(
|
|
3223
|
+
mockResponse({
|
|
3224
|
+
data: {
|
|
3225
|
+
viewer_v2: {
|
|
3226
|
+
user_results: {
|
|
3227
|
+
result: {
|
|
3228
|
+
notification_timeline: {
|
|
3229
|
+
timeline: {
|
|
3230
|
+
instructions: [
|
|
3231
|
+
{
|
|
3232
|
+
type: "TimelineAddEntries",
|
|
3233
|
+
entries: [
|
|
3234
|
+
{
|
|
3235
|
+
content: {
|
|
3236
|
+
cursorType: "Top",
|
|
3237
|
+
value: "TOP-CURSOR-VALUE",
|
|
3238
|
+
},
|
|
3239
|
+
},
|
|
3240
|
+
{
|
|
3241
|
+
content: {
|
|
3242
|
+
cursorType: "Bottom",
|
|
3243
|
+
value: "BOTTOM-CURSOR-VALUE",
|
|
3244
|
+
},
|
|
3245
|
+
},
|
|
3246
|
+
],
|
|
3247
|
+
},
|
|
3248
|
+
],
|
|
3249
|
+
},
|
|
3250
|
+
},
|
|
3251
|
+
},
|
|
3252
|
+
},
|
|
3253
|
+
},
|
|
3254
|
+
},
|
|
3255
|
+
})
|
|
3256
|
+
)
|
|
3257
|
+
);
|
|
3258
|
+
|
|
3259
|
+
const result = await client.getNotifications();
|
|
3260
|
+
expect(result.success).toBe(true);
|
|
3261
|
+
if (result.success) {
|
|
3262
|
+
expect(result.topCursor).toBe("TOP-CURSOR-VALUE");
|
|
3263
|
+
expect(result.bottomCursor).toBe("BOTTOM-CURSOR-VALUE");
|
|
3264
|
+
}
|
|
3265
|
+
});
|
|
3266
|
+
|
|
3267
|
+
it("handles empty instructions", async () => {
|
|
3268
|
+
const client = new XClient({ cookies: validCookies });
|
|
3269
|
+
globalThis.fetch = mock(() =>
|
|
3270
|
+
Promise.resolve(
|
|
3271
|
+
mockResponse({
|
|
3272
|
+
data: {
|
|
3273
|
+
viewer_v2: {
|
|
3274
|
+
user_results: {
|
|
3275
|
+
result: {
|
|
3276
|
+
notification_timeline: {
|
|
3277
|
+
timeline: {
|
|
3278
|
+
instructions: [],
|
|
3279
|
+
},
|
|
3280
|
+
},
|
|
3281
|
+
},
|
|
3282
|
+
},
|
|
3283
|
+
},
|
|
3284
|
+
},
|
|
3285
|
+
})
|
|
3286
|
+
)
|
|
3287
|
+
);
|
|
3288
|
+
|
|
3289
|
+
const result = await client.getNotifications();
|
|
3290
|
+
expect(result.success).toBe(true);
|
|
3291
|
+
if (result.success) {
|
|
3292
|
+
expect(result.notifications.length).toBe(0);
|
|
3293
|
+
}
|
|
3294
|
+
});
|
|
3295
|
+
|
|
3296
|
+
it("handles undefined instructions", async () => {
|
|
3297
|
+
const client = new XClient({ cookies: validCookies });
|
|
3298
|
+
globalThis.fetch = mock(() =>
|
|
3299
|
+
Promise.resolve(
|
|
3300
|
+
mockResponse({
|
|
3301
|
+
data: {
|
|
3302
|
+
viewer_v2: {
|
|
3303
|
+
user_results: {
|
|
3304
|
+
result: {
|
|
3305
|
+
notification_timeline: {
|
|
3306
|
+
timeline: {},
|
|
3307
|
+
},
|
|
3308
|
+
},
|
|
3309
|
+
},
|
|
3310
|
+
},
|
|
3311
|
+
},
|
|
3312
|
+
})
|
|
3313
|
+
)
|
|
3314
|
+
);
|
|
3315
|
+
|
|
3316
|
+
const result = await client.getNotifications();
|
|
3317
|
+
expect(result.success).toBe(true);
|
|
3318
|
+
if (result.success) {
|
|
3319
|
+
expect(result.notifications.length).toBe(0);
|
|
3320
|
+
}
|
|
3321
|
+
});
|
|
3322
|
+
|
|
3323
|
+
it("uses fallback icon for unknown notification type", async () => {
|
|
3324
|
+
const client = new XClient({ cookies: validCookies });
|
|
3325
|
+
globalThis.fetch = mock(() =>
|
|
3326
|
+
Promise.resolve(
|
|
3327
|
+
mockResponse({
|
|
3328
|
+
data: {
|
|
3329
|
+
viewer_v2: {
|
|
3330
|
+
user_results: {
|
|
3331
|
+
result: {
|
|
3332
|
+
notification_timeline: {
|
|
3333
|
+
timeline: {
|
|
3334
|
+
instructions: [
|
|
3335
|
+
{
|
|
3336
|
+
type: "TimelineAddEntries",
|
|
3337
|
+
entries: [
|
|
3338
|
+
{
|
|
3339
|
+
entryId: "notif-1",
|
|
3340
|
+
content: {
|
|
3341
|
+
itemContent: {
|
|
3342
|
+
itemType: "TimelineNotification",
|
|
3343
|
+
id: "1",
|
|
3344
|
+
rich_message: { text: "Unknown type" },
|
|
3345
|
+
notification_url: { url: "" },
|
|
3346
|
+
timestamp_ms: "",
|
|
3347
|
+
},
|
|
3348
|
+
},
|
|
3349
|
+
},
|
|
3350
|
+
],
|
|
3351
|
+
},
|
|
3352
|
+
],
|
|
3353
|
+
},
|
|
3354
|
+
},
|
|
3355
|
+
},
|
|
3356
|
+
},
|
|
3357
|
+
},
|
|
3358
|
+
},
|
|
3359
|
+
})
|
|
3360
|
+
)
|
|
3361
|
+
);
|
|
3362
|
+
|
|
3363
|
+
const result = await client.getNotifications();
|
|
3364
|
+
expect(result.success).toBe(true);
|
|
3365
|
+
if (result.success) {
|
|
3366
|
+
expect(result.notifications[0]?.icon).toBe("bird_icon");
|
|
3367
|
+
}
|
|
3368
|
+
});
|
|
3369
|
+
});
|
|
3370
|
+
});
|