@saltcorn/meta-marketing-api 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 +21 -0
- package/README.md +180 -0
- package/api.js +561 -0
- package/common.js +449 -0
- package/index.js +516 -0
- package/package.json +36 -0
- package/sync-action.js +337 -0
- package/table-provider.js +351 -0
- package/tests/api.test.js +94 -0
- package/tests/common.test.js +186 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const {
|
|
2
|
+
queryValue,
|
|
3
|
+
toQueryString,
|
|
4
|
+
actId,
|
|
5
|
+
appSecretProof,
|
|
6
|
+
isTransientError,
|
|
7
|
+
insightsFields,
|
|
8
|
+
DEFAULT_FIELDS,
|
|
9
|
+
} = require("../api");
|
|
10
|
+
|
|
11
|
+
describe("query string encoding", () => {
|
|
12
|
+
it("joins lists of scalars with commas", () => {
|
|
13
|
+
expect(queryValue(["id", "name"])).toBe("id,name");
|
|
14
|
+
expect(queryValue([1, 2])).toBe("1,2");
|
|
15
|
+
});
|
|
16
|
+
it("sends structured values as JSON", () => {
|
|
17
|
+
expect(queryValue({ since: "2026-01-01", until: "2026-01-31" })).toBe(
|
|
18
|
+
'{"since":"2026-01-01","until":"2026-01-31"}'
|
|
19
|
+
);
|
|
20
|
+
expect(queryValue([{ field: "spend", operator: "GREATER_THAN" }])).toBe(
|
|
21
|
+
'[{"field":"spend","operator":"GREATER_THAN"}]'
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
it("encodes booleans the way Meta expects", () => {
|
|
25
|
+
expect(queryValue(true)).toBe("true");
|
|
26
|
+
expect(queryValue(false)).toBe("false");
|
|
27
|
+
});
|
|
28
|
+
it("drops empty values", () => {
|
|
29
|
+
expect(toQueryString({ a: 1, b: null, c: undefined, d: "" })).toBe("a=1");
|
|
30
|
+
});
|
|
31
|
+
it("url encodes keys and values", () => {
|
|
32
|
+
expect(toQueryString({ fields: ["id", "creative{id,name}"] })).toBe(
|
|
33
|
+
"fields=id%2Ccreative%7Bid%2Cname%7D"
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("ad account ids", () => {
|
|
39
|
+
it("adds the act_ prefix when missing", () => {
|
|
40
|
+
expect(actId("12345")).toBe("act_12345");
|
|
41
|
+
expect(actId(12345)).toBe("act_12345");
|
|
42
|
+
});
|
|
43
|
+
it("leaves an already prefixed id alone", () => {
|
|
44
|
+
expect(actId("act_12345")).toBe("act_12345");
|
|
45
|
+
expect(actId(" act_12345 ")).toBe("act_12345");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("app secret proof", () => {
|
|
50
|
+
it("is the hex hmac of the token with the app secret", () => {
|
|
51
|
+
// computed with: echo -n TOKEN | openssl dgst -sha256 -hmac SECRET
|
|
52
|
+
expect(appSecretProof("TOKEN", "SECRET")).toBe(
|
|
53
|
+
require("crypto")
|
|
54
|
+
.createHmac("sha256", "SECRET")
|
|
55
|
+
.update("TOKEN")
|
|
56
|
+
.digest("hex")
|
|
57
|
+
);
|
|
58
|
+
expect(appSecretProof("TOKEN", "SECRET")).toHaveLength(64);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("transient errors", () => {
|
|
63
|
+
it("retries rate limits and temporary failures", () => {
|
|
64
|
+
expect(isTransientError({ code: 17 })).toBe(true);
|
|
65
|
+
expect(isTransientError({ code: 4 })).toBe(true);
|
|
66
|
+
expect(isTransientError({ code: 80004 })).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
it("does not retry permission or request errors", () => {
|
|
69
|
+
expect(isTransientError({ code: 100 })).toBe(false);
|
|
70
|
+
expect(isTransientError({ code: 190 })).toBe(false);
|
|
71
|
+
expect(isTransientError({ code: 200 })).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("insights fields", () => {
|
|
76
|
+
it("adds the dimensions of the requested level", () => {
|
|
77
|
+
expect(insightsFields("campaign")).toContain("campaign_name");
|
|
78
|
+
expect(insightsFields("campaign")).toContain("spend");
|
|
79
|
+
expect(insightsFields("campaign")).not.toContain("ad_name");
|
|
80
|
+
expect(insightsFields("ad")).toContain("ad_name");
|
|
81
|
+
});
|
|
82
|
+
it("returns only level independent metrics with no level", () => {
|
|
83
|
+
expect(insightsFields()).not.toContain("campaign_name");
|
|
84
|
+
expect(insightsFields()).toContain("impressions");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("default fields", () => {
|
|
89
|
+
it("always asks for the object id", () => {
|
|
90
|
+
Object.values(DEFAULT_FIELDS).forEach((fields) => {
|
|
91
|
+
expect(fields).toContain("id");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
const {
|
|
2
|
+
flattenRow,
|
|
3
|
+
guessType,
|
|
4
|
+
coerceValue,
|
|
5
|
+
coerceRow,
|
|
6
|
+
buildQuery,
|
|
7
|
+
insightsRowId,
|
|
8
|
+
rowMatches,
|
|
9
|
+
applyWhere,
|
|
10
|
+
pushdownParents,
|
|
11
|
+
fieldsFor,
|
|
12
|
+
} = require("../common");
|
|
13
|
+
|
|
14
|
+
describe("flattening Graph API rows", () => {
|
|
15
|
+
it("flattens nested objects into underscored names", () => {
|
|
16
|
+
const flat = flattenRow({
|
|
17
|
+
id: "1",
|
|
18
|
+
name: "An ad",
|
|
19
|
+
creative: { id: "2", thumbnail_url: "http://x/y.png" },
|
|
20
|
+
});
|
|
21
|
+
expect(flat).toEqual({
|
|
22
|
+
id: "1",
|
|
23
|
+
name: "An ad",
|
|
24
|
+
creative_id: "2",
|
|
25
|
+
creative_thumbnail_url: "http://x/y.png",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
it("keeps arrays whole", () => {
|
|
29
|
+
const flat = flattenRow({
|
|
30
|
+
id: "1",
|
|
31
|
+
actions: [{ action_type: "link_click", value: "5" }],
|
|
32
|
+
});
|
|
33
|
+
expect(flat.actions).toEqual([{ action_type: "link_click", value: "5" }]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("column type guessing", () => {
|
|
38
|
+
it("treats budgets and spend as whole numbers of cents", () => {
|
|
39
|
+
expect(guessType("daily_budget", "5000")).toBe("Integer");
|
|
40
|
+
expect(guessType("budget_remaining", "120")).toBe("Integer");
|
|
41
|
+
expect(guessType("amount_spent", "9")).toBe("Integer");
|
|
42
|
+
});
|
|
43
|
+
it("treats insights metrics as numbers", () => {
|
|
44
|
+
expect(guessType("spend", "12.34")).toBe("Float");
|
|
45
|
+
expect(guessType("ctr", "0.9")).toBe("Float");
|
|
46
|
+
expect(guessType("impressions", "1000")).toBe("Integer");
|
|
47
|
+
});
|
|
48
|
+
it("recognises dates, objects and plain text", () => {
|
|
49
|
+
expect(guessType("created_time", "2026-01-01T09:00:00+0000")).toBe("Date");
|
|
50
|
+
expect(guessType("date_start", "2026-01-01")).toBe("Date");
|
|
51
|
+
expect(guessType("actions", [])).toBe("JSON");
|
|
52
|
+
expect(guessType("name", "Summer sale")).toBe("String");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("coercion to column types", () => {
|
|
57
|
+
it("converts the strings Meta returns", () => {
|
|
58
|
+
expect(coerceValue("1000", "Integer")).toBe(1000);
|
|
59
|
+
expect(coerceValue("12.34", "Float")).toBe(12.34);
|
|
60
|
+
expect(coerceValue("true", "Bool")).toBe(true);
|
|
61
|
+
expect(coerceValue("2026-01-01", "Date")).toEqual(new Date("2026-01-01"));
|
|
62
|
+
});
|
|
63
|
+
it("returns null rather than NaN for missing numbers", () => {
|
|
64
|
+
expect(coerceValue(undefined, "Integer")).toBe(null);
|
|
65
|
+
expect(coerceValue("", "Float")).toBe(null);
|
|
66
|
+
});
|
|
67
|
+
it("keeps only the configured columns", () => {
|
|
68
|
+
const row = coerceRow(
|
|
69
|
+
{ id: "1", spend: "3.5", extra: "ignore me" },
|
|
70
|
+
[
|
|
71
|
+
{ name: "id", type: "String" },
|
|
72
|
+
{ name: "spend", type: "Float" },
|
|
73
|
+
]
|
|
74
|
+
);
|
|
75
|
+
expect(row).toEqual({ id: "1", spend: 3.5 });
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("building the API query", () => {
|
|
80
|
+
it("uses the default fields of the object type", () => {
|
|
81
|
+
const q = buildQuery("Campaigns", {});
|
|
82
|
+
expect(q.fields).toContain("objective");
|
|
83
|
+
});
|
|
84
|
+
it("sends statuses as a JSON array", () => {
|
|
85
|
+
const q = buildQuery("Ads", { effective_status: "ACTIVE, PAUSED" });
|
|
86
|
+
expect(q.effective_status).toBe('["ACTIVE","PAUSED"]');
|
|
87
|
+
});
|
|
88
|
+
it("prefers an explicit date range over the preset", () => {
|
|
89
|
+
const q = buildQuery("Insights", {
|
|
90
|
+
date_preset: "last_7d",
|
|
91
|
+
since: "2026-01-01",
|
|
92
|
+
until: "2026-01-31",
|
|
93
|
+
});
|
|
94
|
+
expect(q.time_range).toEqual({
|
|
95
|
+
since: "2026-01-01",
|
|
96
|
+
until: "2026-01-31",
|
|
97
|
+
});
|
|
98
|
+
expect(q.date_preset).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
it("takes the level into account when choosing fields", () => {
|
|
101
|
+
expect(buildQuery("Insights", { level: "adset" }).fields).toContain(
|
|
102
|
+
"adset_name"
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
it("splits comma separated settings", () => {
|
|
106
|
+
const q = buildQuery("Insights", { breakdowns: "age, gender" });
|
|
107
|
+
expect(q.breakdowns).toEqual(["age", "gender"]);
|
|
108
|
+
});
|
|
109
|
+
it("accepts explicit fields", () => {
|
|
110
|
+
expect(fieldsFor("Campaigns", { fields: "id,name" })).toEqual([
|
|
111
|
+
"id",
|
|
112
|
+
"name",
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("insights row ids", () => {
|
|
118
|
+
it("is stable for the same dimensions", () => {
|
|
119
|
+
const row = { date_start: "2026-01-01", campaign_id: "7" };
|
|
120
|
+
expect(insightsRowId(row)).toBe(insightsRowId({ ...row }));
|
|
121
|
+
});
|
|
122
|
+
it("differs when a dimension differs", () => {
|
|
123
|
+
expect(insightsRowId({ campaign_id: "7" })).not.toBe(
|
|
124
|
+
insightsRowId({ campaign_id: "8" })
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
it("takes breakdowns into account", () => {
|
|
128
|
+
const a = { campaign_id: "7", gender: "male" };
|
|
129
|
+
const b = { campaign_id: "7", gender: "female" };
|
|
130
|
+
expect(insightsRowId(a, ["gender"])).not.toBe(insightsRowId(b, ["gender"]));
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("in memory filtering", () => {
|
|
135
|
+
const rows = [
|
|
136
|
+
{ id: "1", name: "Summer", spend: 10, status: "ACTIVE" },
|
|
137
|
+
{ id: "2", name: "Winter", spend: 30, status: "PAUSED" },
|
|
138
|
+
{ id: "3", name: "Spring sale", spend: 20, status: "ACTIVE" },
|
|
139
|
+
];
|
|
140
|
+
it("matches on equality, whatever the type", () => {
|
|
141
|
+
expect(rowMatches(rows[0], { status: "ACTIVE" })).toBe(true);
|
|
142
|
+
expect(rowMatches(rows[0], { id: 1 })).toBe(true);
|
|
143
|
+
expect(rowMatches(rows[0], { status: "PAUSED" })).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
it("supports the search and comparison operators views use", () => {
|
|
146
|
+
expect(rowMatches(rows[2], { name: { ilike: "sale" } })).toBe(true);
|
|
147
|
+
expect(rowMatches(rows[0], { spend: { gt: 5 } })).toBe(true);
|
|
148
|
+
expect(rowMatches(rows[0], { spend: { lt: 10, equal: true } })).toBe(true);
|
|
149
|
+
expect(rowMatches(rows[0], { id: { in: ["1", "2"] } })).toBe(true);
|
|
150
|
+
expect(rowMatches(rows[0], { or: [{ id: "9" }, { id: "1" }] })).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
it("does not hide rows on operators it cannot apply", () => {
|
|
153
|
+
expect(rowMatches(rows[0], { spend: { inSelect: {} } })).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
it("sorts, offsets and limits", () => {
|
|
156
|
+
expect(
|
|
157
|
+
applyWhere(rows, {}, { orderBy: "spend", orderDesc: true }).map(
|
|
158
|
+
(r) => r.id
|
|
159
|
+
)
|
|
160
|
+
).toEqual(["2", "3", "1"]);
|
|
161
|
+
expect(applyWhere(rows, { limit: 2 }).map((r) => r.id)).toEqual(["1", "2"]);
|
|
162
|
+
expect(applyWhere(rows, { offset: 2 }).map((r) => r.id)).toEqual(["3"]);
|
|
163
|
+
expect(applyWhere(rows, { status: "ACTIVE" }).map((r) => r.id)).toEqual([
|
|
164
|
+
"1",
|
|
165
|
+
"3",
|
|
166
|
+
]);
|
|
167
|
+
});
|
|
168
|
+
it("ignores the paging keys when matching", () => {
|
|
169
|
+
expect(rowMatches(rows[0], { limit: 10, offset: 0 })).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("pushing filters down to a narrower endpoint", () => {
|
|
174
|
+
it("uses the campaign or ad set edge when the id is known", () => {
|
|
175
|
+
expect(pushdownParents("Ads", { campaign_id: "7" })).toEqual({
|
|
176
|
+
campaign_id: "7",
|
|
177
|
+
});
|
|
178
|
+
expect(pushdownParents("Ad sets", { campaign_id: "7" })).toEqual({
|
|
179
|
+
campaign_id: "7",
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
it("ignores ids that are not a plain value", () => {
|
|
183
|
+
expect(pushdownParents("Ads", { campaign_id: { ilike: "7" } })).toEqual({});
|
|
184
|
+
expect(pushdownParents("Campaigns", { campaign_id: "7" })).toEqual({});
|
|
185
|
+
});
|
|
186
|
+
});
|