facebook-mcp-server 1.6.6

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.
Files changed (64) hide show
  1. package/.env.example +2 -0
  2. package/.github/dependabot.yml +50 -0
  3. package/.github/workflows/ci.yml +51 -0
  4. package/.github/workflows/release.yml +200 -0
  5. package/CONTRIBUTING.md +112 -0
  6. package/LICENSE +21 -0
  7. package/README.md +128 -0
  8. package/dist/client.d.ts +57 -0
  9. package/dist/client.js +140 -0
  10. package/dist/client.test.d.ts +9 -0
  11. package/dist/client.test.js +211 -0
  12. package/dist/create-post.d.ts +39 -0
  13. package/dist/create-post.js +85 -0
  14. package/dist/create-post.test.d.ts +11 -0
  15. package/dist/create-post.test.js +175 -0
  16. package/dist/errors.d.ts +12 -0
  17. package/dist/errors.js +87 -0
  18. package/dist/errors.test.d.ts +9 -0
  19. package/dist/errors.test.js +162 -0
  20. package/dist/first-comment.test.d.ts +10 -0
  21. package/dist/first-comment.test.js +54 -0
  22. package/dist/handlers.test.d.ts +19 -0
  23. package/dist/handlers.test.js +333 -0
  24. package/dist/index.d.ts +44 -0
  25. package/dist/index.js +374 -0
  26. package/dist/lib/index.d.ts +9 -0
  27. package/dist/lib/index.js +8 -0
  28. package/dist/lib/insights.d.ts +53 -0
  29. package/dist/lib/insights.js +47 -0
  30. package/dist/rate-limiter.d.ts +71 -0
  31. package/dist/rate-limiter.js +214 -0
  32. package/dist/rate-limiter.test.d.ts +1 -0
  33. package/dist/rate-limiter.test.js +154 -0
  34. package/dist/response.d.ts +24 -0
  35. package/dist/response.js +35 -0
  36. package/dist/response.test.d.ts +1 -0
  37. package/dist/response.test.js +71 -0
  38. package/dist/sanitize.d.ts +17 -0
  39. package/dist/sanitize.js +27 -0
  40. package/dist/sanitize.test.d.ts +1 -0
  41. package/dist/sanitize.test.js +43 -0
  42. package/dist/tools.test.d.ts +16 -0
  43. package/dist/tools.test.js +150 -0
  44. package/package.json +29 -0
  45. package/src/client.test.ts +284 -0
  46. package/src/client.ts +204 -0
  47. package/src/create-post.test.ts +196 -0
  48. package/src/create-post.ts +118 -0
  49. package/src/errors.test.ts +297 -0
  50. package/src/errors.ts +108 -0
  51. package/src/first-comment.test.ts +73 -0
  52. package/src/handlers.test.ts +431 -0
  53. package/src/index.ts +540 -0
  54. package/src/lib/index.ts +9 -0
  55. package/src/lib/insights.ts +150 -0
  56. package/src/rate-limiter.test.ts +186 -0
  57. package/src/rate-limiter.ts +252 -0
  58. package/src/response.test.ts +80 -0
  59. package/src/response.ts +43 -0
  60. package/src/sanitize.test.ts +52 -0
  61. package/src/sanitize.ts +35 -0
  62. package/src/tools.test.ts +195 -0
  63. package/tsconfig.json +15 -0
  64. package/vitest.config.ts +10 -0
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Unit tests for buildCreatePostRequest — the pure validation + routing
3
+ * helper backing fb_create_post.
4
+ *
5
+ * Each test is a regression guard for a specific branch of the tool's
6
+ * pre-flight logic. This closes the coverage gap identified in the Facebook
7
+ * MCP test audit: the 4 most FB-specific code paths (attachment routing,
8
+ * mutual-exclusion, caption length, empty-input) were previously only
9
+ * covered by live manual testing.
10
+ */
11
+
12
+ import { describe, it, expect } from "vitest";
13
+ import {
14
+ buildCreatePostRequest,
15
+ FB_MESSAGE_MAX_LENGTH,
16
+ } from "./create-post.js";
17
+
18
+ describe("buildCreatePostRequest — validation", () => {
19
+ it("rejects empty input (no message, no attachment)", () => {
20
+ const r = buildCreatePostRequest({});
21
+ expect(r.ok).toBe(false);
22
+ if (!r.ok) {
23
+ expect(r.error).toBe("Invalid input");
24
+ expect(r.message).toMatch(
25
+ /at least a message, link, imageUrl, or videoUrl/,
26
+ );
27
+ }
28
+ });
29
+
30
+ it("rejects when two attachment types are provided (link + imageUrl)", () => {
31
+ const r = buildCreatePostRequest({
32
+ message: "x",
33
+ link: "https://a.com",
34
+ imageUrl: "https://example.com/x.jpg",
35
+ });
36
+ expect(r.ok).toBe(false);
37
+ if (!r.ok) {
38
+ expect(r.message).toMatch(/Exactly one of link, imageUrl, or videoUrl/);
39
+ expect(r.action).toMatch(/^FIX_INPUT:/);
40
+ }
41
+ });
42
+
43
+ it("rejects when all three attachment types are provided", () => {
44
+ const r = buildCreatePostRequest({
45
+ link: "https://a.com",
46
+ imageUrl: "https://example.com/x.jpg",
47
+ videoUrl: "https://example.com/x.mp4",
48
+ });
49
+ expect(r.ok).toBe(false);
50
+ if (!r.ok) {
51
+ expect(r.action).toMatch(/^FIX_INPUT:/);
52
+ }
53
+ });
54
+
55
+ it("rejects message longer than Facebook's 63206 char limit", () => {
56
+ const r = buildCreatePostRequest({ message: "x".repeat(63207) });
57
+ expect(r.ok).toBe(false);
58
+ if (!r.ok) {
59
+ expect(r.message).toMatch(/63207 chars, Facebook max is 63206/);
60
+ expect(r.action).toMatch(/^CAPTION_TOO_LONG:/);
61
+ }
62
+ });
63
+
64
+ it("accepts exactly 63206 chars (boundary condition)", () => {
65
+ const r = buildCreatePostRequest({
66
+ message: "x".repeat(FB_MESSAGE_MAX_LENGTH),
67
+ });
68
+ expect(r.ok).toBe(true);
69
+ });
70
+ });
71
+
72
+ describe("buildCreatePostRequest — routing", () => {
73
+ it("routes text-only post to /{pageId}/feed with message", () => {
74
+ const r = buildCreatePostRequest({ message: "hello world" });
75
+ expect(r.ok).toBe(true);
76
+ if (r.ok) {
77
+ expect(r.type).toBe("text");
78
+ expect(r.endpoint("548545275018321")).toBe("/548545275018321/feed");
79
+ expect(r.body).toEqual({ message: "hello world" });
80
+ }
81
+ });
82
+
83
+ it("routes link post to /feed with link field", () => {
84
+ const r = buildCreatePostRequest({
85
+ message: "check this out",
86
+ link: "https://www.luminarylane.app",
87
+ });
88
+ expect(r.ok).toBe(true);
89
+ if (r.ok) {
90
+ expect(r.type).toBe("link");
91
+ expect(r.endpoint("548545275018321")).toBe("/548545275018321/feed");
92
+ expect(r.body).toEqual({
93
+ message: "check this out",
94
+ link: "https://www.luminarylane.app",
95
+ });
96
+ }
97
+ });
98
+
99
+ it("routes link post WITHOUT message to /feed (message optional for link)", () => {
100
+ const r = buildCreatePostRequest({ link: "https://www.luminarylane.app" });
101
+ expect(r.ok).toBe(true);
102
+ if (r.ok) {
103
+ expect(r.type).toBe("link");
104
+ expect(r.body).toEqual({ link: "https://www.luminarylane.app" });
105
+ expect(r.body.message).toBeUndefined();
106
+ }
107
+ });
108
+
109
+ it("routes photo post to /{pageId}/photos with url (not link)", () => {
110
+ const r = buildCreatePostRequest({
111
+ message: "a cat",
112
+ imageUrl: "https://example.com/cat.jpg",
113
+ });
114
+ expect(r.ok).toBe(true);
115
+ if (r.ok) {
116
+ expect(r.type).toBe("photo");
117
+ expect(r.endpoint("548545275018321")).toBe("/548545275018321/photos");
118
+ expect(r.body).toEqual({
119
+ message: "a cat",
120
+ url: "https://example.com/cat.jpg",
121
+ });
122
+ // Ensure we did NOT accidentally set the `link` field
123
+ expect(r.body.link).toBeUndefined();
124
+ }
125
+ });
126
+
127
+ it("routes photo post without caption", () => {
128
+ const r = buildCreatePostRequest({
129
+ imageUrl: "https://example.com/cat.jpg",
130
+ });
131
+ expect(r.ok).toBe(true);
132
+ if (r.ok) {
133
+ expect(r.type).toBe("photo");
134
+ expect(r.body.message).toBeUndefined();
135
+ expect(r.body.url).toBe("https://example.com/cat.jpg");
136
+ }
137
+ });
138
+
139
+ it("routes video post to /{pageId}/videos with file_url", () => {
140
+ const r = buildCreatePostRequest({
141
+ message: "watch",
142
+ videoUrl: "https://example.com/clip.mp4",
143
+ });
144
+ expect(r.ok).toBe(true);
145
+ if (r.ok) {
146
+ expect(r.type).toBe("video");
147
+ expect(r.endpoint("548545275018321")).toBe("/548545275018321/videos");
148
+ expect(r.body).toEqual({
149
+ message: "watch",
150
+ file_url: "https://example.com/clip.mp4",
151
+ });
152
+ }
153
+ });
154
+
155
+ it("passes published=false through to body when provided", () => {
156
+ const r = buildCreatePostRequest({
157
+ message: "draft",
158
+ published: false,
159
+ });
160
+ expect(r.ok).toBe(true);
161
+ if (r.ok) {
162
+ expect(r.body.published).toBe(false);
163
+ }
164
+ });
165
+
166
+ it("does NOT set published when undefined (defaults to FB's true)", () => {
167
+ const r = buildCreatePostRequest({ message: "live" });
168
+ expect(r.ok).toBe(true);
169
+ if (r.ok) {
170
+ expect(r.body.published).toBeUndefined();
171
+ expect("published" in r.body).toBe(false);
172
+ }
173
+ });
174
+
175
+ it("does NOT set published when explicitly true (let FB default stand)", () => {
176
+ const r = buildCreatePostRequest({
177
+ message: "live",
178
+ published: true,
179
+ });
180
+ expect(r.ok).toBe(true);
181
+ if (r.ok) {
182
+ expect(r.body.published).toBeUndefined();
183
+ }
184
+ });
185
+ });
186
+
187
+ describe("buildCreatePostRequest — endpoint is a pure function of pageId", () => {
188
+ it("endpoint builder returns different paths for different page ids", () => {
189
+ const r = buildCreatePostRequest({ message: "x" });
190
+ expect(r.ok).toBe(true);
191
+ if (r.ok) {
192
+ expect(r.endpoint("111")).toBe("/111/feed");
193
+ expect(r.endpoint("222")).toBe("/222/feed");
194
+ }
195
+ });
196
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Pure helpers for fb_create_post — validation + request shaping.
3
+ *
4
+ * Extracted from index.ts so the attachment-routing logic can be unit-tested
5
+ * without spinning up the MCP server or mocking HTTP. Every branch here has
6
+ * a test in create-post.test.ts.
7
+ */
8
+
9
+ export interface CreatePostArgs {
10
+ message?: string;
11
+ link?: string;
12
+ imageUrl?: string;
13
+ videoUrl?: string;
14
+ published?: boolean;
15
+ }
16
+
17
+ export interface CreatePostValidationError {
18
+ ok: false;
19
+ error: string;
20
+ message: string;
21
+ action?: string;
22
+ }
23
+
24
+ export interface CreatePostRequest {
25
+ ok: true;
26
+ endpoint: (pageId: string) => string;
27
+ body: Record<string, unknown>;
28
+ type: "text" | "link" | "photo" | "video";
29
+ }
30
+
31
+ export type CreatePostResult = CreatePostValidationError | CreatePostRequest;
32
+
33
+ // Facebook Page post hard cap per Meta docs.
34
+ export const FB_MESSAGE_MAX_LENGTH = 63206;
35
+
36
+ /**
37
+ * Validate fb_create_post arguments and return either a structured error
38
+ * or the request recipe (endpoint + body + content type) the tool should
39
+ * send to the Graph API.
40
+ *
41
+ * This function is pure — it has no side effects, does no IO, and does not
42
+ * depend on env vars or a client instance. The caller is responsible for
43
+ * substituting the real page id into `endpoint(pageId)` and actually
44
+ * dispatching the request.
45
+ */
46
+ export function buildCreatePostRequest(args: CreatePostArgs): CreatePostResult {
47
+ const hasLink = !!args.link;
48
+ const hasImage = !!args.imageUrl;
49
+ const hasVideo = !!args.videoUrl;
50
+ const attachmentCount = [hasLink, hasImage, hasVideo].filter(Boolean).length;
51
+
52
+ // Rule 1: at most one attachment type
53
+ if (attachmentCount > 1) {
54
+ return {
55
+ ok: false,
56
+ error: "Invalid input",
57
+ message:
58
+ "Exactly one of link, imageUrl, or videoUrl may be provided (or none for text-only).",
59
+ action:
60
+ "FIX_INPUT: Pick a single attachment type per post. For multi-photo posts, fall back to calling fb_create_post once per photo.",
61
+ };
62
+ }
63
+
64
+ // Rule 2: must have SOMETHING
65
+ if (!args.message && attachmentCount === 0) {
66
+ return {
67
+ ok: false,
68
+ error: "Invalid input",
69
+ message:
70
+ "A post must have at least a message, link, imageUrl, or videoUrl.",
71
+ };
72
+ }
73
+
74
+ // Rule 3: message length
75
+ if (args.message && args.message.length > FB_MESSAGE_MAX_LENGTH) {
76
+ return {
77
+ ok: false,
78
+ error: "Invalid input",
79
+ message: `Message is ${args.message.length} chars, Facebook max is ${FB_MESSAGE_MAX_LENGTH}.`,
80
+ action: "CAPTION_TOO_LONG: Shorten the message and retry.",
81
+ };
82
+ }
83
+
84
+ // Build the request body
85
+ const body: Record<string, unknown> = {};
86
+ if (args.message) body.message = args.message;
87
+ if (args.published === false) body.published = false;
88
+
89
+ // Route to the correct endpoint based on attachment type
90
+ if (hasImage) {
91
+ body.url = args.imageUrl;
92
+ return {
93
+ ok: true,
94
+ endpoint: (pageId: string) => `/${pageId}/photos`,
95
+ body,
96
+ type: "photo",
97
+ };
98
+ }
99
+
100
+ if (hasVideo) {
101
+ body.file_url = args.videoUrl;
102
+ return {
103
+ ok: true,
104
+ endpoint: (pageId: string) => `/${pageId}/videos`,
105
+ body,
106
+ type: "video",
107
+ };
108
+ }
109
+
110
+ // Text-only OR link attachment — both go through /feed
111
+ if (hasLink) body.link = args.link;
112
+ return {
113
+ ok: true,
114
+ endpoint: (pageId: string) => `/${pageId}/feed`,
115
+ body,
116
+ type: hasLink ? "link" : "text",
117
+ };
118
+ }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Unit tests for error mapping helpers.
3
+ *
4
+ * Every test case here is a regression guard for a specific bug discovered
5
+ * during live testing against the real Facebook Graph API. Changing any of
6
+ * the asserted strings without updating the tool's error-handling docs is
7
+ * a contract break for agents consuming these responses.
8
+ */
9
+
10
+ import { describe, it, expect } from "vitest";
11
+ import { extractApiDetail, suggestAction } from "./errors.js";
12
+ import { FacebookApiError } from "./client.js";
13
+
14
+ describe("extractApiDetail", () => {
15
+ it("returns undefined for non-FacebookApiError values", () => {
16
+ expect(extractApiDetail(new Error("boom"))).toBeUndefined();
17
+ expect(extractApiDetail("string error")).toBeUndefined();
18
+ expect(extractApiDetail(null)).toBeUndefined();
19
+ expect(extractApiDetail(undefined)).toBeUndefined();
20
+ });
21
+
22
+ it("formats FacebookApiError without subcode", () => {
23
+ const err = new FacebookApiError(400, {
24
+ message: "Invalid OAuth access token - Cannot parse access token",
25
+ type: "OAuthException",
26
+ code: 190,
27
+ });
28
+ expect(extractApiDetail(err)).toBe(
29
+ "OAuthException: Invalid OAuth access token - Cannot parse access token",
30
+ );
31
+ });
32
+
33
+ it("formats FacebookApiError with subcode", () => {
34
+ const err = new FacebookApiError(400, {
35
+ message: "Media ID is not available",
36
+ type: "OAuthException",
37
+ code: 100,
38
+ error_subcode: 2207027,
39
+ });
40
+ expect(extractApiDetail(err)).toBe(
41
+ "OAuthException: Media ID is not available (subcode: 2207027)",
42
+ );
43
+ });
44
+ });
45
+
46
+ describe("suggestAction — network errors (no statusCode)", () => {
47
+ it("ENOTFOUND → DNS_FAILURE", () => {
48
+ const r = suggestAction(
49
+ "fb_get_page_insights",
50
+ undefined,
51
+ undefined,
52
+ "getaddrinfo ENOTFOUND graph.facebook.com",
53
+ );
54
+ expect(r).toMatch(/^DNS_FAILURE:/);
55
+ });
56
+
57
+ it("timeout → TIMEOUT", () => {
58
+ expect(
59
+ suggestAction(
60
+ "fb_get_comments",
61
+ undefined,
62
+ undefined,
63
+ "The operation was aborted due to timeout",
64
+ ),
65
+ ).toMatch(/^TIMEOUT:/);
66
+ });
67
+
68
+ it("ECONNREFUSED → CONNECTION_FAILED", () => {
69
+ expect(
70
+ suggestAction(
71
+ "fb_get_comments",
72
+ undefined,
73
+ undefined,
74
+ "connect ECONNREFUSED 31.13.84.4:443",
75
+ ),
76
+ ).toMatch(/^CONNECTION_FAILED:/);
77
+ });
78
+
79
+ it("fetch failed → NETWORK_ERROR", () => {
80
+ expect(
81
+ suggestAction("fb_get_comments", undefined, undefined, "fetch failed"),
82
+ ).toMatch(/^NETWORK_ERROR:/);
83
+ });
84
+
85
+ it("unknown network error → undefined", () => {
86
+ expect(
87
+ suggestAction("fb_get_comments", undefined, undefined, "something weird"),
88
+ ).toBeUndefined();
89
+ });
90
+ });
91
+
92
+ describe("suggestAction — token errors (high priority for Bug #6)", () => {
93
+ it("'Invalid OAuth access token' → AUTH_FAILED", () => {
94
+ const r = suggestAction(
95
+ "fb_get_page_insights",
96
+ 400,
97
+ "OAuthException: Invalid OAuth access token - Cannot parse access token",
98
+ );
99
+ expect(r).toMatch(/^AUTH_FAILED:/);
100
+ });
101
+
102
+ it("'Cannot parse access token' → AUTH_FAILED", () => {
103
+ expect(suggestAction("any", 400, "Cannot parse access token")).toMatch(
104
+ /^AUTH_FAILED:/,
105
+ );
106
+ });
107
+
108
+ it("'access token has expired' → AUTH_FAILED", () => {
109
+ expect(
110
+ suggestAction(
111
+ "any",
112
+ 400,
113
+ "Error validating access token: access token has expired",
114
+ ),
115
+ ).toMatch(/^AUTH_FAILED:/);
116
+ });
117
+
118
+ it("'session has expired' → AUTH_FAILED", () => {
119
+ expect(
120
+ suggestAction("any", 400, "The session has expired on Tuesday"),
121
+ ).toMatch(/^AUTH_FAILED:/);
122
+ });
123
+
124
+ it("'malformed access token' → AUTH_FAILED", () => {
125
+ expect(suggestAction("any", 400, "Malformed access token")).toMatch(
126
+ /^AUTH_FAILED:/,
127
+ );
128
+ });
129
+
130
+ // REGRESSION: generic OAuthException for metric errors must NOT be AUTH_FAILED
131
+ it("metric error mentioning OAuthException is NOT AUTH_FAILED", () => {
132
+ const metricErr =
133
+ "OAuthException: (#100) metric[2] must be one of the following values: reach, follower_count, profile_views";
134
+ const r = suggestAction("fb_get_page_insights", 400, metricErr);
135
+ expect(r).not.toMatch(/^AUTH_FAILED:/);
136
+ expect(r).toMatch(/^INVALID_REQUEST:/);
137
+ });
138
+ });
139
+
140
+ describe("suggestAction — 400 media errors (regression guard for Bug #9)", () => {
141
+ it("'photo or video can be accepted' → INVALID_MEDIA", () => {
142
+ const r = suggestAction(
143
+ "fb_create_post",
144
+ 400,
145
+ "OAuthException: Only photo or video can be accepted as media type. (subcode: 2207052)",
146
+ );
147
+ expect(r).toMatch(/^INVALID_MEDIA:/);
148
+ });
149
+
150
+ it("subcode 2207052 → INVALID_MEDIA", () => {
151
+ expect(
152
+ suggestAction(
153
+ "fb_create_post",
154
+ 400,
155
+ "Some error text (subcode: 2207052)",
156
+ ),
157
+ ).toMatch(/^INVALID_MEDIA:/);
158
+ });
159
+
160
+ it("subcode 2207027 → INVALID_MEDIA", () => {
161
+ expect(
162
+ suggestAction(
163
+ "fb_create_post",
164
+ 400,
165
+ "Media ID is not available (subcode: 2207027)",
166
+ ),
167
+ ).toMatch(/^INVALID_MEDIA:/);
168
+ });
169
+
170
+ it("'invalid media URL' → INVALID_MEDIA", () => {
171
+ expect(
172
+ suggestAction("fb_create_post", 400, "Invalid media URL provided"),
173
+ ).toMatch(/^INVALID_MEDIA:/);
174
+ });
175
+ });
176
+
177
+ describe("suggestAction — other 400 branches", () => {
178
+ it("caption mention → CAPTION_TOO_LONG", () => {
179
+ expect(suggestAction("fb_create_post", 400, "Caption is too long")).toMatch(
180
+ /^CAPTION_TOO_LONG:/,
181
+ );
182
+ });
183
+
184
+ it("'too long' → CAPTION_TOO_LONG", () => {
185
+ expect(suggestAction("fb_create_post", 400, "Value too long")).toMatch(
186
+ /^CAPTION_TOO_LONG:/,
187
+ );
188
+ });
189
+
190
+ it("carousel children error → INVALID_CAROUSEL", () => {
191
+ expect(
192
+ suggestAction(
193
+ "fb_create_post",
194
+ 400,
195
+ "Carousel children must be between 2 and 10",
196
+ ),
197
+ ).toMatch(/^INVALID_CAROUSEL:/);
198
+ });
199
+
200
+ it("hashtag error → INVALID_HASHTAG", () => {
201
+ expect(
202
+ suggestAction("fb_get_page_feed", 400, "Hashtag quota exceeded"),
203
+ ).toMatch(/^INVALID_HASHTAG:/);
204
+ });
205
+
206
+ it("unmatched 400 → INVALID_REQUEST", () => {
207
+ expect(suggestAction("any", 400, "Unknown bad request reason")).toMatch(
208
+ /^INVALID_REQUEST:/,
209
+ );
210
+ });
211
+ });
212
+
213
+ describe("suggestAction — 401/403/404", () => {
214
+ it("401 → AUTH_FAILED", () => {
215
+ expect(suggestAction("any", 401, "anything")).toMatch(/^AUTH_FAILED:/);
216
+ });
217
+
218
+ it("403 with 'permission' → PERMISSION_DENIED", () => {
219
+ expect(
220
+ suggestAction("any", 403, "You lack the required permission"),
221
+ ).toMatch(/^PERMISSION_DENIED:/);
222
+ });
223
+
224
+ it("403 with 'not approved' → APP_NOT_APPROVED", () => {
225
+ expect(
226
+ suggestAction("any", 403, "This feature is not approved for your app"),
227
+ ).toMatch(/^APP_NOT_APPROVED:/);
228
+ });
229
+
230
+ it("403 with 'business' → BUSINESS_ACCOUNT_REQUIRED", () => {
231
+ expect(suggestAction("any", 403, "A business account is required")).toMatch(
232
+ /^BUSINESS_ACCOUNT_REQUIRED:/,
233
+ );
234
+ });
235
+
236
+ it("403 unmatched → FORBIDDEN", () => {
237
+ expect(suggestAction("any", 403, "generic denial")).toMatch(/^FORBIDDEN:/);
238
+ });
239
+
240
+ it("404 comment tool → COMMENT_NOT_FOUND", () => {
241
+ expect(suggestAction("fb_reply_comment", 404, "not found")).toMatch(
242
+ /^COMMENT_NOT_FOUND:/,
243
+ );
244
+ });
245
+
246
+ it("404 post tool (delete) → MEDIA_NOT_FOUND", () => {
247
+ expect(suggestAction("fb_delete_post", 404, "not found")).toMatch(
248
+ /^MEDIA_NOT_FOUND:/,
249
+ );
250
+ });
251
+
252
+ it("404 insight tool → MEDIA_NOT_FOUND", () => {
253
+ expect(suggestAction("fb_get_post_insights", 404, "not found")).toMatch(
254
+ /^MEDIA_NOT_FOUND:/,
255
+ );
256
+ });
257
+
258
+ // Story branch is inherited from the shared errors.ts but Facebook has no
259
+ // story tools in scope — kept as a future-proofing regression guard.
260
+ it("404 story tool → STORY_NOT_FOUND (future-proofing)", () => {
261
+ expect(suggestAction("some_story_tool", 404, "not found")).toMatch(
262
+ /^STORY_NOT_FOUND:/,
263
+ );
264
+ });
265
+
266
+ it("404 generic tool → NOT_FOUND", () => {
267
+ expect(suggestAction("fb_misc_tool", 404, "not found")).toMatch(
268
+ /^NOT_FOUND:/,
269
+ );
270
+ });
271
+ });
272
+
273
+ describe("suggestAction — 429 and 5xx", () => {
274
+ it("429 → RATE_LIMITED", () => {
275
+ expect(suggestAction("any", 429, "rate limited")).toMatch(/^RATE_LIMITED:/);
276
+ });
277
+
278
+ it("500 → SERVER_ERROR", () => {
279
+ expect(suggestAction("any", 500, "internal server error")).toMatch(
280
+ /^SERVER_ERROR:/,
281
+ );
282
+ });
283
+
284
+ it("502 → SERVER_ERROR", () => {
285
+ expect(suggestAction("any", 502, "bad gateway")).toMatch(/^SERVER_ERROR:/);
286
+ });
287
+
288
+ it("503 → SERVER_ERROR", () => {
289
+ expect(suggestAction("any", 503, "service unavailable")).toMatch(
290
+ /^SERVER_ERROR:/,
291
+ );
292
+ });
293
+
294
+ it("unknown status → undefined", () => {
295
+ expect(suggestAction("any", 418, "I'm a teapot")).toBeUndefined();
296
+ });
297
+ });