@saltcorn/meta-marketing-api 0.1.0 → 0.1.2

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/index.js CHANGED
@@ -24,6 +24,13 @@ const {
24
24
  getAdCreatives,
25
25
  getAdCreative,
26
26
  getAdPreview,
27
+ getAdText,
28
+ getAdHeadline,
29
+ getAdBody,
30
+ getAdMedia,
31
+ getAdMediaType,
32
+ getAdMediaUrl,
33
+ getPageAccessToken,
27
34
  getInsights,
28
35
  getInsightsAsync,
29
36
  exchangeLongLivedToken,
@@ -383,6 +390,72 @@ module.exports = {
383
390
  { name: "query", type: "JSON" },
384
391
  ],
385
392
  },
393
+ get_meta_ad_headline: {
394
+ async run(ad, cfgOverRide) {
395
+ return await getAdHeadline(ad, { ...cfg, ...cfgOverRide });
396
+ },
397
+ isAsync: true,
398
+ description:
399
+ "Get the headline of an ad, wherever Meta keeps it for that kind of ad. Takes an ad id or an ad row that already has its creative.",
400
+ arguments: [{ name: "ad", type: "String" }],
401
+ },
402
+ get_meta_ad_body: {
403
+ async run(ad, cfgOverRide) {
404
+ return await getAdBody(ad, { ...cfg, ...cfgOverRide });
405
+ },
406
+ isAsync: true,
407
+ description:
408
+ "Get the primary text of an ad, the longer wording above the image. Takes an ad id or an ad row that already has its creative.",
409
+ arguments: [{ name: "ad", type: "String" }],
410
+ },
411
+ get_meta_ad_text: {
412
+ async run(ad, cfgOverRide) {
413
+ return await getAdText(ad, { ...cfg, ...cfgOverRide });
414
+ },
415
+ isAsync: true,
416
+ description:
417
+ "Get the headline and the primary text of an ad together, as { headline, body }, in one read",
418
+ arguments: [{ name: "ad", type: "String" }],
419
+ },
420
+ get_meta_ad_media: {
421
+ async run(ad, opts, cfgOverRide) {
422
+ return await getAdMedia(ad, { ...cfg, ...cfgOverRide }, opts || {});
423
+ },
424
+ isAsync: true,
425
+ description:
426
+ "Get what an ad is made of and where to download it: { type, carousel, media }, where type is image, video, mixed or unknown and each entry in media has a url. Takes an ad id or an ad row that already has its creative. Set resolve_urls false in the options to only work out the type, without reading the address of every file.",
427
+ arguments: [
428
+ { name: "ad", type: "String" },
429
+ { name: "opts", type: "JSON" },
430
+ ],
431
+ },
432
+ get_meta_ad_media_type: {
433
+ async run(ad, cfgOverRide) {
434
+ return await getAdMediaType(ad, { ...cfg, ...cfgOverRide });
435
+ },
436
+ isAsync: true,
437
+ description:
438
+ "Whether an ad is an image, a video, mixed or unknown. Takes an ad id or an ad row that already has its creative.",
439
+ arguments: [{ name: "ad", type: "String" }],
440
+ },
441
+ get_meta_ad_media_url: {
442
+ async run(ad, cfgOverRide) {
443
+ return await getAdMediaUrl(ad, { ...cfg, ...cfgOverRide });
444
+ },
445
+ isAsync: true,
446
+ description:
447
+ "The address of the picture or the film in an ad, ready to download. Video addresses are signed and short lived, so download the file straight away.",
448
+ arguments: [{ name: "ad", type: "String" }],
449
+ },
450
+ get_meta_page_access_token: {
451
+ async run(pageId, cfgOverRide) {
452
+ return await getPageAccessToken(pageId, { ...cfg, ...cfgOverRide });
453
+ },
454
+ isAsync: true,
455
+ description:
456
+ "A token for one of your pages, or nothing when your access token has no say over that page. Use it to check why an ad that boosts a page post shows nothing.",
457
+ arguments: [{ name: "pageId", type: "String" }],
458
+ },
386
459
  get_meta_ad_preview: {
387
460
  async run(adId, adFormat, cfgOverRide) {
388
461
  return await getAdPreview(adId, adFormat, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saltcorn/meta-marketing-api",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Read ads on Facebook and Instagram with the Meta Marketing API",
5
5
  "main": "index.js",
6
6
  "dependencies": {
package/tests/api.test.js CHANGED
@@ -6,6 +6,10 @@ const {
6
6
  isTransientError,
7
7
  insightsFields,
8
8
  DEFAULT_FIELDS,
9
+ creativeHeadlines,
10
+ creativeBodies,
11
+ creativeMedia,
12
+ mediaType,
9
13
  } = require("../api");
10
14
 
11
15
  describe("query string encoding", () => {
@@ -92,3 +96,222 @@ describe("default fields", () => {
92
96
  });
93
97
  });
94
98
  });
99
+
100
+ describe("finding the headline on a creative", () => {
101
+ it("reads the headline of a link ad", () => {
102
+ expect(
103
+ creativeHeadlines({
104
+ object_story_spec: {
105
+ link_data: { name: "Half price today", message: "Primary text" },
106
+ },
107
+ })
108
+ ).toEqual(["Half price today"]);
109
+ });
110
+ it("reads the headline of a video ad", () => {
111
+ expect(
112
+ creativeHeadlines({ object_story_spec: { video_data: { title: "Watch" } } })
113
+ ).toEqual(["Watch"]);
114
+ });
115
+ it("reads the old style title field", () => {
116
+ expect(creativeHeadlines({ title: "Legacy headline" })).toEqual([
117
+ "Legacy headline",
118
+ ]);
119
+ });
120
+ it("returns one headline per carousel card", () => {
121
+ expect(
122
+ creativeHeadlines({
123
+ object_story_spec: {
124
+ link_data: {
125
+ name: "Our shop",
126
+ child_attachments: [{ name: "Shoes" }, { name: "Hats" }],
127
+ },
128
+ },
129
+ })
130
+ ).toEqual(["Our shop", "Shoes", "Hats"]);
131
+ });
132
+ it("returns every headline of a dynamic creative, without repeats", () => {
133
+ expect(
134
+ creativeHeadlines({
135
+ title: "Half price",
136
+ asset_feed_spec: {
137
+ titles: [{ text: "Half price" }, { text: "50% off" }],
138
+ },
139
+ })
140
+ ).toEqual(["Half price", "50% off"]);
141
+ });
142
+ it("comes back empty when there is no headline to find", () => {
143
+ expect(creativeHeadlines({ effective_object_story_id: "1_2" })).toEqual([]);
144
+ expect(creativeHeadlines(null)).toEqual([]);
145
+ });
146
+ });
147
+
148
+ describe("finding the primary text on a creative", () => {
149
+ it("reads the text above the image of a link ad", () => {
150
+ expect(
151
+ creativeBodies({
152
+ object_story_spec: {
153
+ link_data: { name: "A headline", message: "The longer wording" },
154
+ },
155
+ })
156
+ ).toEqual(["The longer wording"]);
157
+ });
158
+ it("reads the text of video, photo and text only ads", () => {
159
+ expect(
160
+ creativeBodies({ object_story_spec: { video_data: { message: "Video" } } })
161
+ ).toEqual(["Video"]);
162
+ expect(
163
+ creativeBodies({ object_story_spec: { photo_data: { caption: "Photo" } } })
164
+ ).toEqual(["Photo"]);
165
+ expect(
166
+ creativeBodies({ object_story_spec: { text_data: { message: "Text" } } })
167
+ ).toEqual(["Text"]);
168
+ });
169
+ it("reads the old style body field", () => {
170
+ expect(creativeBodies({ body: "Legacy body" })).toEqual(["Legacy body"]);
171
+ });
172
+ it("returns every primary text of a dynamic creative, without repeats", () => {
173
+ expect(
174
+ creativeBodies({
175
+ body: "Shop the sale",
176
+ asset_feed_spec: {
177
+ bodies: [{ text: "Shop the sale" }, { text: "Everything reduced" }],
178
+ },
179
+ })
180
+ ).toEqual(["Shop the sale", "Everything reduced"]);
181
+ });
182
+ it("does not mistake the headline for the primary text", () => {
183
+ const creative = {
184
+ title: "A headline",
185
+ object_story_spec: { link_data: { name: "A headline" } },
186
+ };
187
+ expect(creativeBodies(creative)).toEqual([]);
188
+ expect(creativeHeadlines(creative)).toEqual(["A headline"]);
189
+ });
190
+ it("comes back empty when there is no text to find", () => {
191
+ expect(creativeBodies({ effective_object_story_id: "1_2" })).toEqual([]);
192
+ expect(creativeBodies(null)).toEqual([]);
193
+ });
194
+ });
195
+
196
+ describe("default fields", () => {
197
+ it("asks for the dynamic creative assets", () => {
198
+ expect(DEFAULT_FIELDS.adcreative).toContain("asset_feed_spec");
199
+ expect(DEFAULT_FIELDS.adcreative).toContain("effective_object_story_id");
200
+ });
201
+ it("asks for both the headline and the primary text", () => {
202
+ expect(DEFAULT_FIELDS.adcreative).toContain("title");
203
+ expect(DEFAULT_FIELDS.adcreative).toContain("body");
204
+ });
205
+ });
206
+
207
+ describe("finding the media on a creative", () => {
208
+ it("reads the film of a video ad, with its still as the thumbnail", () => {
209
+ const media = creativeMedia({
210
+ video_id: "77",
211
+ object_story_spec: {
212
+ video_data: {
213
+ video_id: "77",
214
+ title: "Watch",
215
+ image_url: "https://example.com/still.jpg",
216
+ image_hash: "abc",
217
+ },
218
+ },
219
+ });
220
+ expect(media).toEqual([
221
+ {
222
+ kind: "video",
223
+ video_id: "77",
224
+ thumbnail_url: "https://example.com/still.jpg",
225
+ thumbnail_hash: "abc",
226
+ name: "Watch",
227
+ },
228
+ ]);
229
+ // the still of a film is not a picture of its own
230
+ expect(mediaType(media)).toBe("video");
231
+ });
232
+ it("reads the picture of a link ad", () => {
233
+ const media = creativeMedia({
234
+ object_story_spec: {
235
+ link_data: {
236
+ name: "Half price",
237
+ image_hash: "hash1",
238
+ picture: "https://example.com/pic.jpg",
239
+ link: "https://example.com",
240
+ },
241
+ },
242
+ });
243
+ expect(media).toEqual([
244
+ {
245
+ kind: "image",
246
+ url: "https://example.com/pic.jpg",
247
+ image_hash: "hash1",
248
+ name: "Half price",
249
+ link: "https://example.com",
250
+ },
251
+ ]);
252
+ expect(mediaType(media)).toBe("image");
253
+ });
254
+ it("reads a photo ad", () => {
255
+ expect(
256
+ creativeMedia({
257
+ object_story_spec: {
258
+ photo_data: { url: "https://example.com/photo.jpg", image_hash: "h" },
259
+ },
260
+ })
261
+ ).toEqual([
262
+ { kind: "image", url: "https://example.com/photo.jpg", image_hash: "h" },
263
+ ]);
264
+ });
265
+ it("returns one entry per carousel card, in order", () => {
266
+ const media = creativeMedia({
267
+ object_story_spec: {
268
+ link_data: {
269
+ image_hash: "cover",
270
+ child_attachments: [
271
+ { image_hash: "h1", name: "Shoes" },
272
+ { video_id: "v2", name: "Hats", picture: "https://x/2.jpg" },
273
+ ],
274
+ },
275
+ },
276
+ });
277
+ expect(media.map((m) => m.kind)).toEqual(["image", "video"]);
278
+ expect(media.map((m) => m.name)).toEqual(["Shoes", "Hats"]);
279
+ // a carousel with a film in it counts as a mixed ad
280
+ expect(mediaType(media)).toBe("mixed");
281
+ });
282
+ it("reads the assets of a dynamic creative, without repeats", () => {
283
+ const media = creativeMedia({
284
+ image_url: "https://example.com/a.jpg",
285
+ asset_feed_spec: {
286
+ images: [{ hash: "h1", url: "https://example.com/a.jpg" }],
287
+ videos: [{ video_id: "v1", thumbnail_url: "https://example.com/t.jpg" }],
288
+ },
289
+ });
290
+ expect(media).toHaveLength(2);
291
+ expect(mediaType(media)).toBe("mixed");
292
+ });
293
+ it("does not repeat the same picture reached by two routes", () => {
294
+ const media = creativeMedia({
295
+ image_hash: "h1",
296
+ image_url: "https://example.com/a.jpg",
297
+ object_story_spec: {
298
+ link_data: { image_hash: "h1", picture: "https://example.com/a.jpg" },
299
+ },
300
+ });
301
+ expect(media).toHaveLength(1);
302
+ });
303
+ it("comes back empty for an ad that promotes an existing post", () => {
304
+ expect(creativeMedia({ effective_object_story_id: "1_2" })).toEqual([]);
305
+ expect(creativeMedia(null)).toEqual([]);
306
+ expect(mediaType([])).toBe("unknown");
307
+ });
308
+ });
309
+
310
+ describe("default fields", () => {
311
+ it("asks what kind of ad each ad is", () => {
312
+ const creative = DEFAULT_FIELDS.ad.find((f) => f.startsWith("creative{"));
313
+ expect(creative).toContain("object_type");
314
+ expect(creative).toContain("video_id");
315
+ expect(creative).toContain("image_url");
316
+ });
317
+ });
@@ -0,0 +1,291 @@
1
+ jest.mock("node-fetch");
2
+ const fetch = require("node-fetch");
3
+ const { getAdMedia, getAdMediaType, getAdText } = require("../api");
4
+
5
+ const cfg = { access_token: "USERTOKEN", max_retries: 0 };
6
+
7
+ // Answer each call from a list of [what the address must contain, the reply],
8
+ // and keep what was asked so a test can check which token was used
9
+ let calls;
10
+ const serve = (routes) => {
11
+ calls = [];
12
+ fetch.mockImplementation(async (url, opts) => {
13
+ calls.push({ url, auth: opts?.headers?.Authorization });
14
+ const hit = routes.find(([match]) => url.includes(match));
15
+ return {
16
+ status: 200,
17
+ headers: { get: () => null },
18
+ text: async () => JSON.stringify(hit ? hit[1] : { error: { code: 100, message: `no route for ${url}` } }),
19
+ };
20
+ });
21
+ };
22
+
23
+ // An ad that boosts a post already on the page: the creative names the page
24
+ // and the post, and carries no media of its own
25
+ const boostedAd = {
26
+ id: "120242825926110547",
27
+ name: "Version AI Remake",
28
+ creative: {
29
+ id: "782942930901202",
30
+ object_type: "SHARE",
31
+ object_story_spec: { page_id: "106755536029753", instagram_user_id: "17841401937168196" },
32
+ effective_object_story_id: "106755536029753_1333493868811467",
33
+ },
34
+ };
35
+
36
+ const videoPost = {
37
+ permalink_url: "https://facebook.com/post",
38
+ full_picture: "https://example.com/full.jpg",
39
+ attachments: {
40
+ data: [
41
+ {
42
+ media_type: "video",
43
+ media: { image: { src: "https://example.com/still.jpg" } },
44
+ target: { id: "999" },
45
+ },
46
+ ],
47
+ },
48
+ };
49
+
50
+ describe("an ad that boosts a page post", () => {
51
+ it("reads the post with a token for the page", async () => {
52
+ serve([
53
+ ["/120242825926110547", { account_id: "1", creative: { ...boostedAd.creative, thumbnail_url: "https://example.com/thumb.jpg" } }],
54
+ ["/106755536029753?", { access_token: "PAGETOKEN" }],
55
+ ["/106755536029753_1333493868811467", videoPost],
56
+ ["/999", { id: "999", source: "https://video.fb/film.mp4", picture: "https://example.com/still.jpg", length: 15 }],
57
+ ]);
58
+ const res = await getAdMedia(boostedAd, cfg);
59
+ expect(res.type).toBe("video");
60
+ expect(res.from_post).toBe(true);
61
+ expect(res.story_id).toBe("106755536029753_1333493868811467");
62
+ expect(res.media).toHaveLength(1);
63
+ expect(res.media[0].url).toBe("https://video.fb/film.mp4");
64
+ expect(res.media[0].thumbnail_url).toBe("https://example.com/still.jpg");
65
+ expect(res.error).toBeUndefined();
66
+ // the post is read as the page, not as the user
67
+ const post = calls.find((c) => c.url.includes("106755536029753_1333"));
68
+ expect(post.auth).toBe("Bearer PAGETOKEN");
69
+ });
70
+
71
+ it("says why when the token has no say over the page", async () => {
72
+ const ad = {
73
+ ...boostedAd,
74
+ creative: {
75
+ ...boostedAd.creative,
76
+ object_story_spec: { page_id: "222" },
77
+ effective_object_story_id: "222_333",
78
+ },
79
+ };
80
+ serve([
81
+ ["/120242825926110547", { account_id: "1", creative: { ...ad.creative, thumbnail_url: "https://example.com/thumb.jpg" } }],
82
+ ["/222?", { error: { code: 190, message: "no page access" } }],
83
+ ["/222_333", { error: { code: 100, message: "Unsupported get request" } }],
84
+ ]);
85
+ const res = await getAdMedia(ad, cfg);
86
+ expect(res.type).toBe("unknown");
87
+ expect(res.media).toEqual([]);
88
+ // not silence: the reason, and something to look at
89
+ expect(res.error).toMatch(/could not read the post 222_333/);
90
+ expect(res.error).toMatch(/pages_read_engagement/);
91
+ expect(res.thumbnail_url).toBe("https://example.com/thumb.jpg");
92
+ });
93
+ });
94
+
95
+ describe("reading the ad again", () => {
96
+ it("asks for every media field when handed a creative with none", async () => {
97
+ serve([
98
+ ["/120242825926110547", { account_id: "1", creative: { id: "782942930901202", video_id: "555", thumbnail_url: "https://example.com/t.jpg" } }],
99
+ ["/555", { id: "555", source: "https://video.fb/film.mp4" }],
100
+ ]);
101
+ // the caller asked Meta for a narrow set of fields, so image_hash,
102
+ // video_id and asset_feed_spec were never in the ad we were given
103
+ const res = await getAdMedia(boostedAd, cfg);
104
+ expect(res.type).toBe("video");
105
+ expect(res.media[0].url).toBe("https://video.fb/film.mp4");
106
+ const asked = calls[0].url;
107
+ expect(asked).toContain("video_id");
108
+ expect(asked).toContain("asset_feed_spec");
109
+ expect(asked).toContain("image_hash");
110
+ });
111
+
112
+ it("does not read the ad again when the creative already has media", async () => {
113
+ serve([["/act_", { data: [] }]]);
114
+ const ad = {
115
+ id: "1",
116
+ account_id: "9",
117
+ creative: {
118
+ id: "2",
119
+ object_story_spec: {
120
+ link_data: { image_hash: "h", picture: "https://example.com/p.jpg" },
121
+ },
122
+ },
123
+ };
124
+ const res = await getAdMedia(ad, cfg, { resolve_urls: false });
125
+ expect(res.type).toBe("image");
126
+ expect(calls).toHaveLength(0);
127
+ });
128
+
129
+ it("works out the type without looking up every address", async () => {
130
+ serve([
131
+ ["/1?", { account_id: "9", creative: { id: "2", video_id: "555" } }],
132
+ ]);
133
+ expect(await getAdMediaType({ id: "1" }, cfg)).toBe("video");
134
+ // one read for the ad, and no read of the video
135
+ expect(calls).toHaveLength(1);
136
+ });
137
+ });
138
+
139
+ describe("what counts as a carousel", () => {
140
+ it("a post with several pictures in it does", async () => {
141
+ serve([
142
+ ["/1?", { account_id: "9", creative: { id: "2", object_story_spec: { page_id: "444" }, effective_object_story_id: "444_555" } }],
143
+ ["/444?", { access_token: "PAGETOKEN" }],
144
+ ["/444_555", {
145
+ attachments: {
146
+ data: [
147
+ {
148
+ media_type: "album",
149
+ subattachments: {
150
+ data: [
151
+ { media_type: "photo", media: { image: { src: "https://example.com/1.jpg" } } },
152
+ { media_type: "photo", media: { image: { src: "https://example.com/2.jpg" } } },
153
+ ],
154
+ },
155
+ },
156
+ ],
157
+ },
158
+ }],
159
+ ]);
160
+ const res = await getAdMedia({ id: "1" }, cfg);
161
+ expect(res.type).toBe("image");
162
+ expect(res.carousel).toBe(true);
163
+ expect(res.media).toHaveLength(2);
164
+ });
165
+
166
+ it("a dynamic creative offering Meta a choice does not", async () => {
167
+ serve([["/act_", { data: [] }]]);
168
+ const ad = {
169
+ id: "1",
170
+ account_id: "9",
171
+ creative: {
172
+ id: "2",
173
+ asset_feed_spec: {
174
+ images: [
175
+ { hash: "h1", url: "https://example.com/1.jpg" },
176
+ { hash: "h2", url: "https://example.com/2.jpg" },
177
+ ],
178
+ videos: [{ video_id: "v1" }],
179
+ },
180
+ },
181
+ };
182
+ const res = await getAdMedia(ad, cfg, { resolve_urls: false });
183
+ expect(res.carousel).toBe(false);
184
+ expect(res.type).toBe("mixed");
185
+ expect(res.media).toHaveLength(3);
186
+ });
187
+ });
188
+
189
+ describe("the wording of an ad that boosts a post", () => {
190
+ it("reads the post with a token for the page", async () => {
191
+ serve([
192
+ ["/7?", { creative: { id: "2", object_story_spec: { page_id: "888" }, effective_object_story_id: "888_999" } }],
193
+ ["/888?", { access_token: "PAGETOKEN" }],
194
+ ["/888_999", { message: "The primary text", attachments: { data: [{ title: "The headline" }] } }],
195
+ ]);
196
+ expect(await getAdText("7", cfg)).toEqual({
197
+ headline: "The headline",
198
+ body: "The primary text",
199
+ });
200
+ const post = calls.find((c) => c.url.includes("888_999"));
201
+ expect(post.auth).toBe("Bearer PAGETOKEN");
202
+ });
203
+ });
204
+
205
+ // A flexible creative names its pictures by hash only. The address of each
206
+ // one is held by the ad account's picture library.
207
+ const hashAd = {
208
+ id: "120254266274370547",
209
+ name: "3_STJ16890_FB_Dental",
210
+ creative: {
211
+ id: "1700780627695097",
212
+ object_type: "SHARE",
213
+ object_story_spec: { page_id: "106755536029753" },
214
+ effective_object_story_id: "106755536029753_1515321970628655",
215
+ },
216
+ };
217
+
218
+ const hashCreative = {
219
+ ...hashAd.creative,
220
+ asset_feed_spec: {
221
+ images: [{ hash: "aaa" }, { hash: "bbb" }],
222
+ },
223
+ };
224
+
225
+ describe("pictures named by hash", () => {
226
+ it("asks the picture library as a list, which is how Meta wants hashes", async () => {
227
+ serve([
228
+ ["/120254266274370547", { account_id: "998", creative: hashCreative }],
229
+ ["/adimages", {
230
+ data: [
231
+ { hash: "aaa", url: "https://example.com/a.jpg", width: 1080, height: 1080 },
232
+ { hash: "bbb", url: "https://example.com/b.jpg", width: 1080, height: 1080 },
233
+ ],
234
+ }],
235
+ ]);
236
+ const res = await getAdMedia(hashAd, cfg);
237
+ expect(res.type).toBe("image");
238
+ expect(res.media.map((m) => m.url)).toEqual([
239
+ "https://example.com/a.jpg",
240
+ "https://example.com/b.jpg",
241
+ ]);
242
+ expect(res.media[0].width).toBe(1080);
243
+ expect(res.error).toBeUndefined();
244
+ const lookup = calls.find((c) => c.url.includes("adimages"));
245
+ expect(lookup.url).toContain("act_998");
246
+ // a JSON list, not the comma separated kind Meta takes for fields
247
+ expect(decodeURIComponent(lookup.url)).toContain('hashes=["aaa","bbb"]');
248
+ });
249
+
250
+ it("falls back to the post when the ad account does not hold them", async () => {
251
+ serve([
252
+ ["/120254266274370547", { account_id: "998", creative: hashCreative }],
253
+ ["/adimages", { data: [] }],
254
+ ["/106755536029753?", { access_token: "PAGETOKEN" }],
255
+ ["/106755536029753_1515321970628655", {
256
+ attachments: {
257
+ data: [{ media_type: "photo", media: { image: { src: "https://example.com/post.jpg" } } }],
258
+ },
259
+ }],
260
+ ]);
261
+ const res = await getAdMedia(hashAd, cfg);
262
+ expect(res.type).toBe("image");
263
+ expect(res.from_post).toBe(true);
264
+ expect(res.media).toEqual([{ kind: "image", url: "https://example.com/post.jpg" }]);
265
+ expect(res.error).toBeUndefined();
266
+ });
267
+
268
+ it("says why when neither the library nor the post has them", async () => {
269
+ serve([
270
+ ["/120254266274370547", { account_id: "998", creative: hashCreative }],
271
+ ["/adimages", { error: { code: 100, message: "Invalid hashes" } }],
272
+ ["/106755536029753?", { error: { code: 190, message: "no page access" } }],
273
+ ["/106755536029753_1515321970628655", { error: { code: 100, message: "Unsupported get request" } }],
274
+ ]);
275
+ const res = await getAdMedia(hashAd, cfg);
276
+ expect(res.type).toBe("image");
277
+ expect(res.media.every((m) => !m.url)).toBe(true);
278
+ expect(res.error).toMatch(/2 of 2 could not be reached/);
279
+ expect(res.error).toMatch(/could not read the pictures of ad account act_998/);
280
+ });
281
+
282
+ it("says so when there is no ad account to ask", async () => {
283
+ serve([]);
284
+ const ad = {
285
+ id: "1",
286
+ creative: { id: "2", asset_feed_spec: { images: [{ hash: "aaa" }] } },
287
+ };
288
+ const res = await getAdMedia(ad, {});
289
+ expect(res.error).toMatch(/held by the ad account/);
290
+ });
291
+ });