@opencoredev/social-sdk 0.1.2 → 0.2.1
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/README.md +6 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +234 -0
- package/dist/cloud/common.d.ts +25 -0
- package/dist/cloud/common.js +334 -0
- package/dist/cloud/lifecycle.d.ts +10 -0
- package/dist/cloud/lifecycle.js +112 -0
- package/dist/cloud/media.d.ts +23 -0
- package/dist/cloud/media.js +100 -0
- package/dist/cloud/outcomes.d.ts +9 -0
- package/dist/cloud/outcomes.js +195 -0
- package/dist/cloud/post-for-me.d.ts +69 -0
- package/dist/cloud/post-for-me.js +396 -0
- package/dist/cloud/zernio.d.ts +111 -0
- package/dist/cloud/zernio.js +632 -0
- package/dist/core/adapter.d.ts +158 -0
- package/dist/core/adapter.js +3 -0
- package/dist/core/client.d.ts +141 -0
- package/dist/core/client.js +1285 -0
- package/dist/core/concurrency.d.ts +9 -0
- package/dist/core/concurrency.js +79 -0
- package/dist/core/errors.d.ts +44 -0
- package/dist/core/errors.js +50 -0
- package/dist/core/idempotency.d.ts +33 -0
- package/dist/core/idempotency.js +58 -0
- package/dist/core/index.d.ts +6 -0
- package/dist/core/index.js +6 -0
- package/dist/core/pagination.d.ts +11 -0
- package/dist/core/pagination.js +81 -0
- package/dist/core/types.d.ts +396 -0
- package/dist/core/types.js +16 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/platforms/bluesky.d.ts +261 -0
- package/dist/platforms/bluesky.js +1776 -0
- package/dist/platforms/instagram.d.ts +129 -0
- package/dist/platforms/instagram.js +1031 -0
- package/dist/platforms/linkedin.d.ts +108 -0
- package/dist/platforms/linkedin.js +890 -0
- package/dist/platforms/threads.d.ts +134 -0
- package/dist/platforms/threads.js +945 -0
- package/dist/platforms/tiktok.d.ts +31 -0
- package/dist/platforms/tiktok.js +593 -0
- package/dist/platforms/x-engagement.d.ts +16 -0
- package/dist/platforms/x-engagement.js +50 -0
- package/dist/platforms/x-text.d.ts +2 -0
- package/dist/platforms/x-text.js +123 -0
- package/dist/platforms/x-tlds.d.ts +1 -0
- package/dist/platforms/x-tlds.js +2 -0
- package/dist/platforms/x.d.ts +299 -0
- package/dist/platforms/x.js +1287 -0
- package/dist/platforms/youtube-upload.d.ts +32 -0
- package/dist/platforms/youtube-upload.js +296 -0
- package/dist/platforms/youtube.d.ts +108 -0
- package/dist/platforms/youtube.js +1100 -0
- package/dist/server/connections.d.ts +123 -0
- package/dist/server/connections.js +335 -0
- package/dist/server/credentials.d.ts +51 -0
- package/dist/server/credentials.js +108 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +4 -0
- package/dist/server/oauth.d.ts +52 -0
- package/dist/server/oauth.js +553 -0
- package/dist/server/webhooks.d.ts +57 -0
- package/dist/server/webhooks.js +204 -0
- package/dist/testing/index.d.ts +46 -0
- package/dist/testing/index.js +564 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -0
- package/dist/transport/binary.d.ts +2 -0
- package/dist/transport/binary.js +29 -0
- package/dist/transport/budget.d.ts +3 -0
- package/dist/transport/budget.js +26 -0
- package/dist/transport/http.d.ts +44 -0
- package/dist/transport/http.js +259 -0
- package/dist/transport/json.d.ts +8 -0
- package/dist/transport/json.js +16 -0
- package/dist/transport/upload.d.ts +25 -0
- package/dist/transport/upload.js +153 -0
- package/dist/transport/validation.d.ts +5 -0
- package/dist/transport/validation.js +24 -0
- package/package.json +2 -7
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
/* oxlint-disable anti-slop/require-readable-spacing -- deterministic mock adapter fixtures stay grouped by surface. */
|
|
2
|
+
import { SocialError, connectedAccountRef, defineAdapter, platformPostRef, } from "../core/index.js";
|
|
3
|
+
const encoder = new TextEncoder();
|
|
4
|
+
const decoder = new TextDecoder();
|
|
5
|
+
function mockManifest() {
|
|
6
|
+
return {
|
|
7
|
+
schemaVersion: 1,
|
|
8
|
+
backend: "mock",
|
|
9
|
+
apiRevision: "mock-v1",
|
|
10
|
+
runtime: ["node", "bun"],
|
|
11
|
+
capabilities: [
|
|
12
|
+
{ operation: "accounts.read", platform: "*", availability: "available" },
|
|
13
|
+
{ operation: "posts.status", platform: "*", availability: "available" },
|
|
14
|
+
{
|
|
15
|
+
operation: "posts.publish",
|
|
16
|
+
platform: "*",
|
|
17
|
+
availability: "available",
|
|
18
|
+
formats: ["text", "image", "video"],
|
|
19
|
+
},
|
|
20
|
+
{ operation: "comments.read", platform: "*", availability: "available" },
|
|
21
|
+
{ operation: "comments.write", platform: "*", availability: "available" },
|
|
22
|
+
{ operation: "analytics.read", platform: "*", availability: "available" },
|
|
23
|
+
{ operation: "analytics.account.read", platform: "*", availability: "available" },
|
|
24
|
+
{ operation: "analytics.report.read", platform: "*", availability: "available" },
|
|
25
|
+
{ operation: "search.posts", platform: "*", availability: "available" },
|
|
26
|
+
{ operation: "profiles.read", platform: "*", availability: "available" },
|
|
27
|
+
{ operation: "graph.read", platform: "*", availability: "available" },
|
|
28
|
+
{ operation: "graph.follow", platform: "*", availability: "available" },
|
|
29
|
+
{ operation: "graph.unfollow", platform: "*", availability: "available" },
|
|
30
|
+
{ operation: "graph.block", platform: "*", availability: "available" },
|
|
31
|
+
{ operation: "graph.unblock", platform: "*", availability: "available" },
|
|
32
|
+
{ operation: "graph.mute", platform: "*", availability: "available" },
|
|
33
|
+
{ operation: "graph.unmute", platform: "*", availability: "available" },
|
|
34
|
+
{ operation: "posts.read", platform: "*", availability: "available" },
|
|
35
|
+
{ operation: "posts.list", platform: "*", availability: "available" },
|
|
36
|
+
{ operation: "posts.cancelScheduled", platform: "*", availability: "available" },
|
|
37
|
+
{ operation: "posts.deleteBackendRecord", platform: "*", availability: "available" },
|
|
38
|
+
{ operation: "posts.removeFromPlatform", platform: "*", availability: "available" },
|
|
39
|
+
{ operation: "media.upload", platform: "*", availability: "available" },
|
|
40
|
+
{ operation: "messages.read", platform: "*", availability: "available" },
|
|
41
|
+
{ operation: "messages.write", platform: "*", availability: "available" },
|
|
42
|
+
{ operation: "notifications.read", platform: "*", availability: "available" },
|
|
43
|
+
{ operation: "notifications.seen", platform: "*", availability: "available" },
|
|
44
|
+
{ operation: "webhooks.verify", platform: "*", availability: "available" },
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function fixture(name, eventId, occurredAt) {
|
|
49
|
+
return {
|
|
50
|
+
name,
|
|
51
|
+
headers: { "x-mock-signature": name === "invalid" ? "invalid" : "valid" },
|
|
52
|
+
body: encoder.encode(JSON.stringify({ version: 1, eventId, type: "post.updated", occurredAt })),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function mockBackend(options = {}) {
|
|
56
|
+
let scenario = options.scenario ?? "immediate-text-success";
|
|
57
|
+
const backend = options.backendInstance ?? "default";
|
|
58
|
+
const clock = options.clock ?? (() => new Date("2026-01-01T00:00:00.000Z"));
|
|
59
|
+
const accountOne = connectedAccountRef({ backend, platform: "x", accountId: "mock-account-1" });
|
|
60
|
+
const accountTwo = connectedAccountRef({
|
|
61
|
+
backend,
|
|
62
|
+
platform: "threads",
|
|
63
|
+
accountId: "mock-account-2",
|
|
64
|
+
});
|
|
65
|
+
const accounts = [
|
|
66
|
+
{ ref: accountOne, displayName: "Mock Creator", handle: "mock.creator", status: "connected" },
|
|
67
|
+
{
|
|
68
|
+
ref: accountTwo,
|
|
69
|
+
displayName: "Mock Studio",
|
|
70
|
+
handle: "mock.studio",
|
|
71
|
+
status: scenario === "reconnect-required" ? "reconnect-required" : "connected",
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
let sequence = 0;
|
|
75
|
+
let deliverySequence = 0;
|
|
76
|
+
const deliveries = new Map();
|
|
77
|
+
let processingAdvanced = false;
|
|
78
|
+
const historyEntries = [];
|
|
79
|
+
const webhooks = [
|
|
80
|
+
fixture("valid", "event-1", "2026-01-01T00:00:02.000Z"),
|
|
81
|
+
fixture("duplicate", "event-1", "2026-01-01T00:00:02.000Z"),
|
|
82
|
+
fixture("out-of-order", "event-0", "2026-01-01T00:00:01.000Z"),
|
|
83
|
+
fixture("invalid", "event-invalid", "2026-01-01T00:00:03.000Z"),
|
|
84
|
+
];
|
|
85
|
+
function record(operation, context, account) {
|
|
86
|
+
historyEntries.push({
|
|
87
|
+
sequence: ++sequence,
|
|
88
|
+
operation,
|
|
89
|
+
backend: context.backendInstance,
|
|
90
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
91
|
+
...(account === undefined ? {} : { account }),
|
|
92
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
93
|
+
...(context.targetIdempotencyKey === undefined
|
|
94
|
+
? {}
|
|
95
|
+
: { idempotencyKey: context.targetIdempotencyKey }),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const testing = {
|
|
99
|
+
setScenario(next) {
|
|
100
|
+
scenario = next;
|
|
101
|
+
processingAdvanced = false;
|
|
102
|
+
},
|
|
103
|
+
history: () => historyEntries.map((entry) => ({ ...entry })),
|
|
104
|
+
reset: () => {
|
|
105
|
+
historyEntries.length = 0;
|
|
106
|
+
sequence = 0;
|
|
107
|
+
processingAdvanced = false;
|
|
108
|
+
},
|
|
109
|
+
advanceProcessing: () => {
|
|
110
|
+
processingAdvanced = true;
|
|
111
|
+
},
|
|
112
|
+
webhookFixtures: webhooks,
|
|
113
|
+
};
|
|
114
|
+
const adapter = defineAdapter({
|
|
115
|
+
id: "mock",
|
|
116
|
+
capabilities: mockManifest(),
|
|
117
|
+
testing,
|
|
118
|
+
accounts: {
|
|
119
|
+
async list(_input, context) {
|
|
120
|
+
record("accounts.list", context);
|
|
121
|
+
if (scenario === "account-selection-cancellation")
|
|
122
|
+
return { items: [] };
|
|
123
|
+
return { items: accounts };
|
|
124
|
+
},
|
|
125
|
+
async get(ref, context) {
|
|
126
|
+
record("accounts.get", context, ref);
|
|
127
|
+
const found = accounts.find((account) => account.ref.accountId === ref.accountId);
|
|
128
|
+
if (found === undefined) {
|
|
129
|
+
throw new SocialError({
|
|
130
|
+
code: "invalid_input",
|
|
131
|
+
operation: "accounts.get",
|
|
132
|
+
message: "Mock account was not found",
|
|
133
|
+
account: ref,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return found;
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
posts: {
|
|
140
|
+
async list(account, _input, context) {
|
|
141
|
+
record("posts.list", context, account);
|
|
142
|
+
return { items: [{ id: "mock-post", text: "Mock post" }] };
|
|
143
|
+
},
|
|
144
|
+
async get(ref, context) {
|
|
145
|
+
record("posts.get", context, connectedAccountRef(ref));
|
|
146
|
+
return { id: ref.postId, text: "Mock post" };
|
|
147
|
+
},
|
|
148
|
+
async cancelScheduled(ref, context) {
|
|
149
|
+
record("posts.cancelScheduled", context, connectedAccountRef(ref));
|
|
150
|
+
return { state: "cancelled", backendRecord: "retained" };
|
|
151
|
+
},
|
|
152
|
+
async deleteBackendRecord(ref, context) {
|
|
153
|
+
record("posts.deleteBackendRecord", context, connectedAccountRef(ref));
|
|
154
|
+
},
|
|
155
|
+
async removeFromPlatform(ref, context) {
|
|
156
|
+
record("posts.removeFromPlatform", context, connectedAccountRef(ref));
|
|
157
|
+
},
|
|
158
|
+
prepareTarget(target) {
|
|
159
|
+
if (scenario === "unsupported-feature") {
|
|
160
|
+
return [
|
|
161
|
+
{
|
|
162
|
+
code: "mock.unsupported",
|
|
163
|
+
message: "This scenario does not support publishing",
|
|
164
|
+
severity: "error",
|
|
165
|
+
targetIndex: target.targetIndex,
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
if (scenario === "expired-media-url" &&
|
|
170
|
+
target.content.media?.some((media) => media.source.kind === "https-url" && media.source.url.includes("expired"))) {
|
|
171
|
+
return [
|
|
172
|
+
{
|
|
173
|
+
code: "media.url_expired",
|
|
174
|
+
message: "The mock media URL has expired",
|
|
175
|
+
severity: "error",
|
|
176
|
+
targetIndex: target.targetIndex,
|
|
177
|
+
},
|
|
178
|
+
];
|
|
179
|
+
}
|
|
180
|
+
return [];
|
|
181
|
+
},
|
|
182
|
+
async publishTarget(target, context) {
|
|
183
|
+
const deliveryNumber = ++deliverySequence;
|
|
184
|
+
const execute = async () => {
|
|
185
|
+
record("posts.publishTarget", context, target.account);
|
|
186
|
+
const base = {
|
|
187
|
+
targetIndex: target.targetIndex,
|
|
188
|
+
account: target.account,
|
|
189
|
+
observedAt: clock().toISOString(),
|
|
190
|
+
delivery: {
|
|
191
|
+
kind: "delivery",
|
|
192
|
+
version: 1,
|
|
193
|
+
backend,
|
|
194
|
+
platform: target.account.platform,
|
|
195
|
+
accountId: target.account.accountId,
|
|
196
|
+
deliveryId: `delivery-${deliveryNumber}`,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
if (scenario === "accepted-response-lost") {
|
|
200
|
+
throw new SocialError({
|
|
201
|
+
code: "ambiguous_outcome",
|
|
202
|
+
operation: "posts.publish",
|
|
203
|
+
message: "The mock backend accepted the request but the response was lost",
|
|
204
|
+
account: target.account,
|
|
205
|
+
retryDisposition: { kind: "reconcile-first" },
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
if (scenario === "reconnect-required") {
|
|
209
|
+
throw new SocialError({
|
|
210
|
+
code: "reconnect_required",
|
|
211
|
+
operation: "posts.publish",
|
|
212
|
+
message: "Reconnect the mock account",
|
|
213
|
+
account: target.account,
|
|
214
|
+
retryDisposition: { kind: "after-reconnect" },
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
if (scenario === "mixed-success-failure" && target.targetIndex % 2 === 1) {
|
|
218
|
+
return {
|
|
219
|
+
...base,
|
|
220
|
+
state: "failed",
|
|
221
|
+
code: "mock.rejected",
|
|
222
|
+
message: "Mock target rejected",
|
|
223
|
+
retryDisposition: { kind: "never" },
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (scenario === "expired-permission") {
|
|
227
|
+
return {
|
|
228
|
+
...base,
|
|
229
|
+
state: "failed",
|
|
230
|
+
code: "missing_permission",
|
|
231
|
+
message: "Mock permission expired",
|
|
232
|
+
retryDisposition: { kind: "after-reconnect" },
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (scenario === "rate-limited") {
|
|
236
|
+
return {
|
|
237
|
+
...base,
|
|
238
|
+
state: "failed",
|
|
239
|
+
code: "rate_limited",
|
|
240
|
+
message: "Mock rate limit",
|
|
241
|
+
retryDisposition: { kind: "after-delay", delayMs: 1_000 },
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
if (scenario === "provider-state-unknown") {
|
|
245
|
+
return {
|
|
246
|
+
...base,
|
|
247
|
+
state: "unknown",
|
|
248
|
+
reason: "unmapped-state",
|
|
249
|
+
backendState: "FUTURE_STATE",
|
|
250
|
+
diagnostic: "Unmapped mock provider state",
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
if (scenario === "failed-upload-finalization") {
|
|
254
|
+
return {
|
|
255
|
+
...base,
|
|
256
|
+
state: "failed",
|
|
257
|
+
code: "media_error",
|
|
258
|
+
message: "Mock upload finalization failed",
|
|
259
|
+
retryDisposition: { kind: "never" },
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
if (scenario === "media-processing-then-success" && !processingAdvanced) {
|
|
263
|
+
return { ...base, state: "processing", backendState: "PROCESSING_MEDIA" };
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
...base,
|
|
267
|
+
state: "published",
|
|
268
|
+
post: platformPostRef({
|
|
269
|
+
backend,
|
|
270
|
+
platform: target.account.platform,
|
|
271
|
+
accountId: target.account.accountId,
|
|
272
|
+
postId: `post-${deliveryNumber}`,
|
|
273
|
+
}),
|
|
274
|
+
url: `https://social.example.invalid/${target.account.platform}/post-${deliveryNumber}`,
|
|
275
|
+
};
|
|
276
|
+
};
|
|
277
|
+
const outcome = await execute();
|
|
278
|
+
if (outcome.delivery)
|
|
279
|
+
deliveries.set(outcome.delivery.deliveryId, structuredClone(outcome));
|
|
280
|
+
return outcome;
|
|
281
|
+
},
|
|
282
|
+
async getDelivery(ref, context) {
|
|
283
|
+
record("posts.getDelivery", context, connectedAccountRef({
|
|
284
|
+
backend: ref.backend,
|
|
285
|
+
platform: ref.platform,
|
|
286
|
+
accountId: ref.accountId,
|
|
287
|
+
}));
|
|
288
|
+
if (scenario === "cancelled-polling" && context.signal?.aborted) {
|
|
289
|
+
throw new SocialError({
|
|
290
|
+
code: "cancelled",
|
|
291
|
+
operation: "posts.getDelivery",
|
|
292
|
+
message: "Mock polling was cancelled",
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
const saved = deliveries.get(ref.deliveryId);
|
|
296
|
+
if (!saved ||
|
|
297
|
+
saved.account.accountId !== ref.accountId ||
|
|
298
|
+
saved.account.platform !== ref.platform ||
|
|
299
|
+
saved.account.backend !== ref.backend)
|
|
300
|
+
throw new SocialError({
|
|
301
|
+
code: "unauthorized",
|
|
302
|
+
operation: "posts.getDelivery",
|
|
303
|
+
message: "Mock delivery does not belong to this account.",
|
|
304
|
+
});
|
|
305
|
+
if (saved.state !== "processing" || !processingAdvanced)
|
|
306
|
+
return structuredClone(saved);
|
|
307
|
+
const next = {
|
|
308
|
+
...saved,
|
|
309
|
+
state: "published",
|
|
310
|
+
observedAt: clock().toISOString(),
|
|
311
|
+
post: platformPostRef({
|
|
312
|
+
...saved.account,
|
|
313
|
+
postId: ref.deliveryId.replace(/^delivery-/, "post-"),
|
|
314
|
+
}),
|
|
315
|
+
};
|
|
316
|
+
deliveries.set(ref.deliveryId, next);
|
|
317
|
+
return structuredClone(next);
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
comments: {
|
|
321
|
+
async list(post, _input, context) {
|
|
322
|
+
record("comments.list", context, connectedAccountRef(post));
|
|
323
|
+
if (!accounts.some((account) => account.ref.accountId === post.accountId && account.ref.platform === post.platform))
|
|
324
|
+
throw new SocialError({
|
|
325
|
+
code: "unauthorized",
|
|
326
|
+
operation: "comments.read",
|
|
327
|
+
message: "Mock account is not authorized",
|
|
328
|
+
});
|
|
329
|
+
return {
|
|
330
|
+
items: [{ id: "comment-1", text: "Can you share more about this?", postId: post.postId }],
|
|
331
|
+
};
|
|
332
|
+
},
|
|
333
|
+
async reply(comment, content, context) {
|
|
334
|
+
if (comment.commentId !== "comment-1" ||
|
|
335
|
+
!content.text.trim() ||
|
|
336
|
+
!accounts.some((account) => account.ref.accountId === comment.accountId &&
|
|
337
|
+
account.ref.platform === comment.platform))
|
|
338
|
+
throw new SocialError({
|
|
339
|
+
code: "invalid_input",
|
|
340
|
+
operation: "comments.write",
|
|
341
|
+
message: "Select the returned mock comment and provide reply text",
|
|
342
|
+
});
|
|
343
|
+
record("comments.reply", context, connectedAccountRef(comment));
|
|
344
|
+
return { ...comment, commentId: `mock-reply-${sequence}` };
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
graph: {
|
|
348
|
+
async getProfile(account, input, context) {
|
|
349
|
+
record("profiles.get", context, account);
|
|
350
|
+
const profile = {
|
|
351
|
+
ref: {
|
|
352
|
+
kind: "profile",
|
|
353
|
+
version: 1,
|
|
354
|
+
backend: account.backend,
|
|
355
|
+
platform: account.platform,
|
|
356
|
+
accountId: account.accountId,
|
|
357
|
+
profileId: input.profileId ?? input.handle ?? "mock-profile",
|
|
358
|
+
},
|
|
359
|
+
displayName: "Mock Profile",
|
|
360
|
+
};
|
|
361
|
+
if (input.handle !== undefined)
|
|
362
|
+
return { ...profile, handle: input.handle };
|
|
363
|
+
return profile;
|
|
364
|
+
},
|
|
365
|
+
async listRelationships(account, _input, context) {
|
|
366
|
+
record("graph.listRelationships", context, account);
|
|
367
|
+
return { items: [] };
|
|
368
|
+
},
|
|
369
|
+
async follow(target, context) {
|
|
370
|
+
record("graph.follow", context, connectedAccountRef(target));
|
|
371
|
+
return { profile: target, relationship: "following" };
|
|
372
|
+
},
|
|
373
|
+
async unfollow(target, context) {
|
|
374
|
+
record("graph.unfollow", context, connectedAccountRef(target));
|
|
375
|
+
},
|
|
376
|
+
async block(target, context) {
|
|
377
|
+
record("graph.block", context, connectedAccountRef(target));
|
|
378
|
+
return { profile: target, relationship: "blocked" };
|
|
379
|
+
},
|
|
380
|
+
async unblock(target, context) {
|
|
381
|
+
record("graph.unblock", context, connectedAccountRef(target));
|
|
382
|
+
},
|
|
383
|
+
async mute(target, context) {
|
|
384
|
+
record("graph.mute", context, connectedAccountRef(target));
|
|
385
|
+
return { profile: target, relationship: "muted" };
|
|
386
|
+
},
|
|
387
|
+
async unmute(target, context) {
|
|
388
|
+
record("graph.unmute", context, connectedAccountRef(target));
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
search: {
|
|
392
|
+
async posts(account, input, context) {
|
|
393
|
+
record("search.posts", context, account);
|
|
394
|
+
return {
|
|
395
|
+
items: [
|
|
396
|
+
{
|
|
397
|
+
id: "mock-search-result",
|
|
398
|
+
text: input.query,
|
|
399
|
+
accountId: account.accountId,
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
};
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
analytics: {
|
|
406
|
+
async getAccountMetrics(account, context) {
|
|
407
|
+
record("analytics.getAccountMetrics", context, account);
|
|
408
|
+
return [];
|
|
409
|
+
},
|
|
410
|
+
async getPostMetrics(post, context) {
|
|
411
|
+
record("analytics.getPostMetrics", context, connectedAccountRef(post));
|
|
412
|
+
if (scenario === "no-metric-available")
|
|
413
|
+
return [];
|
|
414
|
+
return [
|
|
415
|
+
{
|
|
416
|
+
name: "views",
|
|
417
|
+
value: 42,
|
|
418
|
+
unit: "count",
|
|
419
|
+
period: "lifetime",
|
|
420
|
+
measuredAt: clock().toISOString(),
|
|
421
|
+
source: "mock",
|
|
422
|
+
},
|
|
423
|
+
];
|
|
424
|
+
},
|
|
425
|
+
async getReport(account, query, context) {
|
|
426
|
+
record("analytics.getReport", context, account);
|
|
427
|
+
return { query, rows: [], fetchedAt: clock().toISOString(), source: "mock" };
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
media: {
|
|
431
|
+
async upload(_input, account, context) {
|
|
432
|
+
record("media.upload", context, account);
|
|
433
|
+
return {
|
|
434
|
+
kind: "media",
|
|
435
|
+
version: 1,
|
|
436
|
+
backend,
|
|
437
|
+
mediaId: `media-${sequence}`,
|
|
438
|
+
platform: account.platform,
|
|
439
|
+
accountId: account.accountId,
|
|
440
|
+
};
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
messages: {
|
|
444
|
+
async listConversations(account, _input, context) {
|
|
445
|
+
record("messages.listConversations", context, account);
|
|
446
|
+
return { items: [{ id: "conversation-1" }] };
|
|
447
|
+
},
|
|
448
|
+
async listMessages(ref, _input, context) {
|
|
449
|
+
record("messages.listMessages", context, connectedAccountRef(ref));
|
|
450
|
+
return { items: [{ id: "message-1", text: "Hello" }] };
|
|
451
|
+
},
|
|
452
|
+
async send(ref, content, context) {
|
|
453
|
+
record("messages.send", context, connectedAccountRef(ref));
|
|
454
|
+
return { id: "message-2", text: content.text };
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
notifications: {
|
|
458
|
+
async list(account, _input, context) {
|
|
459
|
+
record("notifications.list", context, account);
|
|
460
|
+
return { items: [] };
|
|
461
|
+
},
|
|
462
|
+
async markSeen(account, _input, context) {
|
|
463
|
+
record("notifications.markSeen", context, account);
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
webhooks: {
|
|
467
|
+
async verify(input, context) {
|
|
468
|
+
record("webhooks.verify", context);
|
|
469
|
+
return {
|
|
470
|
+
valid: input.headers.get("x-mock-signature") === "valid",
|
|
471
|
+
method: "mock",
|
|
472
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
473
|
+
...(input.headers.get("x-mock-signature") === "valid"
|
|
474
|
+
? {}
|
|
475
|
+
: { reason: "Invalid mock signature" }),
|
|
476
|
+
};
|
|
477
|
+
},
|
|
478
|
+
async decode(input, context) {
|
|
479
|
+
record("webhooks.decode", context);
|
|
480
|
+
const parsed = JSON.parse(decoder.decode(input.body));
|
|
481
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
|
|
482
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
483
|
+
throw new SocialError({
|
|
484
|
+
code: "invalid_input",
|
|
485
|
+
operation: "webhooks.decode",
|
|
486
|
+
message: "Invalid mock webhook body",
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
const entries = [];
|
|
490
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
491
|
+
if (
|
|
492
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
|
|
493
|
+
typeof value === "string" ||
|
|
494
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
|
|
495
|
+
typeof value === "number" ||
|
|
496
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
|
|
497
|
+
typeof value === "boolean" ||
|
|
498
|
+
value === null) {
|
|
499
|
+
entries.push([key, value]);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return Object.fromEntries(entries);
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
native: { scenario: () => scenario },
|
|
506
|
+
});
|
|
507
|
+
// SAFETY: defineAdapter preserves the supplied testing controller and native mock shape.
|
|
508
|
+
return adapter;
|
|
509
|
+
}
|
|
510
|
+
export class MemoryIdempotencyStore {
|
|
511
|
+
#claimsByKey = new Map();
|
|
512
|
+
#claimsById = new Map();
|
|
513
|
+
#sequence = 0;
|
|
514
|
+
async claim(input) {
|
|
515
|
+
const mapKey = JSON.stringify([input.scope, input.key]);
|
|
516
|
+
const existing = this.#claimsByKey.get(mapKey);
|
|
517
|
+
if (existing !== undefined) {
|
|
518
|
+
if (existing.fingerprint !== input.fingerprint ||
|
|
519
|
+
JSON.stringify(existing.targetKeys) !== JSON.stringify(input.targetKeys))
|
|
520
|
+
return { kind: "conflict" };
|
|
521
|
+
return {
|
|
522
|
+
kind: "existing",
|
|
523
|
+
claimId: existing.claimId,
|
|
524
|
+
outcomes: structuredClone(existing.outcomes),
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
const claim = {
|
|
528
|
+
claimId: `mock-claim-${++this.#sequence}`,
|
|
529
|
+
fingerprint: input.fingerprint,
|
|
530
|
+
targetKeys: [...input.targetKeys],
|
|
531
|
+
outcomes: {},
|
|
532
|
+
};
|
|
533
|
+
this.#claimsByKey.set(mapKey, claim);
|
|
534
|
+
this.#claimsById.set(claim.claimId, claim);
|
|
535
|
+
return { kind: "new", claimId: claim.claimId, outcomes: {} };
|
|
536
|
+
}
|
|
537
|
+
async saveOutcome(input) {
|
|
538
|
+
const claim = this.#claimsById.get(input.claimId);
|
|
539
|
+
if (claim === undefined)
|
|
540
|
+
throw new Error(`Unknown idempotency claim: ${input.claimId}`);
|
|
541
|
+
if (!claim.targetKeys.includes(input.targetKey)) {
|
|
542
|
+
throw new Error(`Target does not belong to idempotency claim: ${input.targetKey}`);
|
|
543
|
+
}
|
|
544
|
+
claim.outcomes[input.targetKey] = structuredClone(input.outcome);
|
|
545
|
+
}
|
|
546
|
+
reset() {
|
|
547
|
+
this.#claimsByKey.clear();
|
|
548
|
+
this.#claimsById.clear();
|
|
549
|
+
this.#sequence = 0;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
export function mockSharedAccountAuthorization(input) {
|
|
553
|
+
return {
|
|
554
|
+
async authorizeTargets({ accounts, context }) {
|
|
555
|
+
const tenant = context.authorization?.tenantId;
|
|
556
|
+
return accounts.map((account) => ({
|
|
557
|
+
account,
|
|
558
|
+
allowed: tenant !== undefined && (input.memberships[tenant]?.includes(account.accountId) ?? false),
|
|
559
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
560
|
+
...(tenant === undefined ? { reason: "A tenant is required" } : {}),
|
|
561
|
+
}));
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./testing/index.js";
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./testing/index.js";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { abortable, HttpError } from "./http.js";
|
|
2
|
+
/** Bounded buffering for APIs that require one small binary image payload. */
|
|
3
|
+
export async function readBinary(stream, maxBytes, signal) {
|
|
4
|
+
const reader = stream.getReader();
|
|
5
|
+
const chunks = [];
|
|
6
|
+
let length = 0;
|
|
7
|
+
try {
|
|
8
|
+
for (;;) {
|
|
9
|
+
const chunk = await abortable(reader.read(), signal);
|
|
10
|
+
if (chunk.done)
|
|
11
|
+
break;
|
|
12
|
+
length += chunk.value.byteLength;
|
|
13
|
+
if (length > maxBytes)
|
|
14
|
+
throw new HttpError("Media exceeds its byte limit.", "invalid-input", false);
|
|
15
|
+
chunks.push(chunk.value);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
void reader.cancel().catch(() => undefined);
|
|
20
|
+
reader.releaseLock();
|
|
21
|
+
}
|
|
22
|
+
const result = new Uint8Array(length);
|
|
23
|
+
let offset = 0;
|
|
24
|
+
for (const chunk of chunks) {
|
|
25
|
+
result.set(chunk, offset);
|
|
26
|
+
offset += chunk.byteLength;
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { SocialError } from "../core/errors.js";
|
|
2
|
+
const deadlines = new WeakMap();
|
|
3
|
+
/** Sequential requests sharing an operation context consume one elapsed budget. */
|
|
4
|
+
export function remainingBudget(context, now = performance.now()) {
|
|
5
|
+
const duration = context.retryBudget.maxElapsedMs;
|
|
6
|
+
if (!Number.isFinite(duration) || duration <= 0)
|
|
7
|
+
throw new SocialError({
|
|
8
|
+
code: "invalid_input",
|
|
9
|
+
operation: "transport",
|
|
10
|
+
message: "Elapsed request budget must be positive and finite.",
|
|
11
|
+
});
|
|
12
|
+
let deadline = deadlines.get(context);
|
|
13
|
+
if (deadline === undefined) {
|
|
14
|
+
deadline = now + duration;
|
|
15
|
+
deadlines.set(context, deadline);
|
|
16
|
+
}
|
|
17
|
+
const remaining = deadline - now;
|
|
18
|
+
if (remaining <= 0)
|
|
19
|
+
throw new SocialError({
|
|
20
|
+
code: "timeout",
|
|
21
|
+
operation: "transport",
|
|
22
|
+
message: "The operation exhausted its elapsed request budget before the next dispatch.",
|
|
23
|
+
retryDisposition: { kind: "never" },
|
|
24
|
+
});
|
|
25
|
+
return remaining;
|
|
26
|
+
}
|