@admoai/sdk-scenarios 1.0.0 → 1.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/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@ Versioning: a scenario added or an `expect` tightened is a **minor** release; a
4
4
  removed, or a `request`/`expect` key renamed (every SDK's interpreter must change), is a
5
5
  **major** one. Each SDK vendors one version and its own CI checks the vendored bytes against it.
6
6
 
7
+ ## 1.1.0 — 2026-10-21
8
+
9
+ Also ships `scenarios.feature`: the same 39 scenarios rendered as Gherkin for people to read
10
+ (admoai/adhub#4348). It is generated from `scenarios.json` by `tools/sdkfeaturegen` in adhub and
11
+ is never edited by hand; the SDK runners keep executing the JSON, which is byte-identical to
12
+ 1.0.0's.
13
+
7
14
  ## 1.0.0 — 2026-10-20
8
15
 
9
16
  The 39 scenarios the three SDKs already carried, byte for byte (sha256 `a55f68eb…`):
package/README.md CHANGED
@@ -4,9 +4,11 @@ The scenarios every AdMoai mobile SDK (iOS, Android, Flutter) must pass against
4
4
  decision-engine: given a request, the outcome a publisher's app observes. One definition,
5
5
  executed by a thin interpreter in each SDK, so the three cannot make different claims.
6
6
 
7
- This package is **data only**: `scenarios.json` (the manifest, `schemaVersion` inside). It
8
- carries no URLs, hosts, credentials or tenant identifiers — `check.sh` refuses a release that
9
- does.
7
+ This package is **data only**: `scenarios.json` (the manifest, `schemaVersion` inside) and
8
+ `scenarios.feature`, the same scenarios rendered as Gherkin for a PM or an integrating
9
+ publisher to read. The JSON is what the SDK runners execute; the feature is generated from it.
10
+ Neither carries URLs, hosts, credentials or tenant identifiers — `check.sh` refuses a release
11
+ whose manifest does.
10
12
 
11
13
  ## For an SDK
12
14
 
@@ -21,7 +23,8 @@ curl -sSfL "https://registry.npmjs.org/@admoai/sdk-scenarios/-/sdk-scenarios-$VE
21
23
  ## For adhub (the source)
22
24
 
23
25
  The source of truth is `sdk/scenarios/` in admoai/adhub. To change a scenario: edit
24
- `scenarios.json`, bump `version` in `package.json` (minor: added or tightened; major: removed
26
+ `scenarios.json`, regenerate the Gherkin with `go run ./tools/sdkfeaturegen` from the
27
+ repository root, bump `version` in `package.json` (minor: added or tightened; major: removed
25
28
  or a key renamed), add a `CHANGELOG.md` entry, and run `sdk/scenarios/check.sh`. Merging to
26
29
  `main` publishes the new version (`.github/workflows/sdk-scenarios-release.yml`); each SDK then
27
30
  moves to it in its own PR. See `docs/guides/sdk-ci.md`.
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@admoai/sdk-scenarios",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "The cross-SDK decision & tracking scenarios every AdMoai SDK asserts against a real decision-engine. Data only.",
5
5
  "license": "MIT",
6
6
  "files": [
7
7
  "scenarios.json",
8
+ "scenarios.feature",
8
9
  "CHANGELOG.md",
9
10
  "README.md"
10
11
  ],
@@ -0,0 +1,371 @@
1
+ # GENERATED from scenarios.json — do not edit; regenerate with: go run ./tools/sdkfeaturegen
2
+ #
3
+ # scenarios.json is what the iOS, Android and Flutter manifest runners execute against a
4
+ # real decision-engine. This is its readable rendering: what every AdMoai SDK promises a
5
+ # publisher's app, in the same words for all three. CI regenerates it and fails on any
6
+ # difference, so it cannot drift from what the runners execute.
7
+ # How it is published and kept fresh: docs/guides/sdk-ci.md, Section 7.
8
+ # Manifest schemaVersion: 1
9
+
10
+ Feature: Cross-SDK decision & tracking contract
11
+ Shared cross-SDK E2E scenario manifest. ONE definition, executed by a thin interpreter in each
12
+ SDK, so the three suites cannot drift. Declarative only: given a request, expect an outcome.
13
+ Sponsored Pin tracking scenarios add `pinTracking`: the point-tracking calls an app makes on
14
+ the served pins, as data, and the beacons that must reach the engine. Procedural scenarios
15
+ (log capture, concurrency, retry, TTL waits) stay hand-written per SDK. Mirrored
16
+ byte-identically into all three repos; a checksum test fails the build if the copies diverge.
17
+ Every expected value here was verified against a live local decision-engine before being
18
+ written.
19
+
20
+ @L @native
21
+ Scenario: L1 — a normal native ad serves with complete, well-formed metadata
22
+ Given the app requests placement "home"
23
+ Then an ad is served
24
+ And exactly 1 decision is returned
25
+ And the creative carries complete metadata: ad, creative, placement and template ids
26
+ And the creative's priority is one of standard, sponsorship, house
27
+ And the creative is not a Journey Ad
28
+ And the creative's format is "native"
29
+
30
+ @L @native
31
+ Scenario: L5 — a placement with no eligible ad returns a clean no-ad, not an error
32
+ Given the app requests placement "postroll"
33
+ Then no ad is served, and the placement answers with a clean no-fill
34
+ And exactly 1 decision is returned
35
+
36
+ @L @native
37
+ Scenario: L6 — the advertiser block is complete (name, legalName, logoUrl)
38
+ Given the app requests placement "home"
39
+ Then an ad is served
40
+ And the creative names its advertiser: name, legal name and logo
41
+
42
+ @L @native
43
+ Scenario: L7 — the template block carries a non-empty key
44
+ Given the app requests placement "home"
45
+ Then an ad is served
46
+ And the creative names its template
47
+
48
+ @L @native
49
+ Scenario: L8 — a served native ad exposes an impression URL, a click URL and non-empty contents
50
+ Given the app requests placement "home"
51
+ Then an ad is served
52
+ And the creative carries its content fields
53
+ And the creative exposes an impression URL
54
+ And the creative exposes at least 1 click URL
55
+
56
+ @M
57
+ Scenario: M1 — count=2 returns at most two creatives, and they are distinct
58
+ Given the app requests placement "promotions"
59
+ And placement "promotions" asks for 2 creatives
60
+ Then an ad is served
61
+ And exactly 1 decision is returned
62
+ And at most 2 creatives are returned
63
+ And the returned creatives are all different
64
+
65
+ @M
66
+ Scenario: M2 — three placements in one request each get their own correctly-keyed decision
67
+ Given the app requests placement "home"
68
+ And the app requests placement "promotions"
69
+ And the app requests placement "search"
70
+ Then an ad is served
71
+ And exactly 3 decisions are returned
72
+ And every requested placement comes back with its own decision
73
+
74
+ @M
75
+ Scenario: M3 — an advertiserId filter still serves that advertiser's ad
76
+ Given the app requests placement "promotions"
77
+ And placement "promotions" is restricted to advertiser "advertiser_01JEPGMJXARVYSJA0CAM9FQ79Y"
78
+ Then an ad is served
79
+ And the creative names its advertiser: name, legal name and logo
80
+
81
+ @M
82
+ Scenario: M5 — format=native on a mixed placement returns a native ad
83
+ Given the app requests placement "promotions"
84
+ And placement "promotions" asks for format "native"
85
+ Then an ad is served
86
+ And the creative's format is "native"
87
+
88
+ @M
89
+ Scenario: M6 — format=video on the same mixed placement returns a video ad instead
90
+ Given the app requests placement "promotions"
91
+ And placement "promotions" asks for format "video"
92
+ Then an ad is served
93
+ And the creative's format is "video"
94
+
95
+ @M
96
+ Scenario: M7 — omitting format still serves — the engine chooses
97
+ Given the app requests placement "promotions"
98
+ Then an ad is served
99
+
100
+ @M
101
+ Scenario: M8 — a journey-owned placement and a normal placement resolve independently in ONE request
102
+ Given the app requests placement "promotions"
103
+ And the app requests placement "home"
104
+ And the request carries a fresh session id
105
+ And the user has opted in to Journey Ads
106
+ Then an ad is served
107
+ And exactly 2 decisions are returned
108
+
109
+ @P @video
110
+ Scenario: P1 — a non-skippable JSON video exposes json delivery and five video events (no skip)
111
+ Given the app requests placement "json_none"
112
+ Then an ad is served
113
+ And the creative's format is "video"
114
+ And the creative is delivered as "json"
115
+ And the creative exposes exactly 5 video event URLs
116
+ And the video events include start, first_quartile, midpoint, third_quartile, complete
117
+
118
+ @P @video
119
+ Scenario: P2 — a vast_tag ad exposes its tag URL and surfaces NO engine-side beacons (no double count)
120
+ Given the app requests placement "vasttag_none"
121
+ Then an ad is served
122
+ And the creative is delivered as "vast_tag"
123
+ And the creative exposes no engine impression URL, because the VAST owns the impression
124
+ And the creative exposes exactly 0 video event URLs
125
+ And the creative exposes a VAST tag URL
126
+
127
+ @P @video
128
+ Scenario: P3 — a vast_xml ad exposes base64 that decodes to a VAST document, and no beacons
129
+ Given the app requests placement "vastxml_none"
130
+ Then an ad is served
131
+ And the creative is delivered as "vast_xml"
132
+ And the creative exposes no engine impression URL, because the VAST owns the impression
133
+ And the creative exposes exactly 0 video event URLs
134
+ And the creative's inline VAST XML decodes to a VAST document
135
+
136
+ @P @video
137
+ Scenario: P6 — a skippable video resolves skippability from ENGINE METADATA, plus a sixth skip event
138
+ Given the app requests placement "json_none_skippable"
139
+ Then an ad is served
140
+ And the creative exposes exactly 6 video event URLs
141
+ And the metadata says the video is skippable
142
+ And the metadata's skip offset is 5 seconds
143
+ And the SDK's skippable helper says the video is skippable
144
+ And the SDK's skip-offset helper returns "5"
145
+
146
+ @P @video
147
+ Scenario: P8 — an end-card video reports its configured endCardMode
148
+ Given the app requests placement "json_native_endcard"
149
+ Then an ad is served
150
+ And the end-card mode is "native_endcard"
151
+
152
+ @V @validation
153
+ Scenario: V1 — an unknown placement key raises a typed validation error, not a silent no-fill
154
+ Given the app requests placement "definitely_not_a_real_placement"
155
+ Then the request fails
156
+ And the SDK surfaces a typed validation error with HTTP status 422
157
+ And the engine's error code is 10017
158
+
159
+ @V @validation
160
+ Scenario: V3 — an unknown custom-targeting key is rejected by the engine
161
+ Given the app requests placement "home"
162
+ And the custom target "not_a_configured_preset" is "x"
163
+ Then the request fails
164
+ And the SDK surfaces a typed validation error with HTTP status 422
165
+ And the engine's error code is 10028
166
+
167
+ @V @validation
168
+ Scenario: V4 — the SDK deduplicates a repeated custom key, so the engine never sees the duplicate that would fail the whole request
169
+ Sent raw over HTTP this same payload returns 422 code 10031, and the engine aborts
170
+ validation for the ENTIRE request — every placement, not just the offending target. All
171
+ three SDKs dedupe by key (last write wins), so a publisher cannot reach that state through
172
+ the public API. This scenario is the live proof of that guard: it must SERVE, and both
173
+ placements must come back.
174
+
175
+ Given the app requests placement "home"
176
+ And the app requests placement "promotions"
177
+ And the custom target "category" is "sports"
178
+ And the custom target "category" is "news"
179
+ Then an ad is served
180
+ And exactly 2 decisions are returned
181
+ And every requested placement comes back with its own decision
182
+
183
+ @V @validation
184
+ Scenario: V5 — a single valid custom target does not break serving
185
+ Given the app requests placement "home"
186
+ And the custom target "category" is "sports"
187
+ Then an ad is served
188
+
189
+ @V @validation
190
+ Scenario: V6 — a count above the documented maximum is rejected
191
+ Given the app requests placement "home"
192
+ And placement "home" asks for 99 creatives
193
+ Then the request fails
194
+ And the SDK surfaces a typed validation error with HTTP status 422
195
+ And the engine's error code is 10016
196
+
197
+ @V @validation
198
+ Scenario: V9 — a request with no placements fails locally, before any network call
199
+ Given the app requests no placements
200
+ Then the request fails
201
+ And the SDK rejects the request itself, before any network call
202
+
203
+ @Z @legacy-version
204
+ Scenario: Z1 — API version 2025-01-01 still serves a native ad and never a journey block
205
+ Given the app sends X-Decision-Version "2025-01-01"
206
+ And the app requests placement "home"
207
+ Then an ad is served
208
+ And the creative carries complete metadata: ad, creative, placement and template ids
209
+ And the creative is not a Journey Ad
210
+
211
+ @Z @legacy-version
212
+ Scenario: Z2 — omitting the version header still serves (engine falls back to the oldest active version)
213
+ Given the app sends no X-Decision-Version header
214
+ And the app requests placement "home"
215
+ Then an ad is served
216
+ And the creative is not a Journey Ad
217
+
218
+ @SP @sponsored-pin
219
+ Scenario: SP1 — a radius search returns the campaign's own shops, nearest first
220
+ Given the app sends X-Decision-Version "2025-11-01"
221
+ And the app requests placement "poi"
222
+ And the app searches for pins within 20000 m of (-33.4175, -70.6065)
223
+ Then an ad is served
224
+ And the ad carries at least 2 pins
225
+ And the pins are ordered nearest first
226
+ And every pin is one of the advertiser's locations, with a name and a distance
227
+
228
+ @SP @sponsored-pin
229
+ Scenario: SP2 — a bounds search returns them too — the other shape a map can ask with
230
+ Given the app sends X-Decision-Version "2025-11-01"
231
+ And the app requests placement "poi"
232
+ And the app searches for pins inside the map area north -33.3, south -33.55, east -70.45, west -70.65, around (-33.4175, -70.6065)
233
+ Then an ad is served
234
+ And the ad carries at least 2 pins
235
+ And every pin is one of the advertiser's locations, with a name and a distance
236
+
237
+ @SP @sponsored-pin
238
+ Scenario: SP3 — every returned shop carries its own view, tap and click beacons
239
+ Given the app sends X-Decision-Version "2025-11-01"
240
+ And the app requests placement "poi"
241
+ And the app searches for pins within 20000 m of (-33.4175, -70.6065)
242
+ Then an ad is served
243
+ And every pin carries its own view, tap and click beacons
244
+
245
+ @SP @sponsored-pin
246
+ Scenario: SP4 — every returned shop carries a resolved destination, its own or the creative's
247
+ Given the app sends X-Decision-Version "2025-11-01"
248
+ And the app requests placement "poi"
249
+ And the app searches for pins within 20000 m of (-33.4175, -70.6065)
250
+ Then an ad is served
251
+ And every pin carries a resolved destination URL
252
+
253
+ @SP @sponsored-pin
254
+ Scenario: SP5 — the request's limit narrows how many shops come back
255
+ Given the app sends X-Decision-Version "2025-11-01"
256
+ And the app requests placement "poi"
257
+ And the app searches for pins within 20000 m of (-33.4175, -70.6065)
258
+ And the search asks for at most 2 pins
259
+ Then an ad is served
260
+ And the ad carries exactly 2 pins
261
+
262
+ @SP @sponsored-pin
263
+ Scenario: SP6 — a tight radius excludes the shops outside it
264
+ Given the app sends X-Decision-Version "2025-11-01"
265
+ And the app requests placement "poi"
266
+ And the app searches for pins within 1500 m of (-33.4175, -70.6065)
267
+ Then an ad is served
268
+ And the ad carries exactly 1 pin
269
+
270
+ @SP @sponsored-pin
271
+ Scenario: SP7 — without a distance search a Sponsored Pin campaign does not serve at all
272
+ Given the app sends X-Decision-Version "2025-11-01"
273
+ And the app requests placement "poi"
274
+ Then no ad is served, and the placement answers with a clean no-fill
275
+
276
+ @SP @sponsored-pin
277
+ Scenario: SP8 — on the older API version the ad still serves, silently carrying no pins — the failure an integrator must recognise
278
+ Given the app sends no X-Decision-Version header
279
+ And the app requests placement "poi"
280
+ And the app searches for pins within 20000 m of (-33.4175, -70.6065)
281
+ Then an ad is served
282
+ And the ad carries no pins
283
+
284
+ @SP @sponsored-pin
285
+ Scenario: SP9 — a map's first render sends every shop's view beacon exactly once
286
+ Given the app sends X-Decision-Version "2025-11-01"
287
+ And the app requests placement "poi"
288
+ And the app searches for pins within 20000 m of (-33.4175, -70.6065)
289
+ Then an ad is served
290
+ And the ad carries at least 1 pin
291
+ And every pin carries its own view, tap and click beacons
292
+ When the app shows every pin
293
+ Then each shown pin's view beacon reaches the engine exactly once per response, and no pin the app never showed sends one
294
+ And the engine accepts every beacon
295
+
296
+ @SP @sponsored-pin
297
+ Scenario: SP10 — re-rendering the same response sends no view beacon again
298
+ Given the app sends X-Decision-Version "2025-11-01"
299
+ And the app requests placement "poi"
300
+ And the app searches for pins within 20000 m of (-33.4175, -70.6065)
301
+ Then an ad is served
302
+ And the ad carries at least 1 pin
303
+ And every pin carries its own view, tap and click beacons
304
+ When the app shows every pin
305
+ And the app shows every pin
306
+ And the app shows every pin
307
+ Then each shown pin's view beacon reaches the engine exactly once per response, and no pin the app never showed sends one
308
+ And the engine accepts every beacon
309
+
310
+ @SP @sponsored-pin
311
+ Scenario: SP11 — a render that brings new shops into view sends views only for the new ones
312
+ Given the app sends X-Decision-Version "2025-11-01"
313
+ And the app requests placement "poi"
314
+ And the app searches for pins within 20000 m of (-33.4175, -70.6065)
315
+ Then an ad is served
316
+ And the ad carries at least 4 pins
317
+ And every pin carries its own view, tap and click beacons
318
+ When the app shows the first 2 pins
319
+ And the app shows pins 2 to 4
320
+ And the app shows the first 4 pins
321
+ Then each shown pin's view beacon reaches the engine exactly once per response, and no pin the app never showed sends one
322
+ And the engine accepts every beacon
323
+
324
+ @SP @sponsored-pin
325
+ Scenario: SP12 — a new decision response reports the same shops again, once
326
+ Given the app sends X-Decision-Version "2025-11-01"
327
+ And the app requests placement "poi"
328
+ And the app searches for pins within 20000 m of (-33.4175, -70.6065)
329
+ Then an ad is served
330
+ And the ad carries at least 1 pin
331
+ And every pin carries its own view, tap and click beacons
332
+ When the app shows every pin
333
+ And the app fetches a new decision response for the same search
334
+ And the app shows every pin
335
+ And the app shows every pin
336
+ Then each shown pin's view beacon reaches the engine exactly once per response, and no pin the app never showed sends one
337
+ And a new response carries new view beacons for the same pins
338
+ And the engine accepts every beacon
339
+
340
+ @SP @sponsored-pin
341
+ Scenario: SP13 — single-pin views follow the same once-per-response rule as the bulk call
342
+ Given the app sends X-Decision-Version "2025-11-01"
343
+ And the app requests placement "poi"
344
+ And the app searches for pins within 20000 m of (-33.4175, -70.6065)
345
+ Then an ad is served
346
+ And the ad carries at least 1 pin
347
+ And every pin carries its own view, tap and click beacons
348
+ When the app shows pin 1 on its own
349
+ And the app shows pin 1 on its own
350
+ And the app shows every pin
351
+ And the app shows pin 1 on its own
352
+ Then each shown pin's view beacon reaches the engine exactly once per response, and no pin the app never showed sends one
353
+ And the engine accepts every beacon
354
+
355
+ @SP @sponsored-pin
356
+ Scenario: SP14 — taps and clicks are sent on every call, never de-duplicated
357
+ Given the app sends X-Decision-Version "2025-11-01"
358
+ And the app requests placement "poi"
359
+ And the app searches for pins within 20000 m of (-33.4175, -70.6065)
360
+ Then an ad is served
361
+ And the ad carries at least 1 pin
362
+ And every pin carries its own view, tap and click beacons
363
+ When the app shows every pin
364
+ And the user taps pin 1
365
+ And the user taps pin 1
366
+ And the user clicks pin 1
367
+ And the user clicks pin 1
368
+ And the app shows every pin
369
+ Then each shown pin's view beacon reaches the engine exactly once per response, and no pin the app never showed sends one
370
+ And every tap and every click sends its beacon, on every call
371
+ And the engine accepts every beacon