adserver-dashboard 1.0.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.
Files changed (525) hide show
  1. package/.ci/staging.yml +191 -0
  2. package/.dockerignore +117 -0
  3. package/.env +40 -0
  4. package/.env.staging +38 -0
  5. package/.gitlab-ci.yml +16 -0
  6. package/DEMO_STATUS.md +579 -0
  7. package/Dockerfile +61 -0
  8. package/Influence-MW-AdServer-12-02-2026/client/index.html +17 -0
  9. package/Influence-MW-AdServer-12-02-2026/client/public/favicon.png +0 -0
  10. package/Influence-MW-AdServer-12-02-2026/client/src/App.tsx +91 -0
  11. package/Influence-MW-AdServer-12-02-2026/client/src/components/advanced-map-drawer.tsx +1131 -0
  12. package/Influence-MW-AdServer-12-02-2026/client/src/components/ai-recommendation-panel.tsx +379 -0
  13. package/Influence-MW-AdServer-12-02-2026/client/src/components/app-sidebar.tsx +183 -0
  14. package/Influence-MW-AdServer-12-02-2026/client/src/components/auto-optimize-button.tsx +184 -0
  15. package/Influence-MW-AdServer-12-02-2026/client/src/components/availability-drawer.tsx +385 -0
  16. package/Influence-MW-AdServer-12-02-2026/client/src/components/brand-insights-panel.tsx +87 -0
  17. package/Influence-MW-AdServer-12-02-2026/client/src/components/create-agency-drawer.tsx +198 -0
  18. package/Influence-MW-AdServer-12-02-2026/client/src/components/create-brand-drawer.tsx +275 -0
  19. package/Influence-MW-AdServer-12-02-2026/client/src/components/creative-assignment.tsx +526 -0
  20. package/Influence-MW-AdServer-12-02-2026/client/src/components/data-table-toolbar.tsx +148 -0
  21. package/Influence-MW-AdServer-12-02-2026/client/src/components/data-table.tsx +158 -0
  22. package/Influence-MW-AdServer-12-02-2026/client/src/components/filter-drawer.tsx +356 -0
  23. package/Influence-MW-AdServer-12-02-2026/client/src/components/form-insights-panel.tsx +82 -0
  24. package/Influence-MW-AdServer-12-02-2026/client/src/components/geography-selector.tsx +699 -0
  25. package/Influence-MW-AdServer-12-02-2026/client/src/components/header-user-menu.tsx +178 -0
  26. package/Influence-MW-AdServer-12-02-2026/client/src/components/history-drawer.tsx +313 -0
  27. package/Influence-MW-AdServer-12-02-2026/client/src/components/inventory-availability-section.tsx +176 -0
  28. package/Influence-MW-AdServer-12-02-2026/client/src/components/inventory-format-drawer.tsx +173 -0
  29. package/Influence-MW-AdServer-12-02-2026/client/src/components/inventory-selector.tsx +401 -0
  30. package/Influence-MW-AdServer-12-02-2026/client/src/components/manual-inventory-drawer.tsx +368 -0
  31. package/Influence-MW-AdServer-12-02-2026/client/src/components/mapbox-map.tsx +368 -0
  32. package/Influence-MW-AdServer-12-02-2026/client/src/components/market-insights-panel.tsx +202 -0
  33. package/Influence-MW-AdServer-12-02-2026/client/src/components/media-owner-drawer.tsx +217 -0
  34. package/Influence-MW-AdServer-12-02-2026/client/src/components/metric-card.tsx +58 -0
  35. package/Influence-MW-AdServer-12-02-2026/client/src/components/page-header.tsx +27 -0
  36. package/Influence-MW-AdServer-12-02-2026/client/src/components/player-status-indicator.tsx +137 -0
  37. package/Influence-MW-AdServer-12-02-2026/client/src/components/poi-targeting-drawer.tsx +298 -0
  38. package/Influence-MW-AdServer-12-02-2026/client/src/components/recommendation-score-badge.tsx +102 -0
  39. package/Influence-MW-AdServer-12-02-2026/client/src/components/recommended-inventories-panel.tsx +248 -0
  40. package/Influence-MW-AdServer-12-02-2026/client/src/components/searchable-combobox.tsx +134 -0
  41. package/Influence-MW-AdServer-12-02-2026/client/src/components/signal-visualizations.tsx +407 -0
  42. package/Influence-MW-AdServer-12-02-2026/client/src/components/status-badge.tsx +35 -0
  43. package/Influence-MW-AdServer-12-02-2026/client/src/components/theme-provider.tsx +73 -0
  44. package/Influence-MW-AdServer-12-02-2026/client/src/components/theme-toggle.tsx +37 -0
  45. package/Influence-MW-AdServer-12-02-2026/client/src/components/traffic-slider.tsx +75 -0
  46. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/accordion.tsx +56 -0
  47. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/alert-dialog.tsx +139 -0
  48. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/alert.tsx +59 -0
  49. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/aspect-ratio.tsx +5 -0
  50. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/avatar.tsx +51 -0
  51. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/badge.tsx +38 -0
  52. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/breadcrumb.tsx +115 -0
  53. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/button.tsx +62 -0
  54. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/calendar.tsx +68 -0
  55. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/card.tsx +85 -0
  56. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/carousel.tsx +260 -0
  57. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/chart.tsx +365 -0
  58. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/checkbox.tsx +28 -0
  59. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/collapsible.tsx +11 -0
  60. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/command.tsx +151 -0
  61. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/context-menu.tsx +198 -0
  62. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/dialog.tsx +122 -0
  63. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/drawer.tsx +118 -0
  64. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/dropdown-menu.tsx +198 -0
  65. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/form.tsx +178 -0
  66. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/hover-card.tsx +29 -0
  67. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/input-otp.tsx +69 -0
  68. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/input.tsx +23 -0
  69. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/label.tsx +24 -0
  70. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/menubar.tsx +256 -0
  71. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/navigation-menu.tsx +128 -0
  72. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/pagination.tsx +117 -0
  73. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/popover.tsx +29 -0
  74. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/progress.tsx +28 -0
  75. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/radio-group.tsx +42 -0
  76. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/resizable.tsx +45 -0
  77. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/scroll-area.tsx +46 -0
  78. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/select.tsx +160 -0
  79. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/separator.tsx +29 -0
  80. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/sheet.tsx +140 -0
  81. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/sidebar.tsx +727 -0
  82. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/skeleton.tsx +15 -0
  83. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/slider.tsx +26 -0
  84. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/switch.tsx +27 -0
  85. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/table.tsx +117 -0
  86. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/tabs.tsx +53 -0
  87. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/textarea.tsx +22 -0
  88. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toast.tsx +127 -0
  89. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toaster.tsx +33 -0
  90. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toggle-group.tsx +61 -0
  91. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toggle.tsx +43 -0
  92. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/tooltip.tsx +30 -0
  93. package/Influence-MW-AdServer-12-02-2026/client/src/components/vendor-stores-modal.tsx +336 -0
  94. package/Influence-MW-AdServer-12-02-2026/client/src/components/venue-type-drawer.tsx +359 -0
  95. package/Influence-MW-AdServer-12-02-2026/client/src/components/venue-type-selector.tsx +436 -0
  96. package/Influence-MW-AdServer-12-02-2026/client/src/hooks/use-mobile.tsx +19 -0
  97. package/Influence-MW-AdServer-12-02-2026/client/src/hooks/use-toast.ts +191 -0
  98. package/Influence-MW-AdServer-12-02-2026/client/src/index.css +244 -0
  99. package/Influence-MW-AdServer-12-02-2026/client/src/lib/queryClient.ts +57 -0
  100. package/Influence-MW-AdServer-12-02-2026/client/src/lib/utils.ts +39 -0
  101. package/Influence-MW-AdServer-12-02-2026/client/src/lib/venue-taxonomy.ts +532 -0
  102. package/Influence-MW-AdServer-12-02-2026/client/src/main.tsx +5 -0
  103. package/Influence-MW-AdServer-12-02-2026/client/src/pages/assign-creative.tsx +781 -0
  104. package/Influence-MW-AdServer-12-02-2026/client/src/pages/content-hub.tsx +995 -0
  105. package/Influence-MW-AdServer-12-02-2026/client/src/pages/custom-pois.tsx +431 -0
  106. package/Influence-MW-AdServer-12-02-2026/client/src/pages/dashboard.tsx +620 -0
  107. package/Influence-MW-AdServer-12-02-2026/client/src/pages/deal-detail.tsx +1062 -0
  108. package/Influence-MW-AdServer-12-02-2026/client/src/pages/deal-form.tsx +1570 -0
  109. package/Influence-MW-AdServer-12-02-2026/client/src/pages/deals.tsx +716 -0
  110. package/Influence-MW-AdServer-12-02-2026/client/src/pages/edit-creative-assignment.tsx +1051 -0
  111. package/Influence-MW-AdServer-12-02-2026/client/src/pages/geotargeting.tsx +675 -0
  112. package/Influence-MW-AdServer-12-02-2026/client/src/pages/integrations.tsx +425 -0
  113. package/Influence-MW-AdServer-12-02-2026/client/src/pages/line-item-creatives.tsx +622 -0
  114. package/Influence-MW-AdServer-12-02-2026/client/src/pages/line-item-form.tsx +3132 -0
  115. package/Influence-MW-AdServer-12-02-2026/client/src/pages/line-items.tsx +530 -0
  116. package/Influence-MW-AdServer-12-02-2026/client/src/pages/not-found.tsx +21 -0
  117. package/Influence-MW-AdServer-12-02-2026/client/src/pages/proof-of-play-upload.tsx +479 -0
  118. package/Influence-MW-AdServer-12-02-2026/client/src/pages/proof-of-play.tsx +880 -0
  119. package/Influence-MW-AdServer-12-02-2026/client/src/pages/reports.tsx +235 -0
  120. package/Influence-MW-AdServer-12-02-2026/client/src/pages/settings.tsx +652 -0
  121. package/Influence-MW-AdServer-12-02-2026/client/src/pages/signal-form.tsx +1117 -0
  122. package/Influence-MW-AdServer-12-02-2026/client/src/pages/signals.tsx +366 -0
  123. package/Influence-MW-AdServer-12-02-2026/client/src/pages/tags.tsx +332 -0
  124. package/Influence-MW-AdServer-12-02-2026/client/src/pages/venues.tsx +381 -0
  125. package/Influence-MW-AdServer-12-02-2026/client/src/types/mapbox-gl-draw.d.ts +37 -0
  126. package/Influence-MW-AdServer-12-02-2026/client/src/types/react-simple-maps.d.ts +57 -0
  127. package/Influence-MW-AdServer-12-02-2026/components.json +20 -0
  128. package/Influence-MW-AdServer-12-02-2026/docs/PRD.md +3373 -0
  129. package/Influence-MW-AdServer-12-02-2026/docs/influence-feature-mapping.csv +498 -0
  130. package/Influence-MW-AdServer-12-02-2026/drizzle.config.ts +14 -0
  131. package/Influence-MW-AdServer-12-02-2026/package-lock.json +9672 -0
  132. package/Influence-MW-AdServer-12-02-2026/package.json +118 -0
  133. package/Influence-MW-AdServer-12-02-2026/postcss.config.js +6 -0
  134. package/Influence-MW-AdServer-12-02-2026/replit.md +91 -0
  135. package/Influence-MW-AdServer-12-02-2026/script/build.ts +67 -0
  136. package/Influence-MW-AdServer-12-02-2026/scripts/create-miro-diagrams.cjs +318 -0
  137. package/Influence-MW-AdServer-12-02-2026/scripts/create-remaining-diagrams.cjs +270 -0
  138. package/Influence-MW-AdServer-12-02-2026/server/index.ts +103 -0
  139. package/Influence-MW-AdServer-12-02-2026/server/recommendation-service.ts +319 -0
  140. package/Influence-MW-AdServer-12-02-2026/server/routes.ts +1890 -0
  141. package/Influence-MW-AdServer-12-02-2026/server/static.ts +19 -0
  142. package/Influence-MW-AdServer-12-02-2026/server/storage.ts +2058 -0
  143. package/Influence-MW-AdServer-12-02-2026/server/vite.ts +58 -0
  144. package/Influence-MW-AdServer-12-02-2026/shared/schema.ts +1595 -0
  145. package/Influence-MW-AdServer-12-02-2026/tailwind.config.ts +107 -0
  146. package/Influence-MW-AdServer-12-02-2026/tsconfig.json +23 -0
  147. package/Influence-MW-AdServer-12-02-2026/vite.config.ts +40 -0
  148. package/LINE_ITEM_BUDGET_FIELD_MAPPING.md +178 -0
  149. package/PCM/.env.example +92 -0
  150. package/PCM/README.md +558 -0
  151. package/PCM/docs/TEST_CASES.md +422 -0
  152. package/PCM/index.js +106 -0
  153. package/PCM/package-lock.json +3282 -0
  154. package/PCM/package.json +32 -0
  155. package/PCM/replit.md +64 -0
  156. package/PCM/schema.sql +495 -0
  157. package/PCM/scripts/export-schema.js +183 -0
  158. package/PCM/scripts/seed-comprehensive.js +631 -0
  159. package/PCM/scripts/seed-production.js +477 -0
  160. package/PCM/src/config/db.js +56 -0
  161. package/PCM/src/config/swagger.js +5975 -0
  162. package/PCM/src/dto/EmailRequestDTO.js +166 -0
  163. package/PCM/src/middleware/errorHandler.js +52 -0
  164. package/PCM/src/middleware/logger.js +26 -0
  165. package/PCM/src/migrations/001_add_campaign_mode_fields.sql +36 -0
  166. package/PCM/src/migrations/002_create_deal_id_counters.sql +22 -0
  167. package/PCM/src/migrations/003_update_publishers_column.sql +15 -0
  168. package/PCM/src/migrations/004_add_direct_dealtype_and_advertiser.sql +5 -0
  169. package/PCM/src/migrations/005_add_programmatic_fields_and_update_enums.sql +31 -0
  170. package/PCM/src/migrations/006_add_line_item_programmatic_fields.sql +12 -0
  171. package/PCM/src/migrations/007_add_line_item_direct_fields.sql +15 -0
  172. package/PCM/src/migrations/008_add_inventory_fields.sql +45 -0
  173. package/PCM/src/migrations/009_move_inventory_fields_to_metadata.sql +32 -0
  174. package/PCM/src/migrations/010_add_draft_status_and_line_items_count.sql +23 -0
  175. package/PCM/src/migrations/011_add_planning_field.sql +21 -0
  176. package/PCM/src/migrations/012_fix_inventory_composite_pk.sql +17 -0
  177. package/PCM/src/migrations/013_make_external_id_optional.sql +3 -0
  178. package/PCM/src/migrations/014_create_change_history.sql +38 -0
  179. package/PCM/src/migrations/016_create_publisher_insertion_orders.sql +33 -0
  180. package/PCM/src/migrations/017_fix_line_item_id_fk_reference.sql +86 -0
  181. package/PCM/src/migrations/018_create_approval_tables.sql +44 -0
  182. package/PCM/src/migrations/019_add_encrypted_token_column.sql +2 -0
  183. package/PCM/src/migrations/020_add_rejection_reason_to_deals.sql +10 -0
  184. package/PCM/src/migrations/021_add_publisher_external_id_to_inventories.sql +12 -0
  185. package/PCM/src/migrations/022_add_line_item_extended_fields.sql +24 -0
  186. package/PCM/src/migrations/023_add_base_price_fields.sql +8 -0
  187. package/PCM/src/migrations/run-migrations.js +46 -0
  188. package/PCM/src/models/ApprovalOTP.js +51 -0
  189. package/PCM/src/models/ApprovalToken.js +79 -0
  190. package/PCM/src/models/ChangeHistory.js +107 -0
  191. package/PCM/src/models/Deal.js +186 -0
  192. package/PCM/src/models/DealIdCounter.js +28 -0
  193. package/PCM/src/models/LineItem.js +227 -0
  194. package/PCM/src/models/LineItemCreative.js +89 -0
  195. package/PCM/src/models/LineItemInventory.js +115 -0
  196. package/PCM/src/models/PublisherInsertionOrder.js +93 -0
  197. package/PCM/src/models/TransactionHistory.js +34 -0
  198. package/PCM/src/models/associations.js +81 -0
  199. package/PCM/src/routes/approval.js +321 -0
  200. package/PCM/src/routes/creatives.js +437 -0
  201. package/PCM/src/routes/deals.js +1638 -0
  202. package/PCM/src/routes/digitalSignage.js +242 -0
  203. package/PCM/src/routes/insertionOrders.js +380 -0
  204. package/PCM/src/routes/lineItems.js +926 -0
  205. package/PCM/src/routes/system.js +384 -0
  206. package/PCM/src/services/ApprovalService.js +885 -0
  207. package/PCM/src/services/CampaignImportConverter.js +631 -0
  208. package/PCM/src/services/CampaignModeService.js +273 -0
  209. package/PCM/src/services/CampaignStatusService.js +395 -0
  210. package/PCM/src/services/ChangeHistoryService.js +316 -0
  211. package/PCM/src/services/DealIdService.js +94 -0
  212. package/PCM/src/services/DealResponseFormatter.js +90 -0
  213. package/PCM/src/services/EmailNotificationService.js +315 -0
  214. package/PCM/src/services/LineItemResponseFormatter.js +122 -0
  215. package/PCM/src/services/LineItemStatusService.js +380 -0
  216. package/PCM/src/tests/comprehensiveTestRunner.js +360 -0
  217. package/PCM/src/tests/comprehensiveTests.js +1277 -0
  218. package/PCM/src/tests/dealTypeUnitTests.js +1058 -0
  219. package/PCM/src/tests/testRunner.js +248 -0
  220. package/PCM/src/utils/caseConverter.js +92 -0
  221. package/PCM/src/utils/dealCalculations.js +206 -0
  222. package/PCM/src/utils/lineItemPayloadNormalizer.js +41 -0
  223. package/PCM/src/utils/payloadNormalizer.js +34 -0
  224. package/PCM/src/utils/sourceNormalizer.js +56 -0
  225. package/PCM/src/validators/creativeValidator.js +27 -0
  226. package/PCM/src/validators/dealValidator.js +203 -0
  227. package/PCM/src/validators/lineItemValidator.js +489 -0
  228. package/PCM/tests/approval-flows.test.js +238 -0
  229. package/PCM/tests/approval-workflow.test.js +291 -0
  230. package/PCM/tests/campaign-import-converter.test.js +543 -0
  231. package/PCM/tests/campaign-import-e2e.test.js +520 -0
  232. package/PCM/tests/campaign-status.test.js +539 -0
  233. package/PCM/tests/direct-publisher-split-reimport.test.js +460 -0
  234. package/PCM/tests/e2e/digital-signage.test.js +145 -0
  235. package/PCM/tests/e2e/search-filter-pagination.test.js +399 -0
  236. package/PCM/tests/e2e-comprehensive.test.js +3446 -0
  237. package/PCM/tests/edge-cases.test.js +340 -0
  238. package/PCM/tests/line-item-status.test.js +340 -0
  239. package/PCM/tests/seller-account-external-ids.test.js +877 -0
  240. package/PCM/tests/source-validation.test.js +324 -0
  241. package/PRD.md +3373 -0
  242. package/README.md +186 -0
  243. package/client/index.html +35 -0
  244. package/client/public/DEMO_STATUS.md +579 -0
  245. package/client/public/img/MW-logo-trans_1754045676555.png +0 -0
  246. package/client/public/locales/ar/approval.json +144 -0
  247. package/client/public/locales/ar/buyer.json +61 -0
  248. package/client/public/locales/ar/campaigns.json +1 -0
  249. package/client/public/locales/ar/common.json +218 -0
  250. package/client/public/locales/ar/contentHub.json +266 -0
  251. package/client/public/locales/ar/creatives.json +79 -0
  252. package/client/public/locales/ar/dashboard.json +57 -0
  253. package/client/public/locales/ar/deals.json +886 -0
  254. package/client/public/locales/ar/dsp.json +131 -0
  255. package/client/public/locales/ar/inventory.json +201 -0
  256. package/client/public/locales/ar/lineItems.json +553 -0
  257. package/client/public/locales/ar/navigation.json +48 -0
  258. package/client/public/locales/ar/wizard.json +1 -0
  259. package/client/public/locales/en/approval.json +144 -0
  260. package/client/public/locales/en/buyer.json +65 -0
  261. package/client/public/locales/en/campaigns.json +1 -0
  262. package/client/public/locales/en/common.json +218 -0
  263. package/client/public/locales/en/contentHub.json +266 -0
  264. package/client/public/locales/en/creatives.json +79 -0
  265. package/client/public/locales/en/dashboard.json +57 -0
  266. package/client/public/locales/en/deals.json +886 -0
  267. package/client/public/locales/en/dsp.json +131 -0
  268. package/client/public/locales/en/inventory.json +201 -0
  269. package/client/public/locales/en/lineItems.json +659 -0
  270. package/client/public/locales/en/navigation.json +48 -0
  271. package/client/public/locales/en/wizard.json +1 -0
  272. package/client/public/locales/ja/approval.json +144 -0
  273. package/client/public/locales/ja/buyer.json +61 -0
  274. package/client/public/locales/ja/campaigns.json +1 -0
  275. package/client/public/locales/ja/common.json +218 -0
  276. package/client/public/locales/ja/contentHub.json +266 -0
  277. package/client/public/locales/ja/creatives.json +79 -0
  278. package/client/public/locales/ja/dashboard.json +57 -0
  279. package/client/public/locales/ja/deals.json +886 -0
  280. package/client/public/locales/ja/dsp.json +131 -0
  281. package/client/public/locales/ja/inventory.json +201 -0
  282. package/client/public/locales/ja/lineItems.json +553 -0
  283. package/client/public/locales/ja/navigation.json +48 -0
  284. package/client/public/locales/ja/wizard.json +1 -0
  285. package/client/public/locales/zh/approval.json +144 -0
  286. package/client/public/locales/zh/buyer.json +61 -0
  287. package/client/public/locales/zh/campaigns.json +1 -0
  288. package/client/public/locales/zh/common.json +218 -0
  289. package/client/public/locales/zh/contentHub.json +266 -0
  290. package/client/public/locales/zh/creatives.json +79 -0
  291. package/client/public/locales/zh/dashboard.json +57 -0
  292. package/client/public/locales/zh/deals.json +886 -0
  293. package/client/public/locales/zh/dsp.json +131 -0
  294. package/client/public/locales/zh/inventory.json +201 -0
  295. package/client/public/locales/zh/lineItems.json +553 -0
  296. package/client/public/locales/zh/navigation.json +48 -0
  297. package/client/public/locales/zh/wizard.json +1 -0
  298. package/client/public/manifest.json +36 -0
  299. package/client/src/App.tsx +464 -0
  300. package/client/src/components/app-sidebar.tsx +312 -0
  301. package/client/src/components/approval/approval-decision-form.test.tsx +294 -0
  302. package/client/src/components/approval/approval-decision-form.tsx +326 -0
  303. package/client/src/components/approval/approval-sheet.tsx +631 -0
  304. package/client/src/components/approval/line-item-details-sheet.tsx +371 -0
  305. package/client/src/components/approval/otp-verification.test.tsx +337 -0
  306. package/client/src/components/approval/otp-verification.tsx +180 -0
  307. package/client/src/components/content-hub/bulk-transcode-dialog.tsx +379 -0
  308. package/client/src/components/content-hub/content-hub-manager-v2.tsx +574 -0
  309. package/client/src/components/content-hub/content-hub-manager.tsx +330 -0
  310. package/client/src/components/content-hub/creative-card.tsx +456 -0
  311. package/client/src/components/content-hub/creative-detail-sheet.tsx +685 -0
  312. package/client/src/components/content-hub/creative-filters.tsx +457 -0
  313. package/client/src/components/content-hub/creative-grid.tsx +329 -0
  314. package/client/src/components/content-hub/creative-selector.tsx +415 -0
  315. package/client/src/components/content-hub/creative-upload.tsx +547 -0
  316. package/client/src/components/content-hub/folder-dialogs.tsx +445 -0
  317. package/client/src/components/content-hub/folder-list.tsx +280 -0
  318. package/client/src/components/content-hub/review-dialogs.tsx +268 -0
  319. package/client/src/components/content-hub/transcode-dialog.tsx +226 -0
  320. package/client/src/components/creative-library/creative-details-view.tsx +446 -0
  321. package/client/src/components/creative-library/creative-filters-panel.tsx +203 -0
  322. package/client/src/components/creative-library/creative-list.tsx +360 -0
  323. package/client/src/components/creative-library/creative-status-badge.tsx +71 -0
  324. package/client/src/components/creative-library/folder-card.tsx +78 -0
  325. package/client/src/components/creative-library/index.ts +27 -0
  326. package/client/src/components/creative-library/new-creative-card.tsx +211 -0
  327. package/client/src/components/creative-library/upload-creative-dialog.tsx +261 -0
  328. package/client/src/components/dashboard-overview.tsx +109 -0
  329. package/client/src/components/deals/approval-history-panel.test.tsx +240 -0
  330. package/client/src/components/deals/approval-history-panel.tsx +156 -0
  331. package/client/src/components/deals/deal-status-badge.tsx +92 -0
  332. package/client/src/components/deals/import-from-planner-dialog.tsx +399 -0
  333. package/client/src/components/deals/market-insights-panel.tsx +237 -0
  334. package/client/src/components/deals/reopen-deal-sheet.tsx +191 -0
  335. package/client/src/components/deals/request-approval-sheet.test.tsx +323 -0
  336. package/client/src/components/deals/request-approval-sheet.tsx +136 -0
  337. package/client/src/components/deals/resend-approval-sheet.tsx +201 -0
  338. package/client/src/components/direct-campaigns/campaign-card.tsx +283 -0
  339. package/client/src/components/direct-campaigns/deal-filter-panel.tsx +325 -0
  340. package/client/src/components/inventory/advanced-filters-panel.tsx +273 -0
  341. package/client/src/components/inventory/csv-upload-modal.tsx +639 -0
  342. package/client/src/components/inventory/inventory-availability-view.tsx +486 -0
  343. package/client/src/components/inventory/inventory-details-sheet.tsx +376 -0
  344. package/client/src/components/inventory/inventory-map-view.tsx +596 -0
  345. package/client/src/components/inventory/inventory-settings-menu.tsx +52 -0
  346. package/client/src/components/language-switcher.tsx +53 -0
  347. package/client/src/components/line-items/campaign-forecast-panel.tsx +138 -0
  348. package/client/src/components/line-items/form-insights.tsx +89 -0
  349. package/client/src/components/line-items/geofencing/LocationCsvUploadDrawer.tsx +100 -0
  350. package/client/src/components/line-items/geofencing/POIDropdown.tsx +379 -0
  351. package/client/src/components/line-items/geofencing/SelectedLocationsSidebar.tsx +436 -0
  352. package/client/src/components/line-items/geofencing/ViewFileLocationDrawer.tsx +199 -0
  353. package/client/src/components/line-items/geofencing/components/ExistingFilesTab.tsx +268 -0
  354. package/client/src/components/line-items/geofencing/components/TemplateDownloadSection.tsx +59 -0
  355. package/client/src/components/line-items/geofencing/components/UploadTab.tsx +215 -0
  356. package/client/src/components/line-items/geofencing-map.tsx +1270 -0
  357. package/client/src/components/line-items/inventory-availability-section.tsx +178 -0
  358. package/client/src/components/line-items/line-item-schedule-manager.tsx +313 -0
  359. package/client/src/components/line-items/manual-inventory-drawer.tsx +346 -0
  360. package/client/src/components/line-items/planner-inventory-card.tsx +495 -0
  361. package/client/src/components/line-items/planner-schedule-grid.tsx +495 -0
  362. package/client/src/components/line-items/schedule-rule-editor.tsx +649 -0
  363. package/client/src/components/line-items/schedule-rule-types.ts +122 -0
  364. package/client/src/components/line-items/steps/creatives-step.tsx +681 -0
  365. package/client/src/components/line-items/steps/inventory-schedule-step.tsx +1596 -0
  366. package/client/src/components/line-items/steps/inventory-step.tsx +1533 -0
  367. package/client/src/components/line-items/steps/line-item-details-step.tsx +916 -0
  368. package/client/src/components/line-items/steps/schedule-step.tsx +273 -0
  369. package/client/src/components/line-items/steps/summary-step.tsx +680 -0
  370. package/client/src/components/line-items/steps/targeting-step.tsx +1708 -0
  371. package/client/src/components/product-switcher.tsx +105 -0
  372. package/client/src/components/protected-route.tsx +49 -0
  373. package/client/src/components/skip-link.tsx +22 -0
  374. package/client/src/components/stat-card.tsx +53 -0
  375. package/client/src/components/status-badge.tsx +96 -0
  376. package/client/src/components/ui/hierarchical-venue-selector.tsx +389 -0
  377. package/client/src/components/ui/toaster.tsx +111 -0
  378. package/client/src/contexts/auth-context.tsx +181 -0
  379. package/client/src/contexts/sidebar-state.tsx +50 -0
  380. package/client/src/contexts/theme-context.tsx +66 -0
  381. package/client/src/data/campaign-data.json +107 -0
  382. package/client/src/data/countries.json +22 -0
  383. package/client/src/hooks/use-approval.ts +366 -0
  384. package/client/src/hooks/use-keyboard-shortcuts.ts +74 -0
  385. package/client/src/hooks/use-media-query.ts +46 -0
  386. package/client/src/hooks/use-mobile.tsx +19 -0
  387. package/client/src/hooks/use-page-title.ts +21 -0
  388. package/client/src/hooks/use-toast.ts +195 -0
  389. package/client/src/index.css +694 -0
  390. package/client/src/lib/__tests__/accessibility.test.ts +104 -0
  391. package/client/src/lib/__tests__/date-utils.test.ts +199 -0
  392. package/client/src/lib/__tests__/dsp-buyer-api.test.ts +127 -0
  393. package/client/src/lib/__tests__/dsp-buyer-integration.test.ts +247 -0
  394. package/client/src/lib/__tests__/storage-utils.test.ts +167 -0
  395. package/client/src/lib/__tests__/utils.test.ts +57 -0
  396. package/client/src/lib/accessibility.ts +141 -0
  397. package/client/src/lib/api-config.ts +9 -0
  398. package/client/src/lib/auth-service.ts +209 -0
  399. package/client/src/lib/campaign-creative-api.ts +82 -0
  400. package/client/src/lib/company-api.ts +61 -0
  401. package/client/src/lib/content-hub-api.ts +407 -0
  402. package/client/src/lib/creative-mapper.ts +61 -0
  403. package/client/src/lib/date-utils.ts +119 -0
  404. package/client/src/lib/deal-helpers.ts +220 -0
  405. package/client/src/lib/dsp-buyer-api.ts +196 -0
  406. package/client/src/lib/geo-import-api.ts +151 -0
  407. package/client/src/lib/google-poi-api.ts +305 -0
  408. package/client/src/lib/i18n/__tests__/formatting.test.ts +202 -0
  409. package/client/src/lib/i18n/formatting.ts +130 -0
  410. package/client/src/lib/i18n/index.ts +8 -0
  411. package/client/src/lib/i18n-compat.ts +76 -0
  412. package/client/src/lib/influence-deals-api.ts +896 -0
  413. package/client/src/lib/inventory-api.ts +399 -0
  414. package/client/src/lib/oauth-service.ts +678 -0
  415. package/client/src/lib/poi-types.ts +75 -0
  416. package/client/src/lib/queryClient.ts +144 -0
  417. package/client/src/lib/recommendation-api.ts +380 -0
  418. package/client/src/lib/storage-utils.ts +104 -0
  419. package/client/src/lib/tolgee.ts +85 -0
  420. package/client/src/lib/utils.ts +0 -0
  421. package/client/src/main.tsx +67 -0
  422. package/client/src/mapbox-draw-modes.d.ts +32 -0
  423. package/client/src/pages/all-folders.tsx +203 -0
  424. package/client/src/pages/auth-callback.tsx +115 -0
  425. package/client/src/pages/buyer-form.tsx +339 -0
  426. package/client/src/pages/buyer-list.tsx +622 -0
  427. package/client/src/pages/content-hub.tsx +1358 -0
  428. package/client/src/pages/create-deal.tsx +2093 -0
  429. package/client/src/pages/creative-assignment-page.tsx +548 -0
  430. package/client/src/pages/creatives.tsx +5 -0
  431. package/client/src/pages/custom-pois.tsx +425 -0
  432. package/client/src/pages/dashboard.tsx +615 -0
  433. package/client/src/pages/deal-history.tsx +434 -0
  434. package/client/src/pages/deal-line-items.tsx +1703 -0
  435. package/client/src/pages/demo-status.tsx +113 -0
  436. package/client/src/pages/direct-campaign-details.tsx +361 -0
  437. package/client/src/pages/direct-campaigns-new.tsx +824 -0
  438. package/client/src/pages/dsp-form.tsx +803 -0
  439. package/client/src/pages/dsp-list.tsx +239 -0
  440. package/client/src/pages/folder-content.tsx +336 -0
  441. package/client/src/pages/integrations.tsx +429 -0
  442. package/client/src/pages/line-item-creatives.tsx +789 -0
  443. package/client/src/pages/line-item-detail-page.tsx +684 -0
  444. package/client/src/pages/line-item-form-page.tsx +3261 -0
  445. package/client/src/pages/line-item-wizard.tsx +1207 -0
  446. package/client/src/pages/login.tsx +154 -0
  447. package/client/src/pages/not-found.tsx +23 -0
  448. package/client/src/pages/proof-of-play.tsx +397 -0
  449. package/client/src/pages/public-approval.tsx +551 -0
  450. package/client/src/pages/reports.tsx +231 -0
  451. package/client/src/pages/settings.tsx +760 -0
  452. package/client/src/pages/signals.tsx +389 -0
  453. package/client/src/pages/tags.tsx +318 -0
  454. package/client/src/pages/test-results.tsx +328 -0
  455. package/client/src/store/hooks.ts +5 -0
  456. package/client/src/store/index.ts +15 -0
  457. package/client/src/store/mapMarkerLocationsSlice.ts +241 -0
  458. package/client/src/styles/design-tokens.css +324 -0
  459. package/client/src/test/setup.ts +261 -0
  460. package/client/src/test/test-utils.tsx +40 -0
  461. package/client/src/types/approval.ts +221 -0
  462. package/client/src/types/content-hub.ts +209 -0
  463. package/client/src/types/geofencing.ts +67 -0
  464. package/client/src/types/transcoding.ts +140 -0
  465. package/client/src/vite-env.d.ts +18 -0
  466. package/components.json +20 -0
  467. package/creative-api.json +1 -0
  468. package/docs/AI_REFERENCE.md +459 -0
  469. package/docs/MWDesign-Prompt.md +132 -0
  470. package/docs/MWDesign-System.md +344 -0
  471. package/docs/test-plan.md +277 -0
  472. package/e2e/AUTONOMOUS-TESTING.md +406 -0
  473. package/e2e/README.md +219 -0
  474. package/e2e/autonomous-flow.spec.ts +308 -0
  475. package/e2e/debug-sso.spec.ts +163 -0
  476. package/e2e/direct-campaigns.spec.ts +219 -0
  477. package/e2e/explore-sso.spec.ts +149 -0
  478. package/e2e/fixtures/auth.ts +26 -0
  479. package/e2e/fixtures/enhanced-test.ts +331 -0
  480. package/e2e/pagination.spec.ts +280 -0
  481. package/e2e/view-toggle.spec.ts +312 -0
  482. package/generated-icon.png +0 -0
  483. package/i18next-scanner.config.cjs +46 -0
  484. package/package.json +141 -0
  485. package/playwright.config.ts +93 -0
  486. package/postcss.config.js +6 -0
  487. package/replit.md +196 -0
  488. package/screenshot-after-login.png +0 -0
  489. package/screenshot-contenthub-grid.png +0 -0
  490. package/screenshot-contenthub-list-fixed.png +0 -0
  491. package/screenshot-contenthub-list.png +0 -0
  492. package/screenshot-create-deal.png +0 -0
  493. package/screenshot-dashboard.png +0 -0
  494. package/screenshot-deals.png +0 -0
  495. package/screenshot-login-filled.png +0 -0
  496. package/screenshot-login.png +0 -0
  497. package/screenshot.mjs +24 -0
  498. package/scripts/deploy-stg.sh +185 -0
  499. package/shared/direct-io-schema.ts +383 -0
  500. package/shared/schema.ts +439 -0
  501. package/shared/screen-types.ts +149 -0
  502. package/springdocDefault.json +1 -0
  503. package/swagger-ui-bundle.js +2 -0
  504. package/swagger-ui-init.js +10316 -0
  505. package/tailwind.config.ts +282 -0
  506. package/terraform/README.md +306 -0
  507. package/terraform/cloudfront.tf +289 -0
  508. package/terraform/ecs.tf +727 -0
  509. package/terraform/environments/dev.tfvars +59 -0
  510. package/terraform/environments/production.tfvars +60 -0
  511. package/terraform/main.tf +47 -0
  512. package/terraform/outputs.tf +145 -0
  513. package/terraform/s3.tf +192 -0
  514. package/terraform/variables.tf +226 -0
  515. package/terraform/waf.tf +165 -0
  516. package/terraform-frontend/.terraform.lock.hcl +25 -0
  517. package/terraform-frontend/README.md +85 -0
  518. package/terraform-frontend/cloudfront.tf +125 -0
  519. package/terraform-frontend/main.tf +31 -0
  520. package/terraform-frontend/outputs.tf +24 -0
  521. package/terraform-frontend/terraform.tfvars +12 -0
  522. package/terraform-frontend/variables.tf +53 -0
  523. package/tsconfig.json +23 -0
  524. package/vite.config.ts +226 -0
  525. package/vitest.config.ts +56 -0
@@ -0,0 +1,3446 @@
1
+ import { describe, it, before, after } from 'node:test';
2
+ import assert from 'node:assert';
3
+
4
+ const BASE_URL = 'http://localhost:5000/api/v1/rest';
5
+
6
+ async function request(method, path, body = null) {
7
+ const options = {
8
+ method,
9
+ headers: { 'Content-Type': 'application/json' }
10
+ };
11
+ if (body) options.body = JSON.stringify(body);
12
+ const res = await fetch(`${BASE_URL}${path}`, options);
13
+ const data = await res.json();
14
+ return { status: res.status, data };
15
+ }
16
+
17
+ describe('E2E Comprehensive Test Suite', () => {
18
+ const testData = {
19
+ deals: {},
20
+ lineItems: {},
21
+ creatives: {}
22
+ };
23
+
24
+ describe('1. PROGRAMMATIC Mode - GUARANTEED Deal', () => {
25
+ const dealPayload = {
26
+ name: 'E2E Guaranteed Campaign',
27
+ externalId: `e2e-guaranteed-${Date.now()}`,
28
+ source: 'e2e-test',
29
+ mode: 'PROGRAMMATIC',
30
+ dealType: 'GUARANTEED',
31
+ currency: 'USD',
32
+ seller: { id: 'seller-001', name: 'Test Seller' },
33
+ programmatic: {
34
+ buyers: [{ id: 'buyer-001', name: 'Test Buyer' }],
35
+ transactionType: 'SPOT',
36
+ costType: 'CPM'
37
+ }
38
+ };
39
+
40
+ it('should create a GUARANTEED deal', async () => {
41
+ const { status, data } = await request('POST', '/deals', dealPayload);
42
+ assert.strictEqual(status, 201);
43
+ assert.ok(data.result.dealId.startsWith('GD'));
44
+ testData.deals.guaranteed = data.result;
45
+ });
46
+
47
+ it('should get deal by dealId with full details', async () => {
48
+ const { status, data } = await request('GET', `/deals/${testData.deals.guaranteed.dealId}`);
49
+ assert.strictEqual(status, 200);
50
+ assert.strictEqual(data.result.name, dealPayload.name);
51
+ assert.strictEqual(data.result.mode, 'PROGRAMMATIC');
52
+ assert.strictEqual(data.result.dealType, 'GUARANTEED');
53
+ assert.strictEqual(data.result.programmatic.auctionType, 3);
54
+ testData.deals.guaranteed = data.result;
55
+ });
56
+
57
+ it('should get deal by UUID', async () => {
58
+ const { status, data } = await request('GET', `/deals/${testData.deals.guaranteed.id}`);
59
+ assert.strictEqual(status, 200);
60
+ assert.strictEqual(data.result.id, testData.deals.guaranteed.id);
61
+ });
62
+
63
+ it('should create a DISPLAY line item', async () => {
64
+ const lineItemPayload = {
65
+ externalId: `e2e-li-display-${Date.now()}`,
66
+ name: 'Display Line Item',
67
+ creativeType: 'DISPLAY',
68
+ duration: 15,
69
+ timezoneId: 'America/New_York',
70
+ startDate: '2025-01-01',
71
+ endDate: '2025-03-31',
72
+ resolutions: ['1920x1080', '1280x720'],
73
+ programmatic: {
74
+ costType: 'CPM',
75
+ bidFloor: 5.00,
76
+ deliveryGoal: 100000,
77
+ netCost: 500,
78
+ thresholdCountPerDay: 100
79
+ }
80
+ };
81
+ const { status, data } = await request('POST', `/deals/${testData.deals.guaranteed.dealId}/line-items`, lineItemPayload);
82
+ assert.strictEqual(status, 201);
83
+ testData.lineItems.display = data.result;
84
+ });
85
+
86
+ it('should get line item by id with full details', async () => {
87
+ const { status, data } = await request('GET', `/deals/${testData.deals.guaranteed.dealId}/line-items/${testData.lineItems.display.id}`);
88
+ assert.strictEqual(status, 200);
89
+ assert.strictEqual(data.result.creativeType, 'DISPLAY');
90
+ testData.lineItems.display = data.result;
91
+ });
92
+
93
+ it('should update deal status to APPROVED for creative assignment', async () => {
94
+ const { status, data } = await request('PUT', `/deals/${testData.deals.guaranteed.dealId}`, { status: 'APPROVED' });
95
+ assert.strictEqual(status, 200);
96
+ assert.strictEqual(data.result.status, 'APPROVED');
97
+ testData.deals.guaranteed = data.result;
98
+ });
99
+
100
+ it('should get line items by dealId', async () => {
101
+ const { status, data } = await request('GET', `/deals/${testData.deals.guaranteed.dealId}/line-items`);
102
+ assert.strictEqual(status, 200);
103
+ assert.ok(data.result.data.length >= 1);
104
+ });
105
+
106
+ it('should get line item by externalId', async () => {
107
+ const { status, data } = await request('GET', `/deals/${testData.deals.guaranteed.dealId}/line-items/${testData.lineItems.display.externalId}`);
108
+ assert.strictEqual(status, 200);
109
+ assert.strictEqual(data.result.externalId, testData.lineItems.display.externalId);
110
+ });
111
+
112
+ it('should update line item', async () => {
113
+ const { status, data } = await request('PUT', `/deals/${testData.deals.guaranteed.dealId}/line-items/${testData.lineItems.display.externalId}`, {
114
+ name: 'Updated Display Line Item',
115
+ programmatic: { bidFloor: 6.50 }
116
+ });
117
+ assert.strictEqual(status, 200);
118
+ assert.ok(data.result.version >= 1);
119
+ const { data: getResult } = await request('GET', `/deals/${testData.deals.guaranteed.dealId}/line-items/${testData.lineItems.display.externalId}`);
120
+ assert.strictEqual(getResult.result.name, 'Updated Display Line Item');
121
+ });
122
+
123
+ it('should assign DISPLAY creative with matching resolution', async () => {
124
+ const creativePayload = {
125
+ creativeId: `e2e-creative-display-${Date.now()}`,
126
+ creativeUri: 'https://cdn.example.com/ad.jpg',
127
+ creativeType: 'DISPLAY',
128
+ resolution: '1920x1080',
129
+ thumbnail: 'https://cdn.example.com/thumb.jpg'
130
+ };
131
+ const { status, data } = await request('POST', `/deals/${testData.deals.guaranteed.dealId}/line-items/${testData.lineItems.display.externalId}/creatives`, creativePayload);
132
+ assert.strictEqual(status, 201);
133
+ assert.strictEqual(data.result.status, 'PENDING');
134
+ testData.creatives.display = data.result;
135
+ });
136
+
137
+ it('should reject creative with mismatched resolution', async () => {
138
+ const { status, data } = await request('POST', `/deals/${testData.deals.guaranteed.dealId}/line-items/${testData.lineItems.display.externalId}/creatives`, {
139
+ creativeId: `e2e-creative-bad-res-${Date.now()}`,
140
+ creativeUri: 'https://cdn.example.com/ad2.jpg',
141
+ creativeType: 'DISPLAY',
142
+ resolution: '800x600'
143
+ });
144
+ assert.strictEqual(status, 400);
145
+ assert.ok(data.message.includes('not in line item'));
146
+ });
147
+
148
+ it('should reject creative with mismatched type', async () => {
149
+ const { status, data } = await request('POST', `/deals/${testData.deals.guaranteed.dealId}/line-items/${testData.lineItems.display.externalId}/creatives`, {
150
+ creativeId: `e2e-creative-bad-type-${Date.now()}`,
151
+ creativeUri: 'https://cdn.example.com/ad.mp4',
152
+ creativeType: 'VIDEO',
153
+ resolution: '1920x1080',
154
+ duration: 30
155
+ });
156
+ assert.strictEqual(status, 400);
157
+ assert.ok(data.message.includes('type mismatch'));
158
+ });
159
+
160
+ it('should approve pending creative', async () => {
161
+ const { status, data } = await request('POST', `/deals/${testData.deals.guaranteed.dealId}/line-items/${testData.lineItems.display.externalId}/creatives/${testData.creatives.display.creativeId}`, {
162
+ decision: 'APPROVED',
163
+ reviewerId: 'reviewer-001',
164
+ reviewerName: 'John Reviewer'
165
+ });
166
+ assert.strictEqual(status, 200);
167
+ assert.strictEqual(data.result.status, 'APPROVED');
168
+ });
169
+
170
+ it('should not allow rejecting approved creative', async () => {
171
+ const { status, data } = await request('POST', `/deals/${testData.deals.guaranteed.dealId}/line-items/${testData.lineItems.display.externalId}/creatives/${testData.creatives.display.creativeId}`, {
172
+ decision: 'REJECTED',
173
+ reason: 'Changed my mind'
174
+ });
175
+ assert.strictEqual(status, 400);
176
+ assert.ok(data.message.includes('Cannot reject an approved'));
177
+ });
178
+
179
+ it('should get creatives by dealId', async () => {
180
+ const { status, data } = await request('GET', `/deals/${testData.deals.guaranteed.dealId}/creatives`);
181
+ assert.strictEqual(status, 200);
182
+ assert.ok(data.result.data.length >= 1);
183
+ });
184
+
185
+ it('should get creatives by lineItemId', async () => {
186
+ const { status, data } = await request('GET', `/deals/${testData.deals.guaranteed.dealId}/line-items/${testData.lineItems.display.externalId}/creatives`);
187
+ assert.strictEqual(status, 200);
188
+ assert.ok(data.result.data.length >= 1);
189
+ });
190
+
191
+ it('should get creative by creativeId', async () => {
192
+ const { status, data } = await request('GET', `/deals/${testData.deals.guaranteed.dealId}/line-items/${testData.lineItems.display.externalId}/creatives/${testData.creatives.display.creativeId}`);
193
+ assert.strictEqual(status, 200);
194
+ assert.strictEqual(data.result.creativeId, testData.creatives.display.creativeId);
195
+ });
196
+
197
+ it('should get creative by UUID', async () => {
198
+ const { status, data } = await request('GET', `/deals/${testData.deals.guaranteed.dealId}/line-items/${testData.lineItems.display.externalId}/creatives/${testData.creatives.display.id}`);
199
+ assert.strictEqual(status, 200);
200
+ });
201
+ });
202
+
203
+ describe('2. PROGRAMMATIC Mode - PREFERRED_DEAL', () => {
204
+ it('should create a PREFERRED_DEAL with CPS cost type', async () => {
205
+ const { status, data } = await request('POST', '/deals', {
206
+ name: 'E2E Preferred Deal Campaign',
207
+ externalId: `e2e-preferred-${Date.now()}`,
208
+ source: 'e2e-test',
209
+ mode: 'PROGRAMMATIC',
210
+ dealType: 'PREFERRED_DEAL',
211
+ currency: 'USD',
212
+ seller: { id: 'seller-001', name: 'Test Seller' },
213
+ programmatic: {
214
+ buyers: [{ id: 'buyer-002', name: 'Preferred Buyer' }],
215
+ transactionType: 'SPOT',
216
+ costType: 'CPS'
217
+ }
218
+ });
219
+ assert.strictEqual(status, 201);
220
+ assert.ok(data.result.dealId.startsWith('PD'));
221
+ testData.deals.preferred = data.result;
222
+ });
223
+
224
+ it('should verify PREFERRED_DEAL has auctionType 3', async () => {
225
+ const { status, data } = await request('GET', `/deals/${testData.deals.preferred.dealId}`);
226
+ assert.strictEqual(status, 200);
227
+ assert.strictEqual(data.result.programmatic.auctionType, 3);
228
+ });
229
+ });
230
+
231
+ describe('3. PROGRAMMATIC Mode - PRIVATE_AUCTION', () => {
232
+ it('should create a PRIVATE_AUCTION with first price auction', async () => {
233
+ const { status, data } = await request('POST', '/deals', {
234
+ name: 'E2E Private Auction Campaign',
235
+ externalId: `e2e-private-auction-${Date.now()}`,
236
+ source: 'e2e-test',
237
+ mode: 'PROGRAMMATIC',
238
+ dealType: 'PRIVATE_AUCTION',
239
+ currency: 'EUR',
240
+ seller: { id: 'seller-001', name: 'Test Seller' },
241
+ programmatic: {
242
+ buyers: [{ id: 'buyer-003', name: 'Auction Buyer' }],
243
+ auctionType: 1,
244
+ transactionType: 'AUDIENCE',
245
+ costType: 'CPM'
246
+ }
247
+ });
248
+ assert.strictEqual(status, 201);
249
+ assert.ok(data.result.dealId.startsWith('PA'));
250
+ testData.deals.privateAuction = data.result;
251
+ });
252
+
253
+ it('should verify PRIVATE_AUCTION has auctionType 1', async () => {
254
+ const { status, data } = await request('GET', `/deals/${testData.deals.privateAuction.dealId}`);
255
+ assert.strictEqual(status, 200);
256
+ assert.strictEqual(data.result.programmatic.auctionType, 1);
257
+ testData.deals.privateAuction = data.result;
258
+ });
259
+
260
+ it('should create VIDEO line item', async () => {
261
+ const { status, data } = await request('POST', `/deals/${testData.deals.privateAuction.dealId}/line-items`, {
262
+ externalId: `e2e-li-video-${Date.now()}`,
263
+ name: 'Video Line Item',
264
+ creativeType: 'VIDEO',
265
+ timezoneId: 'Europe/London',
266
+ startDate: '2025-02-01',
267
+ endDate: '2025-04-30',
268
+ resolutions: ['1920x1080'],
269
+ programmatic: {
270
+ duration: 30,
271
+ costType: 'CPM',
272
+ bidFloor: 10.00
273
+ }
274
+ });
275
+ assert.strictEqual(status, 201);
276
+ testData.lineItems.video = data.result;
277
+ });
278
+
279
+ it('should update PRIVATE_AUCTION deal status to APPROVED', async () => {
280
+ const { status, data } = await request('PUT', `/deals/${testData.deals.privateAuction.dealId}`, { status: 'APPROVED' });
281
+ assert.strictEqual(status, 200);
282
+ assert.strictEqual(data.result.status, 'APPROVED');
283
+ });
284
+
285
+ it('should assign VIDEO creative', async () => {
286
+ const { status, data } = await request('POST', `/deals/${testData.deals.privateAuction.dealId}/line-items/${testData.lineItems.video.externalId}/creatives`, {
287
+ creativeId: `e2e-creative-video-${Date.now()}`,
288
+ creativeUri: 'https://cdn.example.com/video.mp4',
289
+ creativeType: 'VIDEO',
290
+ resolution: '1920x1080',
291
+ duration: 30,
292
+ mimeType: 'video/mp4'
293
+ });
294
+ assert.strictEqual(status, 201);
295
+ testData.creatives.video = data.result;
296
+ });
297
+
298
+ it('should reject VIDEO creative', async () => {
299
+ const { status, data } = await request('POST', `/deals/${testData.deals.privateAuction.dealId}/line-items/${testData.lineItems.video.externalId}/creatives/${testData.creatives.video.creativeId}`, {
300
+ decision: 'REJECTED',
301
+ reviewerId: 'reviewer-002',
302
+ reviewerName: 'Jane Reviewer',
303
+ reason: 'Video quality is too low'
304
+ });
305
+ assert.strictEqual(status, 200);
306
+ assert.strictEqual(data.result.status, 'REJECTED');
307
+ });
308
+
309
+ it('should not allow approving rejected creative', async () => {
310
+ const { status, data } = await request('POST', `/deals/${testData.deals.privateAuction.dealId}/line-items/${testData.lineItems.video.externalId}/creatives/${testData.creatives.video.creativeId}`, {
311
+ decision: 'APPROVED'
312
+ });
313
+ assert.strictEqual(status, 400);
314
+ assert.ok(data.message.includes('Cannot approve a rejected'));
315
+ });
316
+ });
317
+
318
+ describe('4. PROGRAMMATIC Mode - EVERGREEN_PMP', () => {
319
+ it('should create an EVERGREEN_PMP with second price auction', async () => {
320
+ const { status, data } = await request('POST', '/deals', {
321
+ name: 'E2E Evergreen PMP Campaign',
322
+ externalId: `e2e-evergreen-${Date.now()}`,
323
+ source: 'e2e-test',
324
+ mode: 'PROGRAMMATIC',
325
+ dealType: 'EVERGREEN_PMP',
326
+ currency: 'GBP',
327
+ seller: { id: 'seller-002', name: 'UK Seller' },
328
+ programmatic: {
329
+ buyers: [{ id: 'buyer-004', name: 'Evergreen Buyer' }],
330
+ auctionType: 2,
331
+ transactionType: 'SPOT',
332
+ costType: 'CPD'
333
+ }
334
+ });
335
+ assert.strictEqual(status, 201);
336
+ assert.ok(data.result.dealId.startsWith('EG'));
337
+ testData.deals.evergreen = data.result;
338
+ });
339
+
340
+ it('should verify EVERGREEN_PMP has auctionType 2', async () => {
341
+ const { status, data } = await request('GET', `/deals/${testData.deals.evergreen.dealId}`);
342
+ assert.strictEqual(status, 200);
343
+ assert.strictEqual(data.result.programmatic.auctionType, 2);
344
+ testData.deals.evergreen = data.result;
345
+ });
346
+
347
+ it('should update EVERGREEN_PMP deal status to APPROVED', async () => {
348
+ const { status, data } = await request('PUT', `/deals/${testData.deals.evergreen.dealId}`, { status: 'APPROVED' });
349
+ assert.strictEqual(status, 200);
350
+ assert.strictEqual(data.result.status, 'APPROVED');
351
+ });
352
+
353
+ it('should create AUDIO line item', async () => {
354
+ const { status, data } = await request('POST', `/deals/${testData.deals.evergreen.dealId}/line-items`, {
355
+ externalId: `e2e-li-audio-${Date.now()}`,
356
+ name: 'Audio Line Item',
357
+ creativeType: 'AUDIO',
358
+ timezoneId: 'Europe/London',
359
+ startDate: '2025-01-15',
360
+ endDate: '2025-06-30',
361
+ programmatic: {
362
+ duration: 60,
363
+ costType: 'CPD',
364
+ bidFloor: 2.00
365
+ }
366
+ });
367
+ assert.strictEqual(status, 201);
368
+ testData.lineItems.audio = data.result;
369
+ });
370
+
371
+ it('should assign AUDIO creative (no resolution required)', async () => {
372
+ const { status, data } = await request('POST', `/deals/${testData.deals.evergreen.dealId}/line-items/${testData.lineItems.audio.externalId}/creatives`, {
373
+ creativeId: `e2e-creative-audio-${Date.now()}`,
374
+ creativeUri: 'https://cdn.example.com/audio.mp3',
375
+ creativeType: 'AUDIO',
376
+ duration: 60,
377
+ mimeType: 'audio/mpeg'
378
+ });
379
+ assert.strictEqual(status, 201);
380
+ testData.creatives.audio = data.result;
381
+ });
382
+
383
+ it('should approve AUDIO creative', async () => {
384
+ const { status, data } = await request('POST', `/deals/${testData.deals.evergreen.dealId}/line-items/${testData.lineItems.audio.externalId}/creatives/${testData.creatives.audio.creativeId}`, {
385
+ decision: 'APPROVED'
386
+ });
387
+ assert.strictEqual(status, 200);
388
+ assert.strictEqual(data.result.status, 'APPROVED');
389
+ });
390
+ });
391
+
392
+ describe('5. DIRECT Mode', () => {
393
+ it('should create a DIRECT deal', async () => {
394
+ const payload = {
395
+ name: 'E2E Direct Campaign',
396
+ externalId: `e2e-direct-${Date.now()}`,
397
+ source: 'e2e-test',
398
+ mode: 'DIRECT',
399
+ currency: 'USD',
400
+ seller: { id: 'seller-003', name: 'Direct Seller' },
401
+ direct: {
402
+ brand: 'Test Brand',
403
+ clientType: 'AGENCY',
404
+ approvalEmails: ['approver@test.com'],
405
+ marketSelection: { country: 'US', currency: 'USD' },
406
+ budgetSetup: { currency: 'USD', budgetAmount: 10000 },
407
+ campaignGoal: { type: 'IMPRESSIONS', targetValue: 500000 }
408
+ }
409
+ };
410
+ const { status, data } = await request('POST', '/deals', payload);
411
+ assert.strictEqual(status, 201, `Failed: ${JSON.stringify(data)}`);
412
+ assert.ok(data.result.dealId.startsWith('DIR'));
413
+ testData.deals.direct = data.result;
414
+ });
415
+
416
+ it('should verify DIRECT deal has correct mode and type', async () => {
417
+ const { status, data } = await request('GET', `/deals/${testData.deals.direct.dealId}`);
418
+ assert.strictEqual(status, 200);
419
+ assert.strictEqual(data.result.mode, 'DIRECT');
420
+ assert.strictEqual(data.result.dealType, 'DIRECT');
421
+ testData.deals.direct = data.result;
422
+ });
423
+
424
+ it('should create DIRECT line item with comprehensive targeting (demographics, venueTypes, geofencing)', async () => {
425
+ const { status, data } = await request('POST', `/deals/${testData.deals.direct.dealId}/line-items`, {
426
+ externalId: `e2e-li-direct-${Date.now()}`,
427
+ name: 'Direct Display Line Item with Full Targeting',
428
+ creativeType: 'DISPLAY',
429
+ timezoneId: 'America/Los_Angeles',
430
+ startDate: '2025-03-01',
431
+ endDate: '2025-05-31',
432
+ resolutions: ['1920x1080', '3840x2160'],
433
+ targeting: {
434
+ demographics: {
435
+ ageGroups: ['25-34', '35-44'],
436
+ genders: ['male', 'female'],
437
+ incomeGroups: ['middle', 'upper-middle'],
438
+ interests: ['technology', 'automotive'],
439
+ audienceBehaviour: ['daily_commuter', 'frequent_shopper']
440
+ },
441
+ venueTypes: [
442
+ 'transit.train_stations',
443
+ 'transit.train_stations.platform',
444
+ 'retail.shopping_mall'
445
+ ],
446
+ geofencing: {
447
+ geometrics: [
448
+ {
449
+ type: 'Polygon',
450
+ coordinates: [[[-118.25, 34.04], [-118.24, 34.04], [-118.24, 34.05], [-118.25, 34.05], [-118.25, 34.04]]],
451
+ included: true
452
+ }
453
+ ],
454
+ locations: [
455
+ {
456
+ name: 'Downtown LA',
457
+ lat: 34.0522,
458
+ lng: -118.2437,
459
+ radius: 2000,
460
+ address: 'Los Angeles, CA',
461
+ metadata: { type: 'circle' },
462
+ included: true
463
+ }
464
+ ]
465
+ }
466
+ },
467
+ deliveryTargeting: {
468
+ signals: {
469
+ weather: {
470
+ conditions: ['sunny', 'partly_cloudy'],
471
+ temperature: { min: 20, max: 35, unit: 'celsius' }
472
+ }
473
+ }
474
+ },
475
+ metadata: {
476
+ inventoryFilteredBy: ['demographics', 'venueTypes', 'geofencing'],
477
+ deliveryTriggeredBy: ['weather'],
478
+ venueTaxonomyFormat: 'dot-notation',
479
+ version: '1.0'
480
+ },
481
+ direct: {
482
+ budgetSetup: { budgetType: 'DAILY', budgetAmount: 500, currency: 'USD' },
483
+ campaignGoal: { type: 'SHARE_OF_VOICE', targetValue: 25 }
484
+ }
485
+ });
486
+ assert.strictEqual(status, 201, `Failed: ${JSON.stringify(data)}`);
487
+ testData.lineItems.direct = data.result;
488
+ });
489
+
490
+ it('should return comprehensive targeting in DIRECT line item response', async () => {
491
+ const { status, data } = await request('GET', `/deals/${testData.deals.direct.dealId}/line-items/${testData.lineItems.direct.externalId}`);
492
+ assert.strictEqual(status, 200);
493
+
494
+ assert.ok(data.result.targeting, 'Expected targeting in response');
495
+ assert.ok(data.result.targeting.demographics, 'Expected demographics in targeting');
496
+ assert.deepStrictEqual(data.result.targeting.demographics.ageGroups, ['25-34', '35-44']);
497
+ assert.ok(data.result.targeting.venueTypes, 'Expected venueTypes in targeting');
498
+ assert.ok(data.result.targeting.venueTypes.includes('transit.train_stations'));
499
+ assert.ok(data.result.targeting.geofencing, 'Expected geofencing in targeting');
500
+ assert.ok(data.result.targeting.geofencing.locations.length > 0, 'Expected locations in geofencing');
501
+
502
+ assert.ok(data.result.deliveryTargeting, 'Expected deliveryTargeting in response');
503
+ assert.ok(data.result.deliveryTargeting.signals, 'Expected signals in deliveryTargeting');
504
+ assert.ok(data.result.deliveryTargeting.signals.weather, 'Expected weather signal');
505
+
506
+ assert.ok(data.result.metadata, 'Expected metadata in response');
507
+ assert.strictEqual(data.result.metadata.version, '1.0');
508
+ assert.strictEqual(data.result.metadata.venueTaxonomyFormat, 'dot-notation');
509
+ });
510
+
511
+ it('should update DIRECT line item targeting', async () => {
512
+ const { status, data } = await request('PUT', `/deals/${testData.deals.direct.dealId}/line-items/${testData.lineItems.direct.externalId}`, {
513
+ targeting: {
514
+ demographics: {
515
+ ageGroups: ['18-24', '25-34', '35-44'],
516
+ genders: ['male', 'female'],
517
+ incomeGroups: ['high'],
518
+ interests: ['luxury', 'travel'],
519
+ audienceBehaviour: ['premium_consumer']
520
+ },
521
+ venueTypes: ['transit.airports', 'transit.airports.lounge'],
522
+ geofencing: {
523
+ locations: [
524
+ {
525
+ name: 'LAX Airport',
526
+ lat: 33.9425,
527
+ lng: -118.4081,
528
+ radius: 5000,
529
+ address: 'Los Angeles International Airport',
530
+ metadata: { type: 'place' },
531
+ included: true
532
+ }
533
+ ]
534
+ }
535
+ },
536
+ deliveryTargeting: {
537
+ signals: {
538
+ weather: { conditions: ['any'] }
539
+ }
540
+ },
541
+ metadata: {
542
+ inventoryFilteredBy: ['demographics', 'venueTypes', 'geofencing'],
543
+ deliveryTriggeredBy: ['weather'],
544
+ venueTaxonomyFormat: 'dot-notation',
545
+ version: '1.1'
546
+ }
547
+ });
548
+ assert.strictEqual(status, 200, `Failed: ${JSON.stringify(data)}`);
549
+
550
+ const getRes = await request('GET', `/deals/${testData.deals.direct.dealId}/line-items/${testData.lineItems.direct.externalId}`);
551
+ assert.ok(getRes.data.result.targeting.demographics.ageGroups.includes('18-24'), 'Expected updated ageGroups');
552
+ assert.ok(getRes.data.result.targeting.venueTypes.includes('transit.airports'), 'Expected updated venueTypes');
553
+ assert.strictEqual(getRes.data.result.metadata.version, '1.1', 'Expected updated metadata version');
554
+ });
555
+
556
+ it('should assign creative to DIRECT line item', async () => {
557
+ const { status, data } = await request('POST', `/deals/${testData.deals.direct.dealId}/line-items/${testData.lineItems.direct.externalId}/creatives`, {
558
+ creativeId: `e2e-creative-direct-${Date.now()}`,
559
+ creativeUri: 'https://cdn.example.com/direct-ad.jpg',
560
+ creativeType: 'DISPLAY',
561
+ resolution: '3840x2160'
562
+ });
563
+ assert.strictEqual(status, 201);
564
+ testData.creatives.direct = data.result;
565
+ });
566
+
567
+ it('should create DIRECT line item with planning data', async () => {
568
+ const planningPayload = {
569
+ externalId: `e2e-li-planning-${Date.now()}`,
570
+ name: 'Direct Line Item with Planning',
571
+ creativeType: 'DISPLAY',
572
+ timezoneId: 'America/New_York',
573
+ startDate: '2025-04-01',
574
+ endDate: '2025-04-30',
575
+ resolutions: ['1920x1080'],
576
+ direct: {
577
+ budgetSetup: { type: 'TOTAL', amount: 15000, currency: 'USD' },
578
+ campaignGoal: { type: 'IMPRESSIONS', target: 500000 },
579
+ planning: {
580
+ capacity: {
581
+ campaignDays: 30,
582
+ available: { slots: 238080, playTimeSec: 3571200, maxImpressions: 28000000 }
583
+ },
584
+ allocation: { slots: 5270, playTimeSec: 79050, sov: 0.04427, sot: 0.04379 },
585
+ estimates: { impressions: 1245000, reach: 285000, frequency: 4.37 },
586
+ pricing: { cpm: 2185.00, estimatedCost: 149299.10 }
587
+ }
588
+ }
589
+ };
590
+ const { status, data } = await request('POST', `/deals/${testData.deals.direct.dealId}/line-items`, planningPayload);
591
+ assert.strictEqual(status, 201, `Failed: ${JSON.stringify(data)}`);
592
+ testData.lineItems.directWithPlanning = data.result;
593
+ });
594
+
595
+ it('should return planning data in DIRECT line item response', async () => {
596
+ const { status, data } = await request('GET', `/deals/${testData.deals.direct.dealId}/line-items/${testData.lineItems.directWithPlanning.externalId}`);
597
+ assert.strictEqual(status, 200);
598
+ assert.ok(data.result.direct, 'Expected direct object in response');
599
+ assert.ok(data.result.direct.planning, 'Expected planning in direct object');
600
+ assert.ok(data.result.direct.planning.capacity, 'Expected capacity in planning');
601
+ assert.strictEqual(data.result.direct.planning.capacity.campaignDays, 30);
602
+ assert.strictEqual(data.result.direct.planning.allocation.slots, 5270);
603
+ assert.strictEqual(data.result.direct.planning.estimates.impressions, 1245000);
604
+ });
605
+
606
+ it('should update DIRECT line item planning data', async () => {
607
+ const { status, data } = await request('PUT', `/deals/${testData.deals.direct.dealId}/line-items/${testData.lineItems.directWithPlanning.externalId}`, {
608
+ direct: {
609
+ planning: {
610
+ allocation: { slots: 6000, playTimeSec: 90000, sov: 0.05, sot: 0.05 },
611
+ estimates: { impressions: 1500000, reach: 300000, frequency: 5.0 },
612
+ pricing: { cpm: 2000.00, estimatedCost: 180000.00 }
613
+ }
614
+ }
615
+ });
616
+ assert.strictEqual(status, 200, `Failed: ${JSON.stringify(data)}`);
617
+ });
618
+
619
+ it('should verify updated planning data persisted', async () => {
620
+ const { status, data } = await request('GET', `/deals/${testData.deals.direct.dealId}/line-items/${testData.lineItems.directWithPlanning.externalId}`);
621
+ assert.strictEqual(status, 200);
622
+ assert.strictEqual(data.result.direct.planning.allocation.slots, 6000);
623
+ assert.strictEqual(data.result.direct.planning.estimates.impressions, 1500000);
624
+ assert.strictEqual(data.result.direct.planning.pricing.estimatedCost, 180000.00);
625
+ });
626
+
627
+ it('should aggregate planning from line items to deal level', async () => {
628
+ const { status, data } = await request('GET', `/deals/${testData.deals.direct.dealId}`);
629
+ assert.strictEqual(status, 200);
630
+ assert.ok(data.result.direct, 'Expected direct object in deal response');
631
+ assert.ok(data.result.direct.planning, 'Expected aggregated planning in deal');
632
+ assert.ok(data.result.direct.planning.allocation, 'Expected allocation in aggregated planning');
633
+ assert.ok(data.result.direct.planning.estimates, 'Expected estimates in aggregated planning');
634
+ });
635
+
636
+ it('should create second DIRECT line item with different planning for aggregation test', async () => {
637
+ const planningPayload = {
638
+ externalId: `e2e-li-planning-2-${Date.now()}`,
639
+ name: 'Second Direct Line Item with Planning',
640
+ creativeType: 'DISPLAY',
641
+ timezoneId: 'America/New_York',
642
+ startDate: '2025-04-01',
643
+ endDate: '2025-04-30',
644
+ resolutions: ['1920x1080'],
645
+ direct: {
646
+ budgetSetup: { type: 'TOTAL', amount: 10000, currency: 'USD' },
647
+ campaignGoal: { type: 'IMPRESSIONS', target: 300000 },
648
+ planning: {
649
+ capacity: {
650
+ campaignDays: 30,
651
+ available: { slots: 100000, playTimeSec: 1500000, maxImpressions: 12000000 }
652
+ },
653
+ allocation: { slots: 4000, playTimeSec: 60000, sov: 0.04, sot: 0.04 },
654
+ estimates: { impressions: 500000, reach: 100000, frequency: 5.0 },
655
+ pricing: { cpm: 2400.00, estimatedCost: 120000.00 }
656
+ }
657
+ }
658
+ };
659
+ const { status, data } = await request('POST', `/deals/${testData.deals.direct.dealId}/line-items`, planningPayload);
660
+ assert.strictEqual(status, 201, `Failed: ${JSON.stringify(data)}`);
661
+ testData.lineItems.directWithPlanning2 = data.result;
662
+ });
663
+
664
+ it('should correctly aggregate planning from multiple line items', async () => {
665
+ const { status, data } = await request('GET', `/deals/${testData.deals.direct.dealId}`);
666
+ assert.strictEqual(status, 200);
667
+ const planning = data.result.direct.planning;
668
+ assert.ok(planning, 'Expected aggregated planning');
669
+ assert.ok(planning.estimates.impressions > 0, 'Aggregated impressions should be positive');
670
+ assert.ok(planning.allocation.slots > 0, 'Aggregated slots should be positive');
671
+ assert.ok(planning.pricing.estimatedCost > 0, 'Aggregated estimated cost should be positive');
672
+ assert.ok(planning.pricing.cpm > 0, 'Aggregated CPM should be calculated');
673
+ assert.ok(planning.estimates.frequency > 0, 'Aggregated frequency should be calculated');
674
+ });
675
+ });
676
+
677
+ describe('6. Deal List Filtering', () => {
678
+ it('should get all deals', async () => {
679
+ const { status, data } = await request('GET', '/deals');
680
+ assert.strictEqual(status, 200);
681
+ assert.ok(Array.isArray(data.result.data));
682
+ });
683
+
684
+ it('should filter deals by mode=PROGRAMMATIC', async () => {
685
+ const { status, data } = await request('GET', '/deals?mode=PROGRAMMATIC');
686
+ assert.strictEqual(status, 200);
687
+ assert.ok(Array.isArray(data.result.data));
688
+ data.result.data.forEach(deal => assert.strictEqual(deal.mode, 'PROGRAMMATIC'));
689
+ });
690
+
691
+ it('should filter deals by mode=DIRECT', async () => {
692
+ const { status, data } = await request('GET', '/deals?mode=DIRECT');
693
+ assert.strictEqual(status, 200);
694
+ assert.ok(Array.isArray(data.result.data));
695
+ data.result.data.forEach(deal => assert.strictEqual(deal.mode, 'DIRECT'));
696
+ });
697
+
698
+ it('should filter deals by dealType=GUARANTEED', async () => {
699
+ const { status, data } = await request('GET', '/deals?dealType=GUARANTEED');
700
+ assert.strictEqual(status, 200);
701
+ assert.ok(Array.isArray(data.result.data));
702
+ data.result.data.forEach(deal => assert.strictEqual(deal.dealType, 'GUARANTEED'));
703
+ });
704
+
705
+ it('should filter deals by source', async () => {
706
+ const { status, data } = await request('GET', '/deals?source=e2e-test');
707
+ assert.strictEqual(status, 200);
708
+ assert.ok(Array.isArray(data.result.data));
709
+ });
710
+
711
+ it('should search deals by name', async () => {
712
+ const { status, data } = await request('GET', '/deals?search=Evergreen');
713
+ assert.strictEqual(status, 200);
714
+ assert.ok(Array.isArray(data.result.data));
715
+ });
716
+
717
+ it('should filter deals by currency', async () => {
718
+ const { status, data } = await request('GET', '/deals?currency=EUR');
719
+ assert.strictEqual(status, 200);
720
+ assert.ok(Array.isArray(data.result.data));
721
+ data.result.data.forEach(deal => assert.strictEqual(deal.currency, 'EUR'));
722
+ });
723
+ });
724
+
725
+ describe('7. Validation Rules', () => {
726
+ it('should reject GUARANTEED deal with CPS cost type', async () => {
727
+ const { status, data } = await request('POST', '/deals', {
728
+ name: 'Invalid GUARANTEED',
729
+ externalId: `invalid-gd-${Date.now()}`,
730
+ source: 'e2e-test',
731
+ mode: 'PROGRAMMATIC',
732
+ dealType: 'GUARANTEED',
733
+ currency: 'USD',
734
+ seller: { id: 'seller-001', name: 'Test' },
735
+ programmatic: {
736
+ buyers: [{ id: 'b1', name: 'Buyer' }],
737
+ costType: 'CPS'
738
+ }
739
+ });
740
+ assert.strictEqual(status, 400);
741
+ });
742
+
743
+ it('should reject PRIVATE_AUCTION with auctionType 3', async () => {
744
+ const { status, data } = await request('POST', '/deals', {
745
+ name: 'Invalid Auction Type',
746
+ externalId: `invalid-pa-${Date.now()}`,
747
+ source: 'e2e-test',
748
+ mode: 'PROGRAMMATIC',
749
+ dealType: 'PRIVATE_AUCTION',
750
+ currency: 'USD',
751
+ seller: { id: 'seller-001', name: 'Test' },
752
+ programmatic: {
753
+ buyers: [{ id: 'b1', name: 'Buyer' }],
754
+ auctionType: 3,
755
+ costType: 'CPM'
756
+ }
757
+ });
758
+ assert.strictEqual(status, 400);
759
+ });
760
+
761
+ it('should auto-set dealType to DIRECT for DIRECT mode', async () => {
762
+ const { status, data } = await request('POST', '/deals', {
763
+ name: 'Auto Direct Type',
764
+ externalId: `e2e-auto-direct-${Date.now()}`,
765
+ source: 'e2e-test',
766
+ mode: 'DIRECT',
767
+ currency: 'USD',
768
+ seller: { id: 'seller-001', name: 'Test' },
769
+ direct: {
770
+ brand: 'Auto Brand',
771
+ clientType: 'DIRECT_ADVERTISER',
772
+ approvalEmails: ['test@test.com'],
773
+ marketSelection: { country: 'US', currency: 'USD' },
774
+ budgetSetup: { currency: 'USD', budgetAmount: 5000 },
775
+ campaignGoal: { type: 'IMPRESSIONS', targetValue: 100000 }
776
+ }
777
+ });
778
+ assert.strictEqual(status, 201, `Failed: ${JSON.stringify(data)}`);
779
+ const { data: getData } = await request('GET', `/deals/${data.result.dealId}`);
780
+ assert.strictEqual(getData.result.dealType, 'DIRECT');
781
+ });
782
+ });
783
+
784
+ describe('8. Inventory Assignment (via Line Item creation)', () => {
785
+ const inventoryTestData = { inv1Id: null, inv2Id: null, inv3Id: null };
786
+
787
+ it('should create line item with inventories including deviceId and optional metadata.externalRefIds', async () => {
788
+ if (!testData.deals.guaranteed?.dealId) {
789
+ assert.ok(true, 'Skipping - requires prior tests');
790
+ return;
791
+ }
792
+ const ts = Date.now();
793
+ inventoryTestData.inv1Id = `inv-e2e-${ts}-001`;
794
+ inventoryTestData.inv2Id = `inv-e2e-${ts}-002`;
795
+ inventoryTestData.inv3Id = `inv-e2e-${ts}-003`;
796
+
797
+ const lineItemPayload = {
798
+ externalId: `e2e-li-with-inv-${ts}`,
799
+ name: 'Line Item with Inventories',
800
+ creativeType: 'DISPLAY',
801
+ timezoneId: 'America/New_York',
802
+ startDate: '2025-01-01',
803
+ endDate: '2025-03-31',
804
+ resolutions: ['1920x1080'],
805
+ thresholdCountPerDay: 100,
806
+ programmatic: {
807
+ costType: 'CPM',
808
+ bidFloor: 5.00
809
+ },
810
+ inventories: [
811
+ {
812
+ id: inventoryTestData.inv1Id,
813
+ deviceId: 'device-e2e-001',
814
+ name: 'E2E Test Screen 1',
815
+ publisher: { id: 'pub-001', name: 'Test Publisher' },
816
+ size: '1920x1080',
817
+ venueType: 'RETAIL',
818
+ countryIso2: 'US',
819
+ metadata: {
820
+ externalRefIds: [{ source: 'LMX', externalRefId: 'lmx-e2e-001' }]
821
+ }
822
+ },
823
+ {
824
+ id: inventoryTestData.inv2Id,
825
+ deviceId: 'device-e2e-002',
826
+ name: 'E2E Test Screen 2',
827
+ publisher: { id: 'pub-001', name: 'Test Publisher' },
828
+ size: '1920x1080',
829
+ venueType: 'TRANSIT',
830
+ countryIso2: 'US',
831
+ metadata: {
832
+ externalRefIds: [
833
+ { source: 'LMX', externalRefId: 'lmx-e2e-002' },
834
+ { source: 'BROADSIGN', externalRefId: 'bs-e2e-002' }
835
+ ]
836
+ }
837
+ },
838
+ {
839
+ id: inventoryTestData.inv3Id,
840
+ deviceId: 'device-e2e-003',
841
+ name: 'E2E Test Screen 3 - No Metadata',
842
+ publisher: { id: 'pub-001', name: 'Test Publisher' },
843
+ size: '1920x1080',
844
+ venueType: 'RETAIL',
845
+ countryIso2: 'US'
846
+ }
847
+ ]
848
+ };
849
+ const { status, data } = await request('POST', `/deals/${testData.deals.guaranteed.dealId}/line-items`, lineItemPayload);
850
+ assert.strictEqual(status, 201);
851
+ testData.lineItems.withInventory = data.result;
852
+ });
853
+
854
+ it('should get inventories by line item and verify deviceId and metadata.externalRefIds are persisted', async () => {
855
+ if (!testData.deals.guaranteed?.dealId || !testData.lineItems.withInventory?.externalId) {
856
+ assert.ok(true, 'Skipping - requires prior tests');
857
+ return;
858
+ }
859
+ const { status, data } = await request('GET', `/deals/${testData.deals.guaranteed.dealId}/line-items/${testData.lineItems.withInventory.externalId}/inventories`);
860
+ assert.strictEqual(status, 200);
861
+ assert.ok(data.result.data.length >= 3, 'Should have at least 3 inventories');
862
+
863
+ const inv1 = data.result.data.find(i => i.id === inventoryTestData.inv1Id);
864
+ const inv2 = data.result.data.find(i => i.id === inventoryTestData.inv2Id);
865
+ const inv3 = data.result.data.find(i => i.id === inventoryTestData.inv3Id);
866
+
867
+ assert.ok(inv1, `Should find inventory with id ${inventoryTestData.inv1Id}`);
868
+ assert.ok(inv2, `Should find inventory with id ${inventoryTestData.inv2Id}`);
869
+ assert.ok(inv3, `Should find inventory with id ${inventoryTestData.inv3Id}`);
870
+
871
+ // Verify deviceId persistence
872
+ assert.strictEqual(inv1.deviceId, 'device-e2e-001', 'Inventory 1 should have deviceId device-e2e-001');
873
+ assert.strictEqual(inv2.deviceId, 'device-e2e-002', 'Inventory 2 should have deviceId device-e2e-002');
874
+ assert.strictEqual(inv3.deviceId, 'device-e2e-003', 'Inventory 3 should have deviceId device-e2e-003');
875
+
876
+ // Verify metadata with externalRefIds (single entry)
877
+ assert.ok(inv1.metadata, 'Inventory 1 should have metadata');
878
+ assert.ok(Array.isArray(inv1.metadata.externalRefIds), 'Inventory 1 externalRefIds should be an array');
879
+ assert.strictEqual(inv1.metadata.externalRefIds.length, 1, 'Inventory 1 should have 1 external ref');
880
+ assert.strictEqual(inv1.metadata.externalRefIds[0].source, 'LMX', 'Inventory 1 externalRefIds[0].source should be LMX');
881
+ assert.strictEqual(inv1.metadata.externalRefIds[0].externalRefId, 'lmx-e2e-001', 'Inventory 1 externalRefIds[0].externalRefId should be lmx-e2e-001');
882
+
883
+ // Verify metadata with externalRefIds (multiple entries)
884
+ assert.ok(inv2.metadata, 'Inventory 2 should have metadata');
885
+ assert.ok(Array.isArray(inv2.metadata.externalRefIds), 'Inventory 2 externalRefIds should be an array');
886
+ assert.strictEqual(inv2.metadata.externalRefIds.length, 2, 'Inventory 2 should have 2 external refs');
887
+ assert.ok(inv2.metadata.externalRefIds.some(r => r.source === 'LMX'), 'Inventory 2 should have LMX ref');
888
+ assert.ok(inv2.metadata.externalRefIds.some(r => r.source === 'BROADSIGN'), 'Inventory 2 should have BROADSIGN ref');
889
+
890
+ // Verify inventory without metadata is accepted (metadata is optional)
891
+ assert.ok(inv3, 'Inventory 3 without metadata should be created successfully');
892
+ assert.strictEqual(inv3.name, 'E2E Test Screen 3 - No Metadata', 'Inventory 3 should have correct name');
893
+ // metadata may be undefined/null or empty object - all are valid
894
+ const hasNoExternalRefs = !inv3.metadata || !inv3.metadata.externalRefIds || inv3.metadata.externalRefIds.length === 0;
895
+ assert.ok(hasNoExternalRefs, 'Inventory 3 should have no externalRefIds');
896
+ });
897
+
898
+ it('should allow same inventory to be assigned to multiple line items', async () => {
899
+ if (!testData.deals.guaranteed?.dealId) {
900
+ assert.ok(true, 'Skipping - requires prior tests');
901
+ return;
902
+ }
903
+ const sharedInventoryId = `shared-inv-${Date.now()}`;
904
+
905
+ const li1Res = await request('POST', `/deals/${testData.deals.guaranteed.dealId}/line-items`, {
906
+ externalId: `e2e-li-shared-inv-1-${Date.now()}`,
907
+ name: 'Line Item 1 with Shared Inventory',
908
+ creativeType: 'DISPLAY',
909
+ timezoneId: 'UTC',
910
+ startDate: '2025-01-01',
911
+ endDate: '2025-12-31',
912
+ resolutions: ['1920x1080'],
913
+ thresholdCountPerDay: 50,
914
+ programmatic: { costType: 'CPM', bidFloor: 5.00 },
915
+ inventories: [{ id: sharedInventoryId, name: 'Shared Screen', size: '1920x1080' }]
916
+ });
917
+ assert.strictEqual(li1Res.status, 201, 'First line item should be created');
918
+
919
+ const li2Res = await request('POST', `/deals/${testData.deals.guaranteed.dealId}/line-items`, {
920
+ externalId: `e2e-li-shared-inv-2-${Date.now()}`,
921
+ name: 'Line Item 2 with Shared Inventory',
922
+ creativeType: 'DISPLAY',
923
+ timezoneId: 'UTC',
924
+ startDate: '2025-01-01',
925
+ endDate: '2025-12-31',
926
+ resolutions: ['1920x1080'],
927
+ thresholdCountPerDay: 50,
928
+ programmatic: { costType: 'CPM', bidFloor: 5.00 },
929
+ inventories: [{ id: sharedInventoryId, name: 'Shared Screen', size: '1920x1080' }]
930
+ });
931
+ assert.strictEqual(li2Res.status, 201, 'Second line item with same inventory should be created');
932
+
933
+ const inv1Res = await request('GET', `/deals/${testData.deals.guaranteed.dealId}/line-items/${li1Res.data.result.externalId}/inventories`);
934
+ const inv2Res = await request('GET', `/deals/${testData.deals.guaranteed.dealId}/line-items/${li2Res.data.result.externalId}/inventories`);
935
+
936
+ assert.ok(inv1Res.data.result.data.some(i => i.id === sharedInventoryId), 'First line item should have shared inventory');
937
+ assert.ok(inv2Res.data.result.data.some(i => i.id === sharedInventoryId), 'Second line item should have shared inventory');
938
+ });
939
+ });
940
+
941
+ describe('9. Schedule Array Tests', () => {
942
+ const scheduleTestDealId = { value: null };
943
+
944
+ it('should create a deal for schedule testing', async () => {
945
+ const { status, data } = await request('POST', '/deals', {
946
+ name: 'E2E Schedule Test Deal',
947
+ externalId: `e2e-schedule-${Date.now()}`,
948
+ source: 'e2e-test',
949
+ mode: 'PROGRAMMATIC',
950
+ dealType: 'GUARANTEED',
951
+ currency: 'USD',
952
+ seller: { id: 'seller-schedule', name: 'Schedule Test Seller' },
953
+ programmatic: {
954
+ buyers: [{ id: 'buyer-schedule', name: 'Schedule Buyer' }],
955
+ transactionType: 'SPOT',
956
+ costType: 'CPM'
957
+ }
958
+ });
959
+ assert.strictEqual(status, 201);
960
+ scheduleTestDealId.value = data.result.dealId;
961
+ });
962
+
963
+ it('should create line item with DEFAULT schedule type', async () => {
964
+ const externalId = `e2e-li-schedule-default-${Date.now()}`;
965
+ const { status, data } = await request('POST', `/deals/${scheduleTestDealId.value}/line-items`, {
966
+ externalId,
967
+ name: 'Line Item with DEFAULT schedule',
968
+ creativeType: 'DISPLAY',
969
+ timezoneId: 'America/New_York',
970
+ startDate: '2025-01-01',
971
+ endDate: '2025-12-31',
972
+ resolutions: ['1920x1080'],
973
+ programmatic: { costType: 'CPM', bidFloor: 5.00 },
974
+ thresholdCountPerDay: 50,
975
+ schedule: [
976
+ {
977
+ type: 'DEFAULT',
978
+ priority: 1,
979
+ hours: [{ start: 6, end: 22 }]
980
+ }
981
+ ]
982
+ });
983
+ assert.strictEqual(status, 201, `Failed: ${JSON.stringify(data)}`);
984
+
985
+ const getRes = await request('GET', `/deals/${scheduleTestDealId.value}/line-items/${externalId}`);
986
+ assert.strictEqual(getRes.status, 200);
987
+ assert.ok(getRes.data.result.schedule, 'Expected schedule in response');
988
+ assert.strictEqual(getRes.data.result.schedule[0].type, 'DEFAULT');
989
+ });
990
+
991
+ it('should create line item with WEEKDAY schedule type', async () => {
992
+ const externalId = `e2e-li-schedule-weekday-${Date.now()}`;
993
+ const { status, data } = await request('POST', `/deals/${scheduleTestDealId.value}/line-items`, {
994
+ externalId,
995
+ name: 'Line Item with WEEKDAY schedule',
996
+ creativeType: 'DISPLAY',
997
+ timezoneId: 'Europe/London',
998
+ startDate: '2025-01-01',
999
+ endDate: '2025-12-31',
1000
+ resolutions: ['1920x1080'],
1001
+ programmatic: { costType: 'CPM', bidFloor: 6.00 },
1002
+ thresholdCountPerDay: 50,
1003
+ schedule: [
1004
+ {
1005
+ type: 'WEEKDAY',
1006
+ priority: 2,
1007
+ daysOfWeek: [1, 2, 3, 4, 5],
1008
+ hours: [{ start: 8, end: 18 }]
1009
+ }
1010
+ ]
1011
+ });
1012
+ assert.strictEqual(status, 201, `Failed: ${JSON.stringify(data)}`);
1013
+
1014
+ const getRes = await request('GET', `/deals/${scheduleTestDealId.value}/line-items/${externalId}`);
1015
+ assert.strictEqual(getRes.data.result.schedule[0].type, 'WEEKDAY');
1016
+ assert.deepStrictEqual(getRes.data.result.schedule[0].daysOfWeek, [1, 2, 3, 4, 5]);
1017
+ });
1018
+
1019
+ it('should create line item with WEEKEND schedule type', async () => {
1020
+ const externalId = `e2e-li-schedule-weekend-${Date.now()}`;
1021
+ const { status, data } = await request('POST', `/deals/${scheduleTestDealId.value}/line-items`, {
1022
+ externalId,
1023
+ name: 'Line Item with WEEKEND schedule',
1024
+ creativeType: 'DISPLAY',
1025
+ timezoneId: 'Asia/Tokyo',
1026
+ startDate: '2025-01-01',
1027
+ endDate: '2025-12-31',
1028
+ resolutions: ['1920x1080'],
1029
+ programmatic: { costType: 'CPM', bidFloor: 7.00 },
1030
+ thresholdCountPerDay: 50,
1031
+ schedule: [
1032
+ {
1033
+ type: 'WEEKEND',
1034
+ priority: 3,
1035
+ daysOfWeek: [6, 7],
1036
+ hours: [{ start: 10, end: 20 }]
1037
+ }
1038
+ ]
1039
+ });
1040
+ assert.strictEqual(status, 201, `Failed: ${JSON.stringify(data)}`);
1041
+
1042
+ const getRes = await request('GET', `/deals/${scheduleTestDealId.value}/line-items/${externalId}`);
1043
+ assert.strictEqual(getRes.data.result.schedule[0].type, 'WEEKEND');
1044
+ assert.deepStrictEqual(getRes.data.result.schedule[0].daysOfWeek, [6, 7]);
1045
+ });
1046
+
1047
+ it('should create line item with CUSTOM schedule type (specific date)', async () => {
1048
+ const externalId = `e2e-li-schedule-custom-${Date.now()}`;
1049
+ const { status, data } = await request('POST', `/deals/${scheduleTestDealId.value}/line-items`, {
1050
+ externalId,
1051
+ name: 'Line Item with CUSTOM schedule',
1052
+ creativeType: 'DISPLAY',
1053
+ timezoneId: 'America/Chicago',
1054
+ startDate: '2025-01-01',
1055
+ endDate: '2025-12-31',
1056
+ resolutions: ['1920x1080'],
1057
+ programmatic: { costType: 'CPM', bidFloor: 8.00 },
1058
+ thresholdCountPerDay: 50,
1059
+ schedule: [
1060
+ {
1061
+ type: 'CUSTOM',
1062
+ date: '2025-07-04',
1063
+ priority: 10,
1064
+ hours: [{ start: 0, end: 23 }]
1065
+ }
1066
+ ]
1067
+ });
1068
+ assert.strictEqual(status, 201, `Failed: ${JSON.stringify(data)}`);
1069
+
1070
+ const getRes = await request('GET', `/deals/${scheduleTestDealId.value}/line-items/${externalId}`);
1071
+ assert.strictEqual(getRes.data.result.schedule[0].type, 'CUSTOM');
1072
+ assert.strictEqual(getRes.data.result.schedule[0].date, '2025-07-04');
1073
+ });
1074
+
1075
+ it('should create line item with multiple schedule entries (mixed types)', async () => {
1076
+ const externalId = `e2e-li-schedule-mixed-${Date.now()}`;
1077
+ const { status, data } = await request('POST', `/deals/${scheduleTestDealId.value}/line-items`, {
1078
+ externalId,
1079
+ name: 'Line Item with mixed schedules',
1080
+ creativeType: 'DISPLAY',
1081
+ timezoneId: 'America/Los_Angeles',
1082
+ startDate: '2025-01-01',
1083
+ endDate: '2025-12-31',
1084
+ resolutions: ['1920x1080'],
1085
+ programmatic: { costType: 'CPM', bidFloor: 9.00 },
1086
+ thresholdCountPerDay: 50,
1087
+ schedule: [
1088
+ {
1089
+ type: 'DEFAULT',
1090
+ priority: 1,
1091
+ hours: [{ start: 8, end: 20 }]
1092
+ },
1093
+ {
1094
+ type: 'WEEKDAY',
1095
+ priority: 5,
1096
+ daysOfWeek: [1, 2, 3, 4, 5],
1097
+ hours: [{ start: 7, end: 19 }]
1098
+ },
1099
+ {
1100
+ type: 'WEEKEND',
1101
+ priority: 5,
1102
+ daysOfWeek: [6, 7],
1103
+ hours: [{ start: 10, end: 22 }]
1104
+ },
1105
+ {
1106
+ type: 'CUSTOM',
1107
+ date: '2025-12-25',
1108
+ priority: 100,
1109
+ hours: [{ start: 0, end: 12 }]
1110
+ }
1111
+ ]
1112
+ });
1113
+ assert.strictEqual(status, 201, `Failed: ${JSON.stringify(data)}`);
1114
+
1115
+ const getRes = await request('GET', `/deals/${scheduleTestDealId.value}/line-items/${externalId}`);
1116
+ assert.strictEqual(getRes.data.result.schedule.length, 4);
1117
+ const types = getRes.data.result.schedule.map(s => s.type);
1118
+ assert.ok(types.includes('DEFAULT'));
1119
+ assert.ok(types.includes('WEEKDAY'));
1120
+ assert.ok(types.includes('WEEKEND'));
1121
+ assert.ok(types.includes('CUSTOM'));
1122
+ });
1123
+
1124
+ it('should update line item schedule', async () => {
1125
+ const createRes = await request('POST', `/deals/${scheduleTestDealId.value}/line-items`, {
1126
+ externalId: `e2e-li-schedule-update-${Date.now()}`,
1127
+ name: 'Line Item to update schedule',
1128
+ creativeType: 'DISPLAY',
1129
+ timezoneId: 'UTC',
1130
+ startDate: '2025-01-01',
1131
+ endDate: '2025-12-31',
1132
+ resolutions: ['1920x1080'],
1133
+ programmatic: { costType: 'CPM', bidFloor: 5.00 },
1134
+ thresholdCountPerDay: 50,
1135
+ schedule: [
1136
+ { type: 'DEFAULT', priority: 1, hours: [{ start: 0, end: 23 }] }
1137
+ ]
1138
+ });
1139
+ assert.strictEqual(createRes.status, 201);
1140
+
1141
+ const { status, data } = await request('PUT', `/deals/${scheduleTestDealId.value}/line-items/${createRes.data.result.externalId}`, {
1142
+ schedule: [
1143
+ { type: 'WEEKDAY', priority: 2, daysOfWeek: [1, 2, 3, 4, 5], hours: [{ start: 9, end: 17 }] },
1144
+ { type: 'WEEKEND', priority: 2, daysOfWeek: [6, 7], hours: [{ start: 12, end: 18 }] }
1145
+ ]
1146
+ });
1147
+ assert.strictEqual(status, 200, `Failed: ${JSON.stringify(data)}`);
1148
+
1149
+ const getRes = await request('GET', `/deals/${scheduleTestDealId.value}/line-items/${createRes.data.result.externalId}`);
1150
+ assert.strictEqual(getRes.data.result.schedule.length, 2);
1151
+ });
1152
+
1153
+ it('should create line item with schedule with validity window', async () => {
1154
+ const externalId = `e2e-li-schedule-validity-${Date.now()}`;
1155
+ const { status, data } = await request('POST', `/deals/${scheduleTestDealId.value}/line-items`, {
1156
+ externalId,
1157
+ name: 'Line Item with schedule validity',
1158
+ creativeType: 'DISPLAY',
1159
+ timezoneId: 'America/New_York',
1160
+ startDate: '2025-01-01',
1161
+ endDate: '2025-12-31',
1162
+ resolutions: ['1920x1080'],
1163
+ programmatic: { costType: 'CPM', bidFloor: 5.00 },
1164
+ thresholdCountPerDay: 50,
1165
+ schedule: [
1166
+ {
1167
+ type: 'DEFAULT',
1168
+ priority: 1,
1169
+ validity: { startDate: '2025-06-01', endDate: '2025-08-31' },
1170
+ hours: [{ start: 6, end: 22 }]
1171
+ }
1172
+ ]
1173
+ });
1174
+ assert.strictEqual(status, 201, `Failed: ${JSON.stringify(data)}`);
1175
+
1176
+ const getRes = await request('GET', `/deals/${scheduleTestDealId.value}/line-items/${externalId}`);
1177
+ assert.ok(getRes.data.result.schedule[0].validity);
1178
+ assert.strictEqual(getRes.data.result.schedule[0].validity.startDate, '2025-06-01');
1179
+ assert.strictEqual(getRes.data.result.schedule[0].validity.endDate, '2025-08-31');
1180
+ });
1181
+
1182
+ it('should archive schedule test deal', async () => {
1183
+ const { status } = await request('DELETE', `/deals/${scheduleTestDealId.value}`);
1184
+ assert.ok([200, 404].includes(status));
1185
+ });
1186
+ });
1187
+
1188
+ describe('10. Auto-Calculation Edge Tests', () => {
1189
+ const autoCalcDealId = { value: null };
1190
+
1191
+ it('should create a deal for auto-calculation testing', async () => {
1192
+ const { status, data } = await request('POST', '/deals', {
1193
+ name: 'E2E Auto-Calc Test Deal',
1194
+ externalId: `e2e-autocalc-${Date.now()}`,
1195
+ source: 'e2e-test',
1196
+ mode: 'PROGRAMMATIC',
1197
+ dealType: 'GUARANTEED',
1198
+ currency: 'USD',
1199
+ seller: { id: 'seller-autocalc', name: 'Auto Calc Seller' },
1200
+ programmatic: {
1201
+ buyers: [{ id: 'buyer-autocalc', name: 'Auto Calc Buyer' }],
1202
+ transactionType: 'SPOT',
1203
+ costType: 'CPM'
1204
+ }
1205
+ });
1206
+ assert.strictEqual(status, 201);
1207
+ autoCalcDealId.value = data.result.dealId;
1208
+ });
1209
+
1210
+ it('should auto-calculate deal dates from line items', async () => {
1211
+ await request('POST', `/deals/${autoCalcDealId.value}/line-items`, {
1212
+ externalId: `e2e-li-autocalc-1-${Date.now()}`,
1213
+ name: 'Line Item 1 for auto-calc',
1214
+ creativeType: 'DISPLAY',
1215
+ timezoneId: 'America/New_York',
1216
+ startDate: '2025-03-15',
1217
+ endDate: '2025-06-30',
1218
+ resolutions: ['1920x1080'],
1219
+ thresholdCountPerDay: 50,
1220
+ programmatic: { costType: 'CPM', bidFloor: 5.00 }
1221
+ });
1222
+
1223
+ await request('POST', `/deals/${autoCalcDealId.value}/line-items`, {
1224
+ externalId: `e2e-li-autocalc-2-${Date.now()}`,
1225
+ name: 'Line Item 2 for auto-calc',
1226
+ creativeType: 'DISPLAY',
1227
+ timezoneId: 'America/New_York',
1228
+ startDate: '2025-01-01',
1229
+ endDate: '2025-04-30',
1230
+ resolutions: ['1920x1080'],
1231
+ thresholdCountPerDay: 50,
1232
+ programmatic: { costType: 'CPM', bidFloor: 6.00 }
1233
+ });
1234
+
1235
+ const { status, data } = await request('GET', `/deals/${autoCalcDealId.value}`);
1236
+ assert.strictEqual(status, 200);
1237
+ assert.strictEqual(data.result.startDate, '2025-01-01', 'Deal startDate should be earliest line item startDate');
1238
+ assert.strictEqual(data.result.endDate, '2025-06-30', 'Deal endDate should be latest line item endDate');
1239
+ });
1240
+
1241
+ it('should auto-calculate lineItemsCount from active line items', async () => {
1242
+ const { status, data } = await request('GET', `/deals/${autoCalcDealId.value}`);
1243
+ assert.strictEqual(status, 200);
1244
+ assert.strictEqual(data.result.lineItemsCount, 2, 'lineItemsCount should equal number of active line items');
1245
+ });
1246
+
1247
+ it('should auto-calculate timezoneId from first line item', async () => {
1248
+ const { status, data } = await request('GET', `/deals/${autoCalcDealId.value}`);
1249
+ assert.strictEqual(status, 200);
1250
+ assert.ok(data.result.timezoneId, 'Deal should have auto-calculated timezoneId');
1251
+ });
1252
+
1253
+ it('should auto-calculate unique publishers count from line item inventories', async () => {
1254
+ const ts = Date.now();
1255
+ const liRes = await request('POST', `/deals/${autoCalcDealId.value}/line-items`, {
1256
+ externalId: `e2e-li-autocalc-inv-${ts}`,
1257
+ name: 'Line Item with inventories for publisher count',
1258
+ creativeType: 'DISPLAY',
1259
+ timezoneId: 'America/Chicago',
1260
+ startDate: '2025-02-01',
1261
+ endDate: '2025-05-31',
1262
+ resolutions: ['1920x1080'],
1263
+ thresholdCountPerDay: 50,
1264
+ programmatic: { costType: 'CPM', bidFloor: 5.00 },
1265
+ inventories: [
1266
+ { id: `inv-ac-1-${ts}`, name: 'Screen 1', publisher: { id: 'pub-001', name: 'Publisher A' }, size: '1920x1080' },
1267
+ { id: `inv-ac-2-${ts}`, name: 'Screen 2', publisher: { id: 'pub-002', name: 'Publisher B' }, size: '1920x1080' },
1268
+ { id: `inv-ac-3-${ts}`, name: 'Screen 3', publisher: { id: 'pub-001', name: 'Publisher A' }, size: '1920x1080' }
1269
+ ]
1270
+ });
1271
+ assert.strictEqual(liRes.status, 201, `Failed: ${JSON.stringify(liRes.data)}`);
1272
+
1273
+ const { status, data } = await request('GET', `/deals/${autoCalcDealId.value}`);
1274
+ assert.strictEqual(status, 200);
1275
+ assert.ok(data.result.publishers !== undefined, 'Deal should have publishers field');
1276
+ assert.ok(Array.isArray(data.result.publishers), 'Publishers should be an array');
1277
+ assert.ok(data.result.publishers.length >= 2, `Publishers count should be at least 2 unique publishers, got: ${data.result.publishers.length}`);
1278
+ });
1279
+
1280
+ it('should auto-calculate programmatic metrics from line items', async () => {
1281
+ const li1 = await request('POST', `/deals/${autoCalcDealId.value}/line-items`, {
1282
+ externalId: `e2e-li-metrics-1-${Date.now()}`,
1283
+ name: 'Metrics Line Item 1',
1284
+ creativeType: 'DISPLAY',
1285
+ timezoneId: 'UTC',
1286
+ startDate: '2025-01-01',
1287
+ endDate: '2025-03-31',
1288
+ resolutions: ['1920x1080'],
1289
+ thresholdCountPerDay: 50,
1290
+ programmatic: { costType: 'CPM', bidFloor: 10.00, deliveryGoal: 100000, netCost: 1000 }
1291
+ });
1292
+
1293
+ const li2 = await request('POST', `/deals/${autoCalcDealId.value}/line-items`, {
1294
+ externalId: `e2e-li-metrics-2-${Date.now()}`,
1295
+ name: 'Metrics Line Item 2',
1296
+ creativeType: 'DISPLAY',
1297
+ timezoneId: 'UTC',
1298
+ startDate: '2025-01-01',
1299
+ endDate: '2025-03-31',
1300
+ resolutions: ['1920x1080'],
1301
+ thresholdCountPerDay: 50,
1302
+ programmatic: { costType: 'CPM', bidFloor: 15.00, deliveryGoal: 200000, netCost: 3000 }
1303
+ });
1304
+
1305
+ assert.strictEqual(li1.status, 201);
1306
+ assert.strictEqual(li2.status, 201);
1307
+
1308
+ const { status, data } = await request('GET', `/deals/${autoCalcDealId.value}`);
1309
+ assert.strictEqual(status, 200);
1310
+ assert.ok(data.result.programmatic, 'Deal should have programmatic object');
1311
+ assert.ok(data.result.programmatic.deliveryGoal >= 300000, 'Aggregated deliveryGoal should be at least 300,000');
1312
+ assert.ok(data.result.programmatic.netCost >= 4000, 'Aggregated netCost should be at least 4,000');
1313
+ });
1314
+
1315
+ it('should update deal auto-calculated fields when line item is archived', async () => {
1316
+ const liRes = await request('POST', `/deals/${autoCalcDealId.value}/line-items`, {
1317
+ externalId: `e2e-li-archive-test-${Date.now()}`,
1318
+ name: 'Line Item to Archive',
1319
+ creativeType: 'DISPLAY',
1320
+ timezoneId: 'UTC',
1321
+ startDate: '2025-01-01',
1322
+ endDate: '2025-12-31',
1323
+ resolutions: ['1920x1080'],
1324
+ thresholdCountPerDay: 50,
1325
+ programmatic: { costType: 'CPM', bidFloor: 5.00 }
1326
+ });
1327
+ assert.strictEqual(liRes.status, 201);
1328
+
1329
+ const beforeArchive = await request('GET', `/deals/${autoCalcDealId.value}`);
1330
+ const countBefore = beforeArchive.data.result.lineItemsCount;
1331
+
1332
+ await request('DELETE', `/deals/${autoCalcDealId.value}/line-items/${liRes.data.result.externalId}`);
1333
+
1334
+ const afterArchive = await request('GET', `/deals/${autoCalcDealId.value}`);
1335
+ assert.strictEqual(afterArchive.data.result.lineItemsCount, countBefore - 1, 'lineItemsCount should decrease after archiving line item');
1336
+ });
1337
+
1338
+ it('should archive auto-calculation test deal', async () => {
1339
+ const { status } = await request('DELETE', `/deals/${autoCalcDealId.value}`);
1340
+ assert.ok([200, 404].includes(status));
1341
+ });
1342
+ });
1343
+
1344
+ describe('11. DIRECT Targeting Edge Cases', () => {
1345
+ const targetingDealId = { value: null };
1346
+
1347
+ it('should create DIRECT deal for targeting edge tests', async () => {
1348
+ const { status, data } = await request('POST', '/deals', {
1349
+ name: 'Targeting Edge Test Deal',
1350
+ externalId: `e2e-targeting-edge-${Date.now()}`,
1351
+ source: 'e2e-test',
1352
+ mode: 'DIRECT',
1353
+ dealType: 'DIRECT',
1354
+ currency: 'MYR',
1355
+ seller: { id: 'seller-001', name: 'Test Seller' },
1356
+ direct: {
1357
+ brand: 'Targeting Test Brand',
1358
+ clientType: 'DIRECT_ADVERTISER',
1359
+ approvalEmails: ['test@example.com'],
1360
+ marketSelection: { country: 'MY', currency: 'MYR' },
1361
+ budgetSetup: { currency: 'MYR', budgetAmount: 50000 },
1362
+ campaignGoal: { type: 'IMPRESSIONS', targetValue: 100000 }
1363
+ }
1364
+ });
1365
+ assert.strictEqual(status, 201, `Failed: ${JSON.stringify(data)}`);
1366
+ targetingDealId.value = data.result.dealId;
1367
+ });
1368
+
1369
+ it('should accept DIRECT line item with empty targeting object', async () => {
1370
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1371
+ const { status, data } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1372
+ externalId: `e2e-li-empty-targeting-${Date.now()}`,
1373
+ name: 'Line Item with Empty Targeting',
1374
+ creativeType: 'DISPLAY',
1375
+ timezoneId: 'UTC',
1376
+ startDate: '2025-01-01',
1377
+ endDate: '2025-12-31',
1378
+ resolutions: ['1920x1080'],
1379
+ targeting: {}
1380
+ });
1381
+ assert.strictEqual(status, 201, `Should accept empty targeting: ${JSON.stringify(data)}`);
1382
+ });
1383
+
1384
+ it('should accept DIRECT line item with partial demographics', async () => {
1385
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1386
+ const { status, data } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1387
+ externalId: `e2e-li-partial-demo-${Date.now()}`,
1388
+ name: 'Line Item with Partial Demographics',
1389
+ creativeType: 'DISPLAY',
1390
+ timezoneId: 'UTC',
1391
+ startDate: '2025-01-01',
1392
+ endDate: '2025-12-31',
1393
+ resolutions: ['1920x1080'],
1394
+ targeting: {
1395
+ demographics: { ageGroups: ['25-34'] }
1396
+ }
1397
+ });
1398
+ assert.strictEqual(status, 201, `Should accept partial demographics: ${JSON.stringify(data)}`);
1399
+ });
1400
+
1401
+ it('should reject DIRECT line item with invalid gender value', async () => {
1402
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1403
+ const { status } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1404
+ externalId: `e2e-li-invalid-gender-${Date.now()}`,
1405
+ name: 'Line Item with Invalid Gender',
1406
+ creativeType: 'DISPLAY',
1407
+ timezoneId: 'UTC',
1408
+ startDate: '2025-01-01',
1409
+ endDate: '2025-12-31',
1410
+ resolutions: ['1920x1080'],
1411
+ targeting: {
1412
+ demographics: { genders: ['invalid_gender'] }
1413
+ }
1414
+ });
1415
+ assert.strictEqual(status, 400, 'Should reject invalid gender value');
1416
+ });
1417
+
1418
+ it('should accept DIRECT line item with venueTypes using dot-notation', async () => {
1419
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1420
+ const { status, data } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1421
+ externalId: `e2e-li-venue-dot-${Date.now()}`,
1422
+ name: 'Line Item with Dot-Notation VenueTypes',
1423
+ creativeType: 'DISPLAY',
1424
+ timezoneId: 'UTC',
1425
+ startDate: '2025-01-01',
1426
+ endDate: '2025-12-31',
1427
+ resolutions: ['1920x1080'],
1428
+ targeting: {
1429
+ venueTypes: ['transit.train_stations.platform', 'retail.shopping_mall.atrium', 'entertainment.cinema']
1430
+ }
1431
+ });
1432
+ assert.strictEqual(status, 201, `Should accept dot-notation venueTypes: ${JSON.stringify(data)}`);
1433
+ });
1434
+
1435
+ it('should accept DIRECT line item with geofencing polygon (exclusion zone)', async () => {
1436
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1437
+ const { status, data } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1438
+ externalId: `e2e-li-geofence-excl-${Date.now()}`,
1439
+ name: 'Line Item with Exclusion Geofencing',
1440
+ creativeType: 'DISPLAY',
1441
+ timezoneId: 'UTC',
1442
+ startDate: '2025-01-01',
1443
+ endDate: '2025-12-31',
1444
+ resolutions: ['1920x1080'],
1445
+ targeting: {
1446
+ geofencing: {
1447
+ geometrics: [
1448
+ { type: 'Polygon', coordinates: [[[-118.25, 34.04], [-118.24, 34.04], [-118.24, 34.05], [-118.25, 34.05], [-118.25, 34.04]]], included: true },
1449
+ { type: 'Polygon', coordinates: [[[-118.245, 34.045], [-118.244, 34.045], [-118.244, 34.046], [-118.245, 34.046], [-118.245, 34.045]]], included: false }
1450
+ ]
1451
+ }
1452
+ }
1453
+ });
1454
+ assert.strictEqual(status, 201, `Should accept exclusion geofencing: ${JSON.stringify(data)}`);
1455
+ });
1456
+
1457
+ it('should reject DIRECT line item with invalid geometry type', async () => {
1458
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1459
+ const { status } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1460
+ externalId: `e2e-li-invalid-geo-${Date.now()}`,
1461
+ name: 'Line Item with Invalid Geometry',
1462
+ creativeType: 'DISPLAY',
1463
+ timezoneId: 'UTC',
1464
+ startDate: '2025-01-01',
1465
+ endDate: '2025-12-31',
1466
+ resolutions: ['1920x1080'],
1467
+ targeting: {
1468
+ geofencing: {
1469
+ geometrics: [{ type: 'InvalidShape', coordinates: [[0, 0]] }]
1470
+ }
1471
+ }
1472
+ });
1473
+ assert.strictEqual(status, 400, 'Should reject invalid geometry type');
1474
+ });
1475
+
1476
+ it('should accept DIRECT line item with POIs in geofencing geometry', async () => {
1477
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1478
+ const externalId = `e2e-li-pois-${Date.now()}`;
1479
+ const { status, data } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1480
+ externalId,
1481
+ name: 'Line Item with POIs',
1482
+ creativeType: 'DISPLAY',
1483
+ timezoneId: 'Asia/Tokyo',
1484
+ startDate: '2025-01-01',
1485
+ endDate: '2025-12-31',
1486
+ resolutions: ['1920x1080'],
1487
+ targeting: {
1488
+ geofencing: {
1489
+ geometrics: [{
1490
+ type: 'Polygon',
1491
+ coordinates: [[[139.668, 35.698], [139.801, 35.698], [139.801, 35.655], [139.668, 35.655], [139.668, 35.698]]],
1492
+ included: true,
1493
+ pois: [
1494
+ { type: 'cafe', label: 'Cafe' },
1495
+ { type: 'restaurant', label: 'Restaurant' },
1496
+ { type: 'shopping_mall', label: 'Shopping Mall' },
1497
+ { type: 'school', label: 'School' }
1498
+ ]
1499
+ }]
1500
+ }
1501
+ }
1502
+ });
1503
+ assert.strictEqual(status, 201, `Should accept POIs: ${JSON.stringify(data)}`);
1504
+
1505
+ const getRes = await request('GET', `/deals/${targetingDealId.value}/line-items/${externalId}`);
1506
+ assert.ok(getRes.data.result.targeting.geofencing.geometrics[0].pois, 'Should have POIs');
1507
+ assert.strictEqual(getRes.data.result.targeting.geofencing.geometrics[0].pois.length, 4, 'Should have 4 POIs');
1508
+ assert.strictEqual(getRes.data.result.targeting.geofencing.geometrics[0].pois[0].type, 'cafe');
1509
+ });
1510
+
1511
+ it('should accept geometry with empty POIs array', async () => {
1512
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1513
+ const { status, data } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1514
+ externalId: `e2e-li-empty-pois-${Date.now()}`,
1515
+ name: 'Line Item with Empty POIs',
1516
+ creativeType: 'DISPLAY',
1517
+ timezoneId: 'UTC',
1518
+ startDate: '2025-01-01',
1519
+ endDate: '2025-12-31',
1520
+ resolutions: ['1920x1080'],
1521
+ targeting: {
1522
+ geofencing: {
1523
+ geometrics: [{
1524
+ type: 'Polygon',
1525
+ coordinates: [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]],
1526
+ included: true,
1527
+ pois: []
1528
+ }]
1529
+ }
1530
+ }
1531
+ });
1532
+ assert.strictEqual(status, 201, `Should accept empty POIs array: ${JSON.stringify(data)}`);
1533
+ });
1534
+
1535
+ it('should accept geometry without POIs field (optional)', async () => {
1536
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1537
+ const { status, data } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1538
+ externalId: `e2e-li-no-pois-${Date.now()}`,
1539
+ name: 'Line Item without POIs',
1540
+ creativeType: 'DISPLAY',
1541
+ timezoneId: 'UTC',
1542
+ startDate: '2025-01-01',
1543
+ endDate: '2025-12-31',
1544
+ resolutions: ['1920x1080'],
1545
+ targeting: {
1546
+ geofencing: {
1547
+ geometrics: [{
1548
+ type: 'Polygon',
1549
+ coordinates: [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]],
1550
+ included: true
1551
+ }]
1552
+ }
1553
+ }
1554
+ });
1555
+ assert.strictEqual(status, 201, `Should accept geometry without POIs: ${JSON.stringify(data)}`);
1556
+ });
1557
+
1558
+ it('should reject POI missing type field', async () => {
1559
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1560
+ const { status } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1561
+ externalId: `e2e-li-poi-no-type-${Date.now()}`,
1562
+ name: 'Line Item with POI missing type',
1563
+ creativeType: 'DISPLAY',
1564
+ timezoneId: 'UTC',
1565
+ startDate: '2025-01-01',
1566
+ endDate: '2025-12-31',
1567
+ resolutions: ['1920x1080'],
1568
+ targeting: {
1569
+ geofencing: {
1570
+ geometrics: [{
1571
+ type: 'Polygon',
1572
+ coordinates: [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]],
1573
+ pois: [{ label: 'Missing Type' }]
1574
+ }]
1575
+ }
1576
+ }
1577
+ });
1578
+ assert.strictEqual(status, 400, 'Should reject POI missing type field');
1579
+ });
1580
+
1581
+ it('should reject POI missing label field', async () => {
1582
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1583
+ const { status } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1584
+ externalId: `e2e-li-poi-no-label-${Date.now()}`,
1585
+ name: 'Line Item with POI missing label',
1586
+ creativeType: 'DISPLAY',
1587
+ timezoneId: 'UTC',
1588
+ startDate: '2025-01-01',
1589
+ endDate: '2025-12-31',
1590
+ resolutions: ['1920x1080'],
1591
+ targeting: {
1592
+ geofencing: {
1593
+ geometrics: [{
1594
+ type: 'Polygon',
1595
+ coordinates: [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]],
1596
+ pois: [{ type: 'cafe' }]
1597
+ }]
1598
+ }
1599
+ }
1600
+ });
1601
+ assert.strictEqual(status, 400, 'Should reject POI missing label field');
1602
+ });
1603
+
1604
+ it('should accept multiple geometrics with different POIs', async () => {
1605
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1606
+ const externalId = `e2e-li-multi-geo-pois-${Date.now()}`;
1607
+ const { status, data } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1608
+ externalId,
1609
+ name: 'Line Item with Multiple Geometries and POIs',
1610
+ creativeType: 'DISPLAY',
1611
+ timezoneId: 'UTC',
1612
+ startDate: '2025-01-01',
1613
+ endDate: '2025-12-31',
1614
+ resolutions: ['1920x1080'],
1615
+ targeting: {
1616
+ geofencing: {
1617
+ geometrics: [
1618
+ {
1619
+ type: 'Polygon',
1620
+ coordinates: [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]],
1621
+ included: true,
1622
+ pois: [{ type: 'cafe', label: 'Coffee Shops' }, { type: 'restaurant', label: 'Restaurants' }]
1623
+ },
1624
+ {
1625
+ type: 'Polygon',
1626
+ coordinates: [[[2, 2], [3, 2], [3, 3], [2, 3], [2, 2]]],
1627
+ included: true,
1628
+ pois: [{ type: 'gym', label: 'Fitness Centers' }, { type: 'hospital', label: 'Hospitals' }]
1629
+ },
1630
+ {
1631
+ type: 'Polygon',
1632
+ coordinates: [[[4, 4], [5, 4], [5, 5], [4, 5], [4, 4]]],
1633
+ included: false
1634
+ }
1635
+ ]
1636
+ }
1637
+ }
1638
+ });
1639
+ assert.strictEqual(status, 201, `Should accept multiple geometrics with POIs: ${JSON.stringify(data)}`);
1640
+
1641
+ const getRes = await request('GET', `/deals/${targetingDealId.value}/line-items/${externalId}`);
1642
+ assert.strictEqual(getRes.data.result.targeting.geofencing.geometrics.length, 3);
1643
+ assert.strictEqual(getRes.data.result.targeting.geofencing.geometrics[0].pois.length, 2);
1644
+ assert.strictEqual(getRes.data.result.targeting.geofencing.geometrics[1].pois.length, 2);
1645
+ assert.ok(!getRes.data.result.targeting.geofencing.geometrics[2].pois || getRes.data.result.targeting.geofencing.geometrics[2].pois.length === 0, 'Third geometry should have no POIs');
1646
+ });
1647
+
1648
+ it('should update POIs on existing line item', async () => {
1649
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1650
+ const externalId = `e2e-li-update-pois-${Date.now()}`;
1651
+ const createRes = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1652
+ externalId,
1653
+ name: 'Line Item for POI Update',
1654
+ creativeType: 'DISPLAY',
1655
+ timezoneId: 'UTC',
1656
+ startDate: '2025-01-01',
1657
+ endDate: '2025-12-31',
1658
+ resolutions: ['1920x1080'],
1659
+ targeting: {
1660
+ geofencing: {
1661
+ geometrics: [{
1662
+ type: 'Polygon',
1663
+ coordinates: [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]],
1664
+ included: true,
1665
+ pois: [{ type: 'cafe', label: 'Cafe' }]
1666
+ }]
1667
+ }
1668
+ }
1669
+ });
1670
+ assert.strictEqual(createRes.status, 201);
1671
+
1672
+ const { status, data } = await request('PUT', `/deals/${targetingDealId.value}/line-items/${externalId}`, {
1673
+ targeting: {
1674
+ geofencing: {
1675
+ geometrics: [{
1676
+ type: 'Polygon',
1677
+ coordinates: [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]],
1678
+ included: true,
1679
+ pois: [
1680
+ { type: 'restaurant', label: 'Restaurant' },
1681
+ { type: 'bar', label: 'Bar' },
1682
+ { type: 'nightclub', label: 'Nightclub' }
1683
+ ]
1684
+ }]
1685
+ }
1686
+ }
1687
+ });
1688
+ assert.strictEqual(status, 200, `POI update failed: ${JSON.stringify(data)}`);
1689
+
1690
+ const getRes = await request('GET', `/deals/${targetingDealId.value}/line-items/${externalId}`);
1691
+ assert.strictEqual(getRes.data.result.targeting.geofencing.geometrics[0].pois.length, 3, 'Should have 3 updated POIs');
1692
+ assert.strictEqual(getRes.data.result.targeting.geofencing.geometrics[0].pois[0].type, 'restaurant');
1693
+ });
1694
+
1695
+ it('should accept DIRECT line item with weather-based deliveryTargeting', async () => {
1696
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1697
+ const { status, data } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1698
+ externalId: `e2e-li-weather-${Date.now()}`,
1699
+ name: 'Line Item with Weather Targeting',
1700
+ creativeType: 'DISPLAY',
1701
+ timezoneId: 'UTC',
1702
+ startDate: '2025-01-01',
1703
+ endDate: '2025-12-31',
1704
+ resolutions: ['1920x1080'],
1705
+ deliveryTargeting: {
1706
+ signals: {
1707
+ weather: {
1708
+ conditions: ['rainy', 'stormy'],
1709
+ temperature: { min: 10, max: 25, unit: 'celsius' }
1710
+ }
1711
+ }
1712
+ }
1713
+ });
1714
+ assert.strictEqual(status, 201, `Should accept weather deliveryTargeting: ${JSON.stringify(data)}`);
1715
+ });
1716
+
1717
+ it('should reject DIRECT line item with invalid temperature unit', async () => {
1718
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1719
+ const { status } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1720
+ externalId: `e2e-li-invalid-temp-${Date.now()}`,
1721
+ name: 'Line Item with Invalid Temp Unit',
1722
+ creativeType: 'DISPLAY',
1723
+ timezoneId: 'UTC',
1724
+ startDate: '2025-01-01',
1725
+ endDate: '2025-12-31',
1726
+ resolutions: ['1920x1080'],
1727
+ deliveryTargeting: {
1728
+ signals: {
1729
+ weather: {
1730
+ temperature: { min: 0, max: 100, unit: 'kelvin' }
1731
+ }
1732
+ }
1733
+ }
1734
+ });
1735
+ assert.strictEqual(status, 400, 'Should reject invalid temperature unit');
1736
+ });
1737
+
1738
+ it('should accept DIRECT line item with metadata version tracking', async () => {
1739
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1740
+ const { status, data } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1741
+ externalId: `e2e-li-metadata-${Date.now()}`,
1742
+ name: 'Line Item with Metadata Version',
1743
+ creativeType: 'DISPLAY',
1744
+ timezoneId: 'UTC',
1745
+ startDate: '2025-01-01',
1746
+ endDate: '2025-12-31',
1747
+ resolutions: ['1920x1080'],
1748
+ metadata: {
1749
+ inventoryFilteredBy: ['demographics', 'venueTypes'],
1750
+ deliveryTriggeredBy: ['weather'],
1751
+ venueTaxonomyFormat: 'dot-notation',
1752
+ version: '2.0.1'
1753
+ }
1754
+ });
1755
+ assert.strictEqual(status, 201, `Should accept metadata: ${JSON.stringify(data)}`);
1756
+ });
1757
+
1758
+ it('should reject DIRECT line item with invalid inventoryFilteredBy value', async () => {
1759
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1760
+ const { status } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1761
+ externalId: `e2e-li-invalid-filter-${Date.now()}`,
1762
+ name: 'Line Item with Invalid Filter',
1763
+ creativeType: 'DISPLAY',
1764
+ timezoneId: 'UTC',
1765
+ startDate: '2025-01-01',
1766
+ endDate: '2025-12-31',
1767
+ resolutions: ['1920x1080'],
1768
+ metadata: {
1769
+ inventoryFilteredBy: ['invalid_filter_type']
1770
+ }
1771
+ });
1772
+ assert.strictEqual(status, 400, 'Should reject invalid inventoryFilteredBy');
1773
+ });
1774
+
1775
+ it('should reject DIRECT line item with invalid venueTaxonomyFormat', async () => {
1776
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1777
+ const { status } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1778
+ externalId: `e2e-li-invalid-taxonomy-${Date.now()}`,
1779
+ name: 'Line Item with Invalid Taxonomy Format',
1780
+ creativeType: 'DISPLAY',
1781
+ timezoneId: 'UTC',
1782
+ startDate: '2025-01-01',
1783
+ endDate: '2025-12-31',
1784
+ resolutions: ['1920x1080'],
1785
+ metadata: {
1786
+ venueTaxonomyFormat: 'unsupported_format'
1787
+ }
1788
+ });
1789
+ assert.strictEqual(status, 400, 'Should reject invalid venueTaxonomyFormat');
1790
+ });
1791
+
1792
+ it('should accept partial update of targeting only', async () => {
1793
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1794
+ const createRes = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1795
+ externalId: `e2e-li-partial-update-${Date.now()}`,
1796
+ name: 'Line Item for Partial Update',
1797
+ creativeType: 'DISPLAY',
1798
+ timezoneId: 'UTC',
1799
+ startDate: '2025-01-01',
1800
+ endDate: '2025-12-31',
1801
+ resolutions: ['1920x1080'],
1802
+ targeting: { demographics: { ageGroups: ['18-24'] } }
1803
+ });
1804
+ assert.strictEqual(createRes.status, 201);
1805
+
1806
+ const { status, data } = await request('PUT', `/deals/${targetingDealId.value}/line-items/${createRes.data.result.externalId}`, {
1807
+ targeting: { demographics: { ageGroups: ['25-34', '35-44'] }, venueTypes: ['retail'] }
1808
+ });
1809
+ assert.strictEqual(status, 200, `Partial targeting update failed: ${JSON.stringify(data)}`);
1810
+
1811
+ const getRes = await request('GET', `/deals/${targetingDealId.value}/line-items/${createRes.data.result.externalId}`);
1812
+ assert.ok(getRes.data.result.targeting.demographics.ageGroups.includes('25-34'), 'Targeting should be updated');
1813
+ assert.ok(getRes.data.result.targeting.venueTypes.includes('retail'), 'VenueTypes should be added');
1814
+ });
1815
+
1816
+ it('should accept DIRECT line item with POIs in locations', async () => {
1817
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1818
+ const { status, data } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1819
+ externalId: `e2e-li-location-pois-${Date.now()}`,
1820
+ name: 'Line Item with Location POIs',
1821
+ creativeType: 'DISPLAY',
1822
+ timezoneId: 'UTC',
1823
+ startDate: '2025-01-01',
1824
+ endDate: '2025-12-31',
1825
+ resolutions: ['1920x1080'],
1826
+ targeting: {
1827
+ geofencing: {
1828
+ locations: [{
1829
+ name: 'Tokyo Station',
1830
+ lat: 35.6812,
1831
+ lng: 139.7671,
1832
+ radius: 1000,
1833
+ address: 'Tokyo, Japan',
1834
+ included: true,
1835
+ pois: [
1836
+ { type: 'train_station', label: 'Train Station' },
1837
+ { type: 'shopping_mall', label: 'Shopping Mall' },
1838
+ { type: 'restaurant', label: 'Restaurant' }
1839
+ ]
1840
+ }]
1841
+ }
1842
+ }
1843
+ });
1844
+ assert.strictEqual(status, 201, `Should accept location POIs: ${JSON.stringify(data)}`);
1845
+
1846
+ const getRes = await request('GET', `/deals/${targetingDealId.value}/line-items/${data.result.externalId}`);
1847
+ assert.ok(getRes.data.result.targeting.geofencing.locations, 'Should have locations');
1848
+ assert.ok(getRes.data.result.targeting.geofencing.locations[0].pois, 'Should have POIs in location');
1849
+ assert.strictEqual(getRes.data.result.targeting.geofencing.locations[0].pois.length, 3, 'Should have 3 POIs');
1850
+ assert.strictEqual(getRes.data.result.targeting.geofencing.locations[0].pois[0].type, 'train_station');
1851
+ });
1852
+
1853
+ it('should accept location with empty POIs array', async () => {
1854
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1855
+ const { status, data } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1856
+ externalId: `e2e-li-location-empty-pois-${Date.now()}`,
1857
+ name: 'Line Item with Empty Location POIs',
1858
+ creativeType: 'DISPLAY',
1859
+ timezoneId: 'UTC',
1860
+ startDate: '2025-01-01',
1861
+ endDate: '2025-12-31',
1862
+ resolutions: ['1920x1080'],
1863
+ targeting: {
1864
+ geofencing: {
1865
+ locations: [{
1866
+ name: 'Osaka Station',
1867
+ lat: 34.7021,
1868
+ lng: 135.4960,
1869
+ radius: 500,
1870
+ included: true,
1871
+ pois: []
1872
+ }]
1873
+ }
1874
+ }
1875
+ });
1876
+ assert.strictEqual(status, 201, `Should accept empty POIs array: ${JSON.stringify(data)}`);
1877
+ });
1878
+
1879
+ it('should accept location without POIs field (optional)', async () => {
1880
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1881
+ const { status, data } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1882
+ externalId: `e2e-li-location-no-pois-${Date.now()}`,
1883
+ name: 'Line Item with No POIs Field',
1884
+ creativeType: 'DISPLAY',
1885
+ timezoneId: 'UTC',
1886
+ startDate: '2025-01-01',
1887
+ endDate: '2025-12-31',
1888
+ resolutions: ['1920x1080'],
1889
+ targeting: {
1890
+ geofencing: {
1891
+ locations: [{
1892
+ name: 'Kyoto Station',
1893
+ lat: 34.9858,
1894
+ lng: 135.7587,
1895
+ radius: 800,
1896
+ included: true
1897
+ }]
1898
+ }
1899
+ }
1900
+ });
1901
+ assert.strictEqual(status, 201, `Should accept without POIs field: ${JSON.stringify(data)}`);
1902
+ });
1903
+
1904
+ it('should reject location POI missing type field', async () => {
1905
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1906
+ const { status } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1907
+ externalId: `e2e-li-location-poi-no-type-${Date.now()}`,
1908
+ name: 'Line Item with Invalid POI',
1909
+ creativeType: 'DISPLAY',
1910
+ timezoneId: 'UTC',
1911
+ startDate: '2025-01-01',
1912
+ endDate: '2025-12-31',
1913
+ resolutions: ['1920x1080'],
1914
+ targeting: {
1915
+ geofencing: {
1916
+ locations: [{
1917
+ lat: 35.0,
1918
+ lng: 135.0,
1919
+ radius: 500,
1920
+ pois: [{ label: 'Missing Type' }]
1921
+ }]
1922
+ }
1923
+ }
1924
+ });
1925
+ assert.strictEqual(status, 400, 'Should reject POI missing type');
1926
+ });
1927
+
1928
+ it('should reject location POI missing label field', async () => {
1929
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1930
+ const { status } = await request('POST', `/deals/${targetingDealId.value}/line-items`, {
1931
+ externalId: `e2e-li-location-poi-no-label-${Date.now()}`,
1932
+ name: 'Line Item with Invalid POI',
1933
+ creativeType: 'DISPLAY',
1934
+ timezoneId: 'UTC',
1935
+ startDate: '2025-01-01',
1936
+ endDate: '2025-12-31',
1937
+ resolutions: ['1920x1080'],
1938
+ targeting: {
1939
+ geofencing: {
1940
+ locations: [{
1941
+ lat: 35.0,
1942
+ lng: 135.0,
1943
+ radius: 500,
1944
+ pois: [{ type: 'cafe' }]
1945
+ }]
1946
+ }
1947
+ }
1948
+ });
1949
+ assert.strictEqual(status, 400, 'Should reject POI missing label');
1950
+ });
1951
+
1952
+ it('should archive targeting edge test deal', async () => {
1953
+ if (!targetingDealId.value) { assert.ok(true, 'Skipping'); return; }
1954
+ const { status } = await request('DELETE', `/deals/${targetingDealId.value}`);
1955
+ assert.ok([200, 404].includes(status));
1956
+ });
1957
+ });
1958
+
1959
+ describe('12. Optional ApprovalEmails for DIRECT Mode', () => {
1960
+ it('should create DIRECT deal without approvalEmails', async () => {
1961
+ const { status, data } = await request('POST', '/deals', {
1962
+ name: 'Direct Deal No Approval Emails',
1963
+ externalId: `e2e-no-approval-${Date.now()}`,
1964
+ source: 'e2e-test',
1965
+ mode: 'DIRECT',
1966
+ dealType: 'DIRECT',
1967
+ brand: 'TestBrand',
1968
+ clientType: 'DIRECT_ADVERTISER',
1969
+ currency: 'USD',
1970
+ marketSelection: { country: 'US', currency: 'USD' },
1971
+ budgetSetup: { currency: 'USD', budgetAmount: 10000, budgetType: 'TOTAL' },
1972
+ campaignGoal: { type: 'IMPRESSIONS', targetValue: 100000 }
1973
+ });
1974
+ assert.strictEqual(status, 201, `Should create DIRECT deal without approvalEmails: ${JSON.stringify(data)}`);
1975
+
1976
+ await request('DELETE', `/deals/${data.result.dealId}`);
1977
+ });
1978
+
1979
+ it('should create DIRECT deal with empty approvalEmails array', async () => {
1980
+ const { status, data } = await request('POST', '/deals', {
1981
+ name: 'Direct Deal Empty Approval Emails',
1982
+ externalId: `e2e-empty-approval-${Date.now()}`,
1983
+ source: 'e2e-test',
1984
+ mode: 'DIRECT',
1985
+ dealType: 'DIRECT',
1986
+ brand: 'TestBrand',
1987
+ clientType: 'DIRECT_ADVERTISER',
1988
+ currency: 'USD',
1989
+ approvalEmails: [],
1990
+ marketSelection: { country: 'US', currency: 'USD' },
1991
+ budgetSetup: { currency: 'USD', budgetAmount: 10000, budgetType: 'TOTAL' },
1992
+ campaignGoal: { type: 'IMPRESSIONS', targetValue: 100000 }
1993
+ });
1994
+ assert.strictEqual(status, 201, `Should create DIRECT deal with empty approvalEmails: ${JSON.stringify(data)}`);
1995
+
1996
+ await request('DELETE', `/deals/${data.result.dealId}`);
1997
+ });
1998
+
1999
+ it('should create DIRECT deal with approvalEmails', async () => {
2000
+ const { status, data } = await request('POST', '/deals', {
2001
+ name: 'Direct Deal With Approval Emails',
2002
+ externalId: `e2e-with-approval-${Date.now()}`,
2003
+ source: 'e2e-test',
2004
+ mode: 'DIRECT',
2005
+ dealType: 'DIRECT',
2006
+ brand: 'TestBrand',
2007
+ clientType: 'DIRECT_ADVERTISER',
2008
+ currency: 'USD',
2009
+ approvalEmails: ['test@example.com', 'approver@example.com'],
2010
+ marketSelection: { country: 'US', currency: 'USD' },
2011
+ budgetSetup: { currency: 'USD', budgetAmount: 10000, budgetType: 'TOTAL' },
2012
+ campaignGoal: { type: 'IMPRESSIONS', targetValue: 100000 }
2013
+ });
2014
+ assert.strictEqual(status, 201, `Should create DIRECT deal with approvalEmails: ${JSON.stringify(data)}`);
2015
+
2016
+ const getRes = await request('GET', `/deals/${data.result.dealId}`);
2017
+ assert.strictEqual(getRes.status, 200, 'Should get deal');
2018
+ assert.ok(getRes.data.result.direct, 'Should have direct object');
2019
+ assert.ok(Array.isArray(getRes.data.result.direct.approvalEmails), 'Should have approvalEmails array in direct object');
2020
+ assert.strictEqual(getRes.data.result.direct.approvalEmails.length, 2, 'Should have 2 approval emails');
2021
+
2022
+ await request('DELETE', `/deals/${data.result.dealId}`);
2023
+ });
2024
+ });
2025
+
2026
+ describe('13. PROGRAMMATIC Mode Targeting Rejection', () => {
2027
+ const progDealId = { value: null };
2028
+
2029
+ it('should create PROGRAMMATIC deal for rejection tests', async () => {
2030
+ const { status, data } = await request('POST', '/deals', {
2031
+ name: 'Programmatic Targeting Rejection Deal',
2032
+ externalId: `e2e-prog-reject-${Date.now()}`,
2033
+ source: 'e2e-test',
2034
+ mode: 'PROGRAMMATIC',
2035
+ dealType: 'PRIVATE_AUCTION',
2036
+ currency: 'USD',
2037
+ seller: { id: 'seller-001', name: 'Test Seller' },
2038
+ programmatic: { buyers: [{ id: 'buyer-001', name: 'DSP Buyer', seatId: 'SEAT-001' }] }
2039
+ });
2040
+ assert.strictEqual(status, 201, `Failed: ${JSON.stringify(data)}`);
2041
+ progDealId.value = data.result.dealId;
2042
+ });
2043
+
2044
+ it('should create PROGRAMMATIC line item WITHOUT targeting (baseline)', async () => {
2045
+ if (!progDealId.value) { assert.ok(true, 'Skipping'); return; }
2046
+ const { status, data } = await request('POST', `/deals/${progDealId.value}/line-items`, {
2047
+ externalId: `e2e-li-prog-base-${Date.now()}`,
2048
+ name: 'Programmatic Line Item Baseline',
2049
+ creativeType: 'DISPLAY',
2050
+ timezoneId: 'UTC',
2051
+ startDate: '2025-01-01',
2052
+ endDate: '2025-12-31',
2053
+ resolutions: ['1920x1080'],
2054
+ programmatic: { costType: 'CPM', bidFloor: 10.00 }
2055
+ });
2056
+ assert.strictEqual(status, 201, `Baseline PROGRAMMATIC creation failed: ${JSON.stringify(data)}`);
2057
+ });
2058
+
2059
+ it('should accept targeting for PROGRAMMATIC line item (filters inventories)', async () => {
2060
+ if (!progDealId.value) { assert.ok(true, 'Skipping'); return; }
2061
+ const { status, data } = await request('POST', `/deals/${progDealId.value}/line-items`, {
2062
+ externalId: `e2e-li-prog-with-targeting-${Date.now()}`,
2063
+ name: 'Programmatic Line Item with Targeting',
2064
+ creativeType: 'DISPLAY',
2065
+ timezoneId: 'UTC',
2066
+ startDate: '2025-01-01',
2067
+ endDate: '2025-12-31',
2068
+ resolutions: ['1920x1080'],
2069
+ programmatic: { costType: 'CPM', bidFloor: 10.00 },
2070
+ targeting: {
2071
+ demographics: { ageGroups: ['25-34'], genders: ['male', 'female'] },
2072
+ venueTypes: ['transit.train_stations', 'retail.shopping_mall']
2073
+ },
2074
+ metadata: { version: '1.0', inventoryFilteredBy: ['demographics', 'venueTypes'] }
2075
+ });
2076
+ assert.strictEqual(status, 201, `Should accept targeting for PROGRAMMATIC: ${JSON.stringify(data)}`);
2077
+
2078
+ const getRes = await request('GET', `/deals/${progDealId.value}/line-items/${data.result.externalId}`);
2079
+ const li = getRes.data.result;
2080
+ assert.ok(li.targeting, 'PROGRAMMATIC line item should have targeting stored');
2081
+ assert.ok(li.targeting.demographics, 'Should have demographics');
2082
+ assert.ok(li.targeting.venueTypes, 'Should have venueTypes');
2083
+ });
2084
+
2085
+ it('should reject deliveryTargeting for PROGRAMMATIC line item (DIRECT-only)', async () => {
2086
+ if (!progDealId.value) { assert.ok(true, 'Skipping'); return; }
2087
+ const { status, data } = await request('POST', `/deals/${progDealId.value}/line-items`, {
2088
+ externalId: `e2e-li-prog-delivery-targeting-${Date.now()}`,
2089
+ name: 'Programmatic Line Item with DeliveryTargeting Attempt',
2090
+ creativeType: 'DISPLAY',
2091
+ timezoneId: 'UTC',
2092
+ startDate: '2025-01-01',
2093
+ endDate: '2025-12-31',
2094
+ resolutions: ['1920x1080'],
2095
+ programmatic: { costType: 'CPM', bidFloor: 10.00 },
2096
+ deliveryTargeting: { signals: { weather: { conditions: ['sunny'] } } }
2097
+ });
2098
+ assert.strictEqual(status, 400, `Should reject deliveryTargeting for PROGRAMMATIC: ${JSON.stringify(data)}`);
2099
+ assert.ok(data.message && data.message.includes('deliveryTargeting'), 'Error should mention deliveryTargeting');
2100
+ });
2101
+
2102
+ it('should accept PROGRAMMATIC line item with geofencing (polygon + locations)', async () => {
2103
+ if (!progDealId.value) { assert.ok(true, 'Skipping'); return; }
2104
+ const { status, data } = await request('POST', `/deals/${progDealId.value}/line-items`, {
2105
+ externalId: `e2e-li-prog-geofencing-${Date.now()}`,
2106
+ name: 'Programmatic Line Item with Geofencing',
2107
+ creativeType: 'DISPLAY',
2108
+ timezoneId: 'UTC',
2109
+ startDate: '2025-01-01',
2110
+ endDate: '2025-12-31',
2111
+ resolutions: ['1920x1080'],
2112
+ programmatic: { costType: 'CPM', bidFloor: 8.00 },
2113
+ targeting: {
2114
+ geofencing: {
2115
+ geometrics: [
2116
+ { type: 'Polygon', coordinates: [[[101.68, 3.13], [101.72, 3.13], [101.72, 3.17], [101.68, 3.17], [101.68, 3.13]]], included: true }
2117
+ ],
2118
+ locations: [
2119
+ { name: 'KL Sentral', lat: 3.1336, lng: 101.6865, radius: 1000, included: true }
2120
+ ]
2121
+ }
2122
+ }
2123
+ });
2124
+ assert.strictEqual(status, 201, `Should accept PROGRAMMATIC with geofencing: ${JSON.stringify(data)}`);
2125
+
2126
+ const getRes = await request('GET', `/deals/${progDealId.value}/line-items/${data.result.externalId}`);
2127
+ const li = getRes.data.result;
2128
+ assert.ok(li.targeting && li.targeting.geofencing, 'Should have geofencing stored');
2129
+ assert.ok(li.targeting.geofencing.geometrics, 'Should have geometrics');
2130
+ assert.ok(li.targeting.geofencing.locations, 'Should have locations');
2131
+ });
2132
+
2133
+ it('should update PROGRAMMATIC line item targeting and verify persistence', async () => {
2134
+ if (!progDealId.value) { assert.ok(true, 'Skipping'); return; }
2135
+ const createRes = await request('POST', `/deals/${progDealId.value}/line-items`, {
2136
+ externalId: `e2e-li-prog-update-targeting-${Date.now()}`,
2137
+ name: 'Programmatic Line Item for Targeting Update',
2138
+ creativeType: 'DISPLAY',
2139
+ timezoneId: 'UTC',
2140
+ startDate: '2025-01-01',
2141
+ endDate: '2025-12-31',
2142
+ resolutions: ['1920x1080'],
2143
+ programmatic: { costType: 'CPM', bidFloor: 5.00 },
2144
+ targeting: { demographics: { ageGroups: ['18-24'] } }
2145
+ });
2146
+ assert.strictEqual(createRes.status, 201);
2147
+
2148
+ const updateRes = await request('PUT', `/deals/${progDealId.value}/line-items/${createRes.data.result.externalId}`, {
2149
+ targeting: {
2150
+ demographics: { ageGroups: ['25-34', '35-44'], genders: ['male'] },
2151
+ venueTypes: ['retail.shopping_mall', 'transit.bus_stops']
2152
+ },
2153
+ metadata: { version: '2.0', inventoryFilteredBy: ['demographics', 'venueTypes'] }
2154
+ });
2155
+ assert.strictEqual(updateRes.status, 200, `Should update PROGRAMMATIC targeting: ${JSON.stringify(updateRes.data)}`);
2156
+
2157
+ // Verify via GET single item
2158
+ const getRes = await request('GET', `/deals/${progDealId.value}/line-items/${createRes.data.result.externalId}`);
2159
+ const li = getRes.data.result;
2160
+ assert.deepStrictEqual(li.targeting.demographics.ageGroups, ['25-34', '35-44'], 'Updated ageGroups');
2161
+ assert.ok(li.targeting.venueTypes.includes('retail.shopping_mall'), 'Updated venueTypes');
2162
+ assert.strictEqual(li.metadata.version, '2.0', 'Updated metadata version');
2163
+
2164
+ // Verify via GET list (targeting persists in list response)
2165
+ const listRes = await request('GET', `/deals/${progDealId.value}/line-items`);
2166
+ const liFromList = listRes.data.result.data.find(item => item.externalId === createRes.data.result.externalId);
2167
+ assert.ok(liFromList.targeting, 'Targeting should appear in list response');
2168
+ assert.ok(liFromList.targeting.demographics, 'Demographics should appear in list response');
2169
+ });
2170
+
2171
+ it('should reject PROGRAMMATIC line item update with deliveryTargeting (verify error payload)', async () => {
2172
+ if (!progDealId.value) { assert.ok(true, 'Skipping'); return; }
2173
+ const createRes = await request('POST', `/deals/${progDealId.value}/line-items`, {
2174
+ externalId: `e2e-li-prog-reject-update-${Date.now()}`,
2175
+ name: 'Programmatic Line Item for Update Rejection',
2176
+ creativeType: 'DISPLAY',
2177
+ timezoneId: 'UTC',
2178
+ startDate: '2025-01-01',
2179
+ endDate: '2025-12-31',
2180
+ resolutions: ['1920x1080'],
2181
+ programmatic: { costType: 'CPM', bidFloor: 5.00 }
2182
+ });
2183
+ assert.strictEqual(createRes.status, 201);
2184
+
2185
+ const updateRes = await request('PUT', `/deals/${progDealId.value}/line-items/${createRes.data.result.externalId}`, {
2186
+ deliveryTargeting: { signals: { weather: { conditions: ['rainy'] } } }
2187
+ });
2188
+ assert.strictEqual(updateRes.status, 400, 'Should reject deliveryTargeting update for PROGRAMMATIC');
2189
+ assert.strictEqual(updateRes.data.code, 'VALIDATION_ERROR', 'Error code should be VALIDATION_ERROR');
2190
+ assert.ok(updateRes.data.message && updateRes.data.message.includes('deliveryTargeting'), 'Error message should mention deliveryTargeting');
2191
+ assert.ok(updateRes.data.message.includes('DIRECT') || updateRes.data.message.includes('PROGRAMMATIC'), 'Error message should mention mode restriction');
2192
+ });
2193
+
2194
+ it('should archive programmatic rejection test deal', async () => {
2195
+ if (!progDealId.value) { assert.ok(true, 'Skipping'); return; }
2196
+ const { status } = await request('DELETE', `/deals/${progDealId.value}`);
2197
+ assert.ok([200, 404].includes(status));
2198
+ });
2199
+ });
2200
+
2201
+ describe('13. Edge Cases - Validation & Error Handling', () => {
2202
+ const edgeDealId = { value: null };
2203
+
2204
+ it('should reject DIRECT deal missing required fields', async () => {
2205
+ const { status } = await request('POST', '/deals', {
2206
+ name: 'Invalid Direct Deal',
2207
+ externalId: `e2e-invalid-direct-${Date.now()}`,
2208
+ source: 'e2e-test',
2209
+ mode: 'DIRECT',
2210
+ dealType: 'DIRECT',
2211
+ currency: 'MYR',
2212
+ seller: { id: 'seller-001', name: 'Test Seller' }
2213
+ });
2214
+ assert.strictEqual(status, 400, 'Should reject DIRECT deal missing required fields');
2215
+ });
2216
+
2217
+ it('should reject invalid dealType for PROGRAMMATIC mode', async () => {
2218
+ const { status } = await request('POST', '/deals', {
2219
+ name: 'Invalid DealType',
2220
+ externalId: `e2e-invalid-type-${Date.now()}`,
2221
+ source: 'e2e-test',
2222
+ mode: 'PROGRAMMATIC',
2223
+ dealType: 'DIRECT',
2224
+ currency: 'USD',
2225
+ seller: { id: 'seller-001', name: 'Test Seller' }
2226
+ });
2227
+ assert.strictEqual(status, 400, 'Should reject invalid dealType for mode');
2228
+ });
2229
+
2230
+ it('should create DIRECT deal for inventory planning tests', async () => {
2231
+ const { status, data } = await request('POST', '/deals', {
2232
+ name: 'Edge Test DIRECT Deal',
2233
+ externalId: `e2e-edge-direct-${Date.now()}`,
2234
+ source: 'e2e-test',
2235
+ mode: 'DIRECT',
2236
+ dealType: 'DIRECT',
2237
+ currency: 'MYR',
2238
+ seller: { id: 'seller-001', name: 'Test Seller' },
2239
+ direct: {
2240
+ brand: 'Edge Test Brand',
2241
+ clientType: 'DIRECT_ADVERTISER',
2242
+ approvalEmails: ['test@example.com'],
2243
+ marketSelection: { country: 'MY', currency: 'MYR' },
2244
+ budgetSetup: { currency: 'MYR', budgetAmount: 100000 },
2245
+ campaignGoal: { type: 'IMPRESSIONS', targetValue: 500000 }
2246
+ }
2247
+ });
2248
+ assert.strictEqual(status, 201, `Failed: ${JSON.stringify(data)}`);
2249
+ edgeDealId.value = data.result.dealId;
2250
+ });
2251
+
2252
+ it('should create DIRECT line item with inventory-level planning', async () => {
2253
+ if (!edgeDealId.value) {
2254
+ assert.ok(true, 'Skipping - deal creation failed');
2255
+ return;
2256
+ }
2257
+ const ts = Date.now();
2258
+ const { status, data } = await request('POST', `/deals/${edgeDealId.value}/line-items`, {
2259
+ externalId: `e2e-li-inv-planning-${ts}`,
2260
+ name: 'Line Item with Inventory Planning',
2261
+ creativeType: 'DISPLAY',
2262
+ timezoneId: 'Asia/Kuala_Lumpur',
2263
+ startDate: '2025-01-15',
2264
+ endDate: '2025-02-28',
2265
+ resolutions: ['1920x1080'],
2266
+ creativeSource: 'ADVERTISER',
2267
+ inventories: [
2268
+ {
2269
+ id: `inv-edge-plan-1-${ts}`,
2270
+ name: 'Inventory with Planning 1',
2271
+ size: '1920x1080',
2272
+ publisherId: 'pub-edge-001',
2273
+ publisherName: 'Edge Publisher',
2274
+ venueType: 'SHOPPING_MALL',
2275
+ planning: {
2276
+ capacity: { campaignDays: 45, available: { slots: 178200, playTimeSec: 2673000, maxImpressions: 21000000 } },
2277
+ allocation: { slots: 3960, playTimeSec: 59400, sov: 0.0222, sot: 0.0222 },
2278
+ estimates: { impressions: 933750, reach: 213750, frequency: 4.37 },
2279
+ pricing: { cpm: 2677.50, estimatedCost: 2500000 }
2280
+ }
2281
+ },
2282
+ {
2283
+ id: `inv-edge-plan-2-${ts}`,
2284
+ name: 'Inventory with Planning 2',
2285
+ size: '1920x1080',
2286
+ publisherId: 'pub-edge-001',
2287
+ publisherName: 'Edge Publisher',
2288
+ venueType: 'TRANSIT',
2289
+ planning: {
2290
+ capacity: { campaignDays: 45, available: { slots: 89100, playTimeSec: 1336500, maxImpressions: 10500000 } },
2291
+ allocation: { slots: 1980, playTimeSec: 29700, sov: 0.0222, sot: 0.0222 },
2292
+ estimates: { impressions: 466875, reach: 106875, frequency: 4.37 },
2293
+ pricing: { cpm: 2677.50, estimatedCost: 1250000 }
2294
+ }
2295
+ }
2296
+ ]
2297
+ });
2298
+ assert.strictEqual(status, 201, `Failed: ${JSON.stringify(data)}`);
2299
+ });
2300
+
2301
+ it('should retrieve line items and verify inventories', async () => {
2302
+ if (!edgeDealId.value) {
2303
+ assert.ok(true, 'Skipping - deal creation failed');
2304
+ return;
2305
+ }
2306
+ const { status, data } = await request('GET', `/deals/${edgeDealId.value}/line-items`);
2307
+ assert.strictEqual(status, 200, `Failed: ${JSON.stringify(data)}`);
2308
+ assert.ok(data.result.data.length >= 1, 'Should have at least 1 line item');
2309
+ const lineItem = data.result.data[0];
2310
+ const invRes = await request('GET', `/deals/${edgeDealId.value}/line-items/${lineItem.id}/inventories`);
2311
+ assert.strictEqual(invRes.status, 200, `Failed: ${JSON.stringify(invRes.data)}`);
2312
+ });
2313
+
2314
+ it('should handle empty schedule array', async () => {
2315
+ if (!edgeDealId.value) {
2316
+ assert.ok(true, 'Skipping - deal creation failed');
2317
+ return;
2318
+ }
2319
+ const { status, data } = await request('POST', `/deals/${edgeDealId.value}/line-items`, {
2320
+ externalId: `e2e-li-empty-schedule-${Date.now()}`,
2321
+ name: 'Line Item with Empty Schedule',
2322
+ creativeType: 'DISPLAY',
2323
+ timezoneId: 'Asia/Kuala_Lumpur',
2324
+ startDate: '2025-01-01',
2325
+ endDate: '2025-12-31',
2326
+ resolutions: ['1920x1080'],
2327
+ schedule: []
2328
+ });
2329
+ assert.strictEqual(status, 201, `Should accept empty schedule array: ${JSON.stringify(data)}`);
2330
+ });
2331
+
2332
+ it('should accept schedule with all rule types', async () => {
2333
+ if (!edgeDealId.value) {
2334
+ assert.ok(true, 'Skipping - deal creation failed');
2335
+ return;
2336
+ }
2337
+ const { status, data } = await request('POST', `/deals/${edgeDealId.value}/line-items`, {
2338
+ externalId: `e2e-li-full-schedule-${Date.now()}`,
2339
+ name: 'Line Item with Full Schedule',
2340
+ creativeType: 'DISPLAY',
2341
+ timezoneId: 'Asia/Kuala_Lumpur',
2342
+ startDate: '2025-01-01',
2343
+ endDate: '2025-12-31',
2344
+ resolutions: ['1920x1080'],
2345
+ schedule: [
2346
+ { type: 'DEFAULT', hours: [{ start: 0, end: 23 }] },
2347
+ { type: 'WEEKDAY', priority: 50, daysOfWeek: [1,2,3,4,5], validity: { startDate: '2025-01-01', endDate: '2025-12-31' }, hours: [{ start: 8, end: 20 }] },
2348
+ { type: 'WEEKEND', priority: 50, daysOfWeek: [6,7], hours: [{ start: 10, end: 18 }] },
2349
+ { type: 'CUSTOM', date: '2025-12-25', hours: [{ start: 12, end: 16 }] }
2350
+ ]
2351
+ });
2352
+ assert.strictEqual(status, 201, `Failed: ${JSON.stringify(data)}`);
2353
+ });
2354
+
2355
+ it('should reject line item with invalid schedule type', async () => {
2356
+ if (!edgeDealId.value) {
2357
+ assert.ok(true, 'Skipping - deal creation failed');
2358
+ return;
2359
+ }
2360
+ const { status } = await request('POST', `/deals/${edgeDealId.value}/line-items`, {
2361
+ externalId: `e2e-li-invalid-schedule-${Date.now()}`,
2362
+ name: 'Line Item with Invalid Schedule',
2363
+ creativeType: 'DISPLAY',
2364
+ timezoneId: 'Asia/Kuala_Lumpur',
2365
+ startDate: '2025-01-01',
2366
+ endDate: '2025-12-31',
2367
+ resolutions: ['1920x1080'],
2368
+ schedule: [
2369
+ { type: 'INVALID_TYPE', hours: [{ start: 0, end: 23 }] }
2370
+ ]
2371
+ });
2372
+ assert.strictEqual(status, 400, 'Should reject invalid schedule type');
2373
+ });
2374
+
2375
+ it('should reject duplicate externalId with detailed error message', async () => {
2376
+ if (!edgeDealId.value) {
2377
+ assert.ok(true, 'Skipping - deal creation failed');
2378
+ return;
2379
+ }
2380
+ const externalId = `e2e-li-dup-${Date.now()}`;
2381
+ await request('POST', `/deals/${edgeDealId.value}/line-items`, {
2382
+ externalId,
2383
+ name: 'First Line Item',
2384
+ creativeType: 'DISPLAY',
2385
+ timezoneId: 'UTC',
2386
+ startDate: '2025-01-01',
2387
+ endDate: '2025-12-31',
2388
+ resolutions: ['1920x1080']
2389
+ });
2390
+ const { status, data } = await request('POST', `/deals/${edgeDealId.value}/line-items`, {
2391
+ externalId,
2392
+ name: 'Duplicate Line Item',
2393
+ creativeType: 'DISPLAY',
2394
+ timezoneId: 'UTC',
2395
+ startDate: '2025-01-01',
2396
+ endDate: '2025-12-31',
2397
+ resolutions: ['1920x1080']
2398
+ });
2399
+ assert.strictEqual(status, 409, 'Should reject duplicate externalId');
2400
+ assert.strictEqual(data.code, 'CONFLICT', 'Error code should be CONFLICT');
2401
+ assert.ok(data.message.includes('Duplicate'), 'Error message should mention duplicate');
2402
+ assert.ok(Array.isArray(data.details), 'Should include error details array');
2403
+ assert.ok(data.details.some(d => d.field === 'external_id'), 'Details should include external_id field');
2404
+ });
2405
+
2406
+ it('should handle GET non-existent deal', async () => {
2407
+ const { status } = await request('GET', '/deals/non-existent-deal-id');
2408
+ assert.strictEqual(status, 404);
2409
+ });
2410
+
2411
+ it('should handle GET non-existent line item', async () => {
2412
+ if (!edgeDealId.value) {
2413
+ assert.ok(true, 'Skipping - deal creation failed');
2414
+ return;
2415
+ }
2416
+ const { status } = await request('GET', `/deals/${edgeDealId.value}/line-items/non-existent-id`);
2417
+ assert.strictEqual(status, 404);
2418
+ });
2419
+
2420
+ it('should archive edge test deal', async () => {
2421
+ if (!edgeDealId.value) {
2422
+ assert.ok(true, 'Skipping - no deal to archive');
2423
+ return;
2424
+ }
2425
+ const { status } = await request('DELETE', `/deals/${edgeDealId.value}`);
2426
+ assert.ok([200, 404].includes(status));
2427
+ });
2428
+ });
2429
+
2430
+ describe('14. Dual ID Resolution (UUID or dealId)', () => {
2431
+ const dualIdDeal = { value: null };
2432
+ const dualIdLineItem = { value: null };
2433
+ const dualIdCreative = { value: null };
2434
+
2435
+ it('should create a deal for dual ID testing', async () => {
2436
+ const { status, data } = await request('POST', '/deals', {
2437
+ name: 'Dual ID Test Deal',
2438
+ externalId: `e2e-dual-id-${Date.now()}`,
2439
+ source: 'e2e-test',
2440
+ mode: 'PROGRAMMATIC',
2441
+ dealType: 'GUARANTEED',
2442
+ currency: 'USD',
2443
+ seller: { id: 'seller-dual', name: 'Dual ID Seller' },
2444
+ programmatic: {
2445
+ buyers: [{ id: 'buyer-dual', name: 'Dual ID Buyer' }],
2446
+ transactionType: 'SPOT',
2447
+ costType: 'CPM'
2448
+ }
2449
+ });
2450
+ assert.strictEqual(status, 201);
2451
+ dualIdDeal.value = data.result;
2452
+ });
2453
+
2454
+ it('should GET deal by dealId', async () => {
2455
+ const { status, data } = await request('GET', `/deals/${dualIdDeal.value.dealId}`);
2456
+ assert.strictEqual(status, 200);
2457
+ assert.strictEqual(data.result.dealId, dualIdDeal.value.dealId);
2458
+ });
2459
+
2460
+ it('should GET deal by UUID (id field)', async () => {
2461
+ const { status, data } = await request('GET', `/deals/${dualIdDeal.value.id}`);
2462
+ assert.strictEqual(status, 200);
2463
+ assert.strictEqual(data.result.id, dualIdDeal.value.id);
2464
+ });
2465
+
2466
+ it('should PUT deal by UUID', async () => {
2467
+ const { status, data } = await request('PUT', `/deals/${dualIdDeal.value.id}`, { status: 'APPROVED' });
2468
+ assert.strictEqual(status, 200);
2469
+ assert.strictEqual(data.result.status, 'APPROVED');
2470
+ });
2471
+
2472
+ it('should create line item for dual ID testing', async () => {
2473
+ const { status, data } = await request('POST', `/deals/${dualIdDeal.value.id}/line-items`, {
2474
+ externalId: `e2e-dual-li-${Date.now()}`,
2475
+ name: 'Dual ID Line Item',
2476
+ creativeType: 'DISPLAY',
2477
+ timezoneId: 'UTC',
2478
+ startDate: '2025-01-01',
2479
+ endDate: '2025-12-31',
2480
+ resolutions: ['1920x1080'],
2481
+ thresholdCountPerDay: 50,
2482
+ programmatic: { costType: 'CPM', bidFloor: 5.00 }
2483
+ });
2484
+ assert.strictEqual(status, 201);
2485
+ dualIdLineItem.value = data.result;
2486
+ });
2487
+
2488
+ it('should GET line items using deal UUID', async () => {
2489
+ const { status, data } = await request('GET', `/deals/${dualIdDeal.value.id}/line-items`);
2490
+ assert.strictEqual(status, 200);
2491
+ assert.ok(data.result.data.length >= 1);
2492
+ });
2493
+
2494
+ it('should GET line item using deal UUID and line item id', async () => {
2495
+ const { status, data } = await request('GET', `/deals/${dualIdDeal.value.id}/line-items/${dualIdLineItem.value.id}`);
2496
+ assert.strictEqual(status, 200);
2497
+ assert.strictEqual(data.result.id, dualIdLineItem.value.id);
2498
+ });
2499
+
2500
+ it('should GET line item using deal UUID and externalId', async () => {
2501
+ const { status, data } = await request('GET', `/deals/${dualIdDeal.value.id}/line-items/${dualIdLineItem.value.externalId}`);
2502
+ assert.strictEqual(status, 200);
2503
+ assert.strictEqual(data.result.externalId, dualIdLineItem.value.externalId);
2504
+ });
2505
+
2506
+ it('should PUT line item using deal UUID', async () => {
2507
+ const { status, data } = await request('PUT', `/deals/${dualIdDeal.value.id}/line-items/${dualIdLineItem.value.externalId}`, {
2508
+ name: 'Updated via UUID'
2509
+ });
2510
+ assert.strictEqual(status, 200);
2511
+ assert.strictEqual(data.result.name, 'Updated via UUID');
2512
+ });
2513
+
2514
+ it('should POST creative using deal UUID', async () => {
2515
+ const { status, data } = await request('POST', `/deals/${dualIdDeal.value.id}/line-items/${dualIdLineItem.value.externalId}/creatives`, {
2516
+ creativeId: `e2e-dual-creative-${Date.now()}`,
2517
+ creativeUri: 'https://cdn.example.com/dual-test.jpg',
2518
+ creativeType: 'DISPLAY',
2519
+ resolution: '1920x1080'
2520
+ });
2521
+ assert.strictEqual(status, 201);
2522
+ dualIdCreative.value = data.result;
2523
+ });
2524
+
2525
+ it('should GET creatives using deal UUID', async () => {
2526
+ const { status, data } = await request('GET', `/deals/${dualIdDeal.value.id}/creatives`);
2527
+ assert.strictEqual(status, 200);
2528
+ assert.ok(data.result.data.length >= 1);
2529
+ });
2530
+
2531
+ it('should GET creatives using deal UUID and line item externalId', async () => {
2532
+ const { status, data } = await request('GET', `/deals/${dualIdDeal.value.id}/line-items/${dualIdLineItem.value.externalId}/creatives`);
2533
+ assert.strictEqual(status, 200);
2534
+ assert.ok(data.result.data.length >= 1);
2535
+ });
2536
+
2537
+ it('should GET single creative using deal UUID', async () => {
2538
+ const { status, data } = await request('GET', `/deals/${dualIdDeal.value.id}/line-items/${dualIdLineItem.value.externalId}/creatives/${dualIdCreative.value.creativeId}`);
2539
+ assert.strictEqual(status, 200);
2540
+ assert.strictEqual(data.result.creativeId, dualIdCreative.value.creativeId);
2541
+ });
2542
+
2543
+ it('should GET creative by creative UUID using deal UUID', async () => {
2544
+ const { status, data } = await request('GET', `/deals/${dualIdDeal.value.id}/line-items/${dualIdLineItem.value.externalId}/creatives/${dualIdCreative.value.id}`);
2545
+ assert.strictEqual(status, 200);
2546
+ assert.strictEqual(data.result.id, dualIdCreative.value.id);
2547
+ });
2548
+
2549
+ it('should POST approve creative using deal UUID', async () => {
2550
+ const { status, data } = await request('POST', `/deals/${dualIdDeal.value.id}/line-items/${dualIdLineItem.value.externalId}/creatives/${dualIdCreative.value.creativeId}`, {
2551
+ decision: 'APPROVED',
2552
+ reviewerId: 'reviewer-dual',
2553
+ reviewerName: 'Dual ID Reviewer'
2554
+ });
2555
+ assert.strictEqual(status, 200);
2556
+ assert.strictEqual(data.result.status, 'APPROVED');
2557
+ });
2558
+
2559
+ it('should create another creative for reject test', async () => {
2560
+ const { status, data } = await request('POST', `/deals/${dualIdDeal.value.id}/line-items/${dualIdLineItem.value.externalId}/creatives`, {
2561
+ creativeId: `e2e-dual-creative-reject-${Date.now()}`,
2562
+ creativeUri: 'https://cdn.example.com/dual-reject.jpg',
2563
+ creativeType: 'DISPLAY',
2564
+ resolution: '1920x1080'
2565
+ });
2566
+ assert.strictEqual(status, 201);
2567
+ dualIdCreative.rejectTest = data.result;
2568
+ });
2569
+
2570
+ it('should POST reject creative using deal UUID', async () => {
2571
+ const { status, data } = await request('POST', `/deals/${dualIdDeal.value.id}/line-items/${dualIdLineItem.value.externalId}/creatives/${dualIdCreative.rejectTest.creativeId}`, {
2572
+ decision: 'REJECTED',
2573
+ reviewerId: 'reviewer-dual',
2574
+ reviewerName: 'Dual ID Reviewer',
2575
+ reason: 'Testing reject via UUID'
2576
+ });
2577
+ assert.strictEqual(status, 200);
2578
+ assert.strictEqual(data.result.status, 'REJECTED');
2579
+ });
2580
+
2581
+ it('should create creative for delete test', async () => {
2582
+ const { status, data } = await request('POST', `/deals/${dualIdDeal.value.id}/line-items/${dualIdLineItem.value.externalId}/creatives`, {
2583
+ creativeId: `e2e-dual-creative-delete-${Date.now()}`,
2584
+ creativeUri: 'https://cdn.example.com/dual-delete.jpg',
2585
+ creativeType: 'DISPLAY',
2586
+ resolution: '1920x1080'
2587
+ });
2588
+ assert.strictEqual(status, 201);
2589
+ dualIdCreative.deleteTest = data.result;
2590
+ });
2591
+
2592
+ it('should DELETE creative using deal UUID', async () => {
2593
+ const { status, data } = await request('DELETE', `/deals/${dualIdDeal.value.id}/line-items/${dualIdLineItem.value.externalId}/creatives/${dualIdCreative.deleteTest.creativeId}`);
2594
+ assert.strictEqual(status, 200);
2595
+ assert.ok(data.result.message.includes('deleted'));
2596
+ });
2597
+
2598
+ it('should DELETE creative by creative UUID using deal UUID', async () => {
2599
+ const createRes = await request('POST', `/deals/${dualIdDeal.value.id}/line-items/${dualIdLineItem.value.externalId}/creatives`, {
2600
+ creativeId: `e2e-dual-creative-delete2-${Date.now()}`,
2601
+ creativeUri: 'https://cdn.example.com/dual-delete2.jpg',
2602
+ creativeType: 'DISPLAY',
2603
+ resolution: '1920x1080'
2604
+ });
2605
+ assert.strictEqual(createRes.status, 201);
2606
+
2607
+ const { status, data } = await request('DELETE', `/deals/${dualIdDeal.value.id}/line-items/${dualIdLineItem.value.externalId}/creatives/${createRes.data.result.id}`);
2608
+ assert.strictEqual(status, 200);
2609
+ });
2610
+
2611
+ it('should archive deal using UUID', async () => {
2612
+ const { status } = await request('DELETE', `/deals/${dualIdDeal.value.id}`);
2613
+ assert.strictEqual(status, 200);
2614
+ });
2615
+
2616
+ it('should verify archived deal by UUID', async () => {
2617
+ const { status, data } = await request('GET', `/deals/${dualIdDeal.value.id}`);
2618
+ assert.strictEqual(status, 200);
2619
+ assert.strictEqual(data.result.status, 'ARCHIVED');
2620
+ });
2621
+ });
2622
+
2623
+ describe('15. Minified Inventory Endpoints', () => {
2624
+ let minifiedTestDeal = {};
2625
+ let minifiedTestLineItem = {};
2626
+
2627
+ it('should create a deal with line items and inventories for minified test', async () => {
2628
+ const dealPayload = {
2629
+ name: 'Minified Inventory Test Deal',
2630
+ externalId: `e2e-minified-${Date.now()}`,
2631
+ source: 'e2e-test',
2632
+ mode: 'PROGRAMMATIC',
2633
+ dealType: 'PRIVATE_AUCTION',
2634
+ currency: 'USD'
2635
+ };
2636
+ const { status, data } = await request('POST', '/deals', dealPayload);
2637
+ assert.strictEqual(status, 201);
2638
+ minifiedTestDeal = data.result;
2639
+ });
2640
+
2641
+ it('should create a line item with inventories', async () => {
2642
+ const lineItemPayload = {
2643
+ externalId: `e2e-li-minified-${Date.now()}`,
2644
+ name: 'Minified Test Line Item',
2645
+ creativeType: 'DISPLAY',
2646
+ timezoneId: 'America/New_York',
2647
+ startDate: '2025-01-01',
2648
+ endDate: '2025-12-31',
2649
+ resolutions: ['1920x1080'],
2650
+ programmatic: {
2651
+ costType: 'CPM',
2652
+ bidFloor: 5.00
2653
+ },
2654
+ inventories: [
2655
+ { id: 'inv-minified-001', deviceId: 'device-alpha-001', name: 'Screen Alpha', size: '1920x1080', publisherId: 'pub-001', publisherName: 'Publisher One', metadata: { externalRefIds: [{ source: 'LMX', externalRefId: 'lmx-alpha-001' }] } },
2656
+ { id: 'inv-minified-002', deviceId: 'device-beta-001', name: 'Screen Beta', size: '1280x720', publisherId: 'pub-002', publisherName: 'Publisher Two' },
2657
+ { id: 'inv-minified-003', name: 'Screen Gamma', size: '1920x1080', publisherId: 'pub-001', publisherName: 'Publisher One' }
2658
+ ]
2659
+ };
2660
+ const { status, data } = await request('POST', `/deals/${minifiedTestDeal.dealId}/line-items`, lineItemPayload);
2661
+ assert.strictEqual(status, 201);
2662
+ minifiedTestLineItem = data.result;
2663
+ });
2664
+
2665
+ it('should GET minified inventories for deal', async () => {
2666
+ const { status, data } = await request('GET', `/deals/${minifiedTestDeal.dealId}/inventories/minified`);
2667
+ assert.strictEqual(status, 200);
2668
+ assert.ok(Array.isArray(data.result.data));
2669
+ assert.strictEqual(data.result.total, 3);
2670
+
2671
+ const inv = data.result.data[0];
2672
+ assert.ok(inv.id);
2673
+ assert.ok(inv.name);
2674
+ assert.ok(inv.publisher);
2675
+ assert.ok(inv.publisher.id);
2676
+ assert.ok(inv.publisher.name);
2677
+ assert.ok(inv.resolution);
2678
+
2679
+ assert.strictEqual(Object.keys(inv).length, 4);
2680
+ });
2681
+
2682
+ it('should GET minified inventories for deal using UUID', async () => {
2683
+ const { status, data } = await request('GET', `/deals/${minifiedTestDeal.id}/inventories/minified`);
2684
+ assert.strictEqual(status, 200);
2685
+ assert.strictEqual(data.result.total, 3);
2686
+ });
2687
+
2688
+ it('should GET minified inventories for line item', async () => {
2689
+ const { status, data } = await request('GET', `/deals/${minifiedTestDeal.dealId}/line-items/${minifiedTestLineItem.externalId}/inventories/minified`);
2690
+ assert.strictEqual(status, 200);
2691
+ assert.ok(Array.isArray(data.result.data));
2692
+ assert.strictEqual(data.result.total, 3);
2693
+
2694
+ const inv = data.result.data[0];
2695
+ assert.ok(inv.id);
2696
+ assert.ok(inv.name);
2697
+ assert.ok(inv.publisher);
2698
+ assert.ok(inv.resolution);
2699
+ });
2700
+
2701
+ it('should GET minified inventories for line item using deal UUID', async () => {
2702
+ const { status, data } = await request('GET', `/deals/${minifiedTestDeal.id}/line-items/${minifiedTestLineItem.externalId}/inventories/minified`);
2703
+ assert.strictEqual(status, 200);
2704
+ assert.strictEqual(data.result.total, 3);
2705
+ });
2706
+
2707
+ it('should return 404 for minified inventories with invalid deal', async () => {
2708
+ const { status } = await request('GET', '/deals/INVALID-DEAL/inventories/minified');
2709
+ assert.strictEqual(status, 404);
2710
+ });
2711
+
2712
+ it('should return 404 for minified inventories with invalid line item', async () => {
2713
+ const { status } = await request('GET', `/deals/${minifiedTestDeal.dealId}/line-items/INVALID-LI/inventories/minified`);
2714
+ assert.strictEqual(status, 404);
2715
+ });
2716
+
2717
+ it('should cleanup - archive minified test deal', async () => {
2718
+ const { status } = await request('DELETE', `/deals/${minifiedTestDeal.dealId}`);
2719
+ assert.strictEqual(status, 200);
2720
+ });
2721
+ });
2722
+
2723
+ describe('16. Inventory Replacement (PUT)', () => {
2724
+ let invTestDeal;
2725
+ let invTestLineItem;
2726
+
2727
+ it('should create test deal for inventory replacement', async () => {
2728
+ const { status, data } = await request('POST', '/deals', {
2729
+ name: 'Inventory Replacement Test Deal',
2730
+ externalId: `inv-replace-test-${Date.now()}`,
2731
+ source: 'e2e-test',
2732
+ mode: 'PROGRAMMATIC',
2733
+ dealType: 'PREFERRED_DEAL',
2734
+ currency: 'USD',
2735
+ seller: { id: 'seller-inv-test', name: 'Inventory Test Seller' },
2736
+ programmatic: {
2737
+ buyers: [{ id: 'buyer-inv-test', name: 'Test Buyer' }],
2738
+ transactionType: 'SPOT',
2739
+ costType: 'CPM'
2740
+ }
2741
+ });
2742
+ assert.strictEqual(status, 201);
2743
+ invTestDeal = data.result;
2744
+ });
2745
+
2746
+ it('should create line item with initial inventories', async () => {
2747
+ const { status, data } = await request('POST', `/deals/${invTestDeal.dealId}/line-items`, {
2748
+ externalId: `li-inv-test-${Date.now()}`,
2749
+ name: 'Inventory Replacement Test Line Item',
2750
+ creativeType: 'DISPLAY',
2751
+ timezoneId: 'UTC',
2752
+ startDate: '2025-01-01',
2753
+ endDate: '2025-12-31',
2754
+ resolutions: ['1920x1080'],
2755
+ programmatic: { costType: 'CPM', bidFloor: 5.00 },
2756
+ inventories: [
2757
+ { id: 'inv-original-001', name: 'Original Screen 1', size: '1920x1080' },
2758
+ { id: 'inv-original-002', name: 'Original Screen 2', size: '1920x1080' }
2759
+ ]
2760
+ });
2761
+ assert.strictEqual(status, 201);
2762
+ invTestLineItem = data.result;
2763
+ });
2764
+
2765
+ it('should verify original inventories exist', async () => {
2766
+ const { status, data } = await request('GET', `/deals/${invTestDeal.dealId}/line-items/${invTestLineItem.externalId}/inventories`);
2767
+ assert.strictEqual(status, 200);
2768
+ assert.strictEqual(data.result.pagination.total, 2);
2769
+ });
2770
+
2771
+ it('should replace inventories with new list', async () => {
2772
+ const { status, data } = await request('PUT', `/deals/${invTestDeal.dealId}/line-items/${invTestLineItem.externalId}/inventories`, {
2773
+ inventories: [
2774
+ { id: 'inv-new-001', name: 'New Screen A', size: '1920x1080', deviceId: 'DEVICE-A' },
2775
+ { id: 'inv-new-002', name: 'New Screen B', size: '3840x2160', deviceId: 'DEVICE-B' },
2776
+ { id: 'inv-new-003', name: 'New Screen C', size: '1920x1080', publisher: { id: 'pub-new', name: 'New Publisher' } }
2777
+ ]
2778
+ });
2779
+ assert.strictEqual(status, 200);
2780
+ assert.strictEqual(data.result.inventoriesCount, 3);
2781
+ assert.strictEqual(data.result.message, 'Inventories replaced successfully');
2782
+ });
2783
+
2784
+ it('should verify new inventories replaced old ones', async () => {
2785
+ const { status, data } = await request('GET', `/deals/${invTestDeal.dealId}/line-items/${invTestLineItem.externalId}/inventories`);
2786
+ assert.strictEqual(status, 200);
2787
+ assert.strictEqual(data.result.pagination.total, 3);
2788
+
2789
+ const invIds = data.result.data.map(inv => inv.id);
2790
+ assert.ok(invIds.includes('inv-new-001'));
2791
+ assert.ok(invIds.includes('inv-new-002'));
2792
+ assert.ok(invIds.includes('inv-new-003'));
2793
+ assert.ok(!invIds.includes('inv-original-001'));
2794
+ assert.ok(!invIds.includes('inv-original-002'));
2795
+ });
2796
+
2797
+ it('should replace with empty list to clear inventories', async () => {
2798
+ const { status, data } = await request('PUT', `/deals/${invTestDeal.dealId}/line-items/${invTestLineItem.externalId}/inventories`, {
2799
+ inventories: []
2800
+ });
2801
+ assert.strictEqual(status, 200);
2802
+ assert.strictEqual(data.result.inventoriesCount, 0);
2803
+ });
2804
+
2805
+ it('should verify all inventories cleared', async () => {
2806
+ const { status, data } = await request('GET', `/deals/${invTestDeal.dealId}/line-items/${invTestLineItem.externalId}/inventories`);
2807
+ assert.strictEqual(status, 200);
2808
+ assert.strictEqual(data.result.pagination.total, 0);
2809
+ });
2810
+
2811
+ it('should reject PUT inventories with missing required id', async () => {
2812
+ const { status, data } = await request('PUT', `/deals/${invTestDeal.dealId}/line-items/${invTestLineItem.externalId}/inventories`, {
2813
+ inventories: [
2814
+ { name: 'Screen Without ID', size: '1920x1080' }
2815
+ ]
2816
+ });
2817
+ assert.strictEqual(status, 400);
2818
+ assert.strictEqual(data.code, 'VALIDATION_ERROR');
2819
+ });
2820
+
2821
+ it('should replace with inventories including metadata.externalRefIds', async () => {
2822
+ const { status, data } = await request('PUT', `/deals/${invTestDeal.dealId}/line-items/${invTestLineItem.externalId}/inventories`, {
2823
+ inventories: [
2824
+ {
2825
+ id: 'inv-ext-001',
2826
+ name: 'Screen with External Refs',
2827
+ size: '1920x1080',
2828
+ metadata: {
2829
+ externalRefIds: [
2830
+ { source: 'LMX', externalRefId: 'lmx-12345' },
2831
+ { source: 'BROADSIGN', externalRefId: 'bs-67890' }
2832
+ ]
2833
+ }
2834
+ }
2835
+ ]
2836
+ });
2837
+ assert.strictEqual(status, 200);
2838
+ assert.strictEqual(data.result.inventoriesCount, 1);
2839
+ });
2840
+
2841
+ it('should verify externalRefIds persisted', async () => {
2842
+ const { status, data } = await request('GET', `/deals/${invTestDeal.dealId}/line-items/${invTestLineItem.externalId}/inventories`);
2843
+ assert.strictEqual(status, 200);
2844
+ const inv = data.result.data[0];
2845
+ assert.ok(inv.metadata?.externalRefIds);
2846
+ assert.strictEqual(inv.metadata.externalRefIds.length, 2);
2847
+ assert.strictEqual(inv.metadata.externalRefIds[0].source, 'LMX');
2848
+ });
2849
+
2850
+ it('should return 404 for invalid deal', async () => {
2851
+ const { status } = await request('PUT', '/deals/INVALID-DEAL/line-items/li-test/inventories', {
2852
+ inventories: []
2853
+ });
2854
+ assert.strictEqual(status, 404);
2855
+ });
2856
+
2857
+ it('should return 404 for invalid line item', async () => {
2858
+ const { status } = await request('PUT', `/deals/${invTestDeal.dealId}/line-items/INVALID-LI/inventories`, {
2859
+ inventories: []
2860
+ });
2861
+ assert.strictEqual(status, 404);
2862
+ });
2863
+
2864
+ it('should cleanup - archive inventory test deal', async () => {
2865
+ const { status } = await request('DELETE', `/deals/${invTestDeal.dealId}`);
2866
+ assert.strictEqual(status, 200);
2867
+ });
2868
+ });
2869
+
2870
+ describe('17. Auto-IO Creation via Import with DIRECT_PUBLISHER_SPLIT', () => {
2871
+ it('should auto-create insertion orders when importing DIRECT_PUBLISHER_SPLIT payload', async () => {
2872
+ const timestamp = Date.now();
2873
+ const { status, data } = await request('POST', '/deals/import', {
2874
+ payloadType: 'DIRECT_PUBLISHER_SPLIT',
2875
+ externalPayload: {
2876
+ campaign: {
2877
+ externalId: `import-auto-io-${timestamp}`,
2878
+ source: 'e2e-test',
2879
+ name: 'Auto IO Import Test',
2880
+ status: 'DRAFT',
2881
+ currency: 'USD',
2882
+ brand: 'TestBrand',
2883
+ clientType: 'AGENCY',
2884
+ marketSelection: { country: 'US', currency: 'USD' },
2885
+ budgetSetup: { currency: 'USD', budgetAmount: 50000, budgetType: 'TOTAL' },
2886
+ campaignGoal: { type: 'IMPRESSIONS', targetValue: 500000 },
2887
+ startDate: '2025-02-01',
2888
+ endDate: '2025-04-30',
2889
+ timezoneId: 'America/Chicago',
2890
+ creativeType: 'DISPLAY'
2891
+ },
2892
+ inventories: [
2893
+ { id: `inv-1-${timestamp}`, name: 'Screen 1', size: '1920x1080', publisher: { id: 'pub-a', name: 'Publisher A' } },
2894
+ { id: `inv-2-${timestamp}`, name: 'Screen 2', size: '1920x1080', publisher: { id: 'pub-a', name: 'Publisher A' } },
2895
+ { id: `inv-3-${timestamp}`, name: 'Screen 3', size: '1920x1080', publisher: { id: 'pub-b', name: 'Publisher B' } }
2896
+ ]
2897
+ }
2898
+ });
2899
+
2900
+ assert.strictEqual(status, 201, `Import should succeed: ${JSON.stringify(data)}`);
2901
+ assert.strictEqual(data.result.summary.payloadType, 'DIRECT_PUBLISHER_SPLIT');
2902
+ assert.strictEqual(data.result.summary.insertionOrdersCreated, 2);
2903
+ assert.strictEqual(data.result.summary.lineItemsProcessed, 2);
2904
+
2905
+ const dealId = data.result.deal.dealId;
2906
+ const { status: getStatus, data: getData } = await request('GET', `/deals/${dealId}?embed=insertionOrders`);
2907
+ assert.strictEqual(getStatus, 200);
2908
+ assert.ok(getData.result.insertionOrders);
2909
+ assert.strictEqual(getData.result.insertionOrders.length, 2);
2910
+
2911
+ await request('DELETE', `/deals/${dealId}`);
2912
+ });
2913
+
2914
+ it('should create individual line items for scheduled inventories and group the rest', async () => {
2915
+ const timestamp = Date.now();
2916
+ const { status, data } = await request('POST', '/deals/import', {
2917
+ payloadType: 'DIRECT_PUBLISHER_SPLIT',
2918
+ externalPayload: {
2919
+ campaign: {
2920
+ externalId: `import-schedule-${timestamp}`,
2921
+ source: 'e2e-test',
2922
+ name: 'Scheduled Import Test',
2923
+ status: 'DRAFT',
2924
+ currency: 'USD',
2925
+ brand: 'TestBrand',
2926
+ clientType: 'AGENCY',
2927
+ marketSelection: { country: 'US', currency: 'USD' },
2928
+ budgetSetup: { currency: 'USD', budgetAmount: 100000, budgetType: 'TOTAL' },
2929
+ campaignGoal: { type: 'IMPRESSIONS', targetValue: 1000000 },
2930
+ startDate: '2025-03-01',
2931
+ endDate: '2025-05-31',
2932
+ timezoneId: 'America/New_York',
2933
+ creativeType: 'DISPLAY'
2934
+ },
2935
+ inventories: [
2936
+ // Inventory with schedule -> individual line item
2937
+ {
2938
+ id: `inv-sched-1-${timestamp}`,
2939
+ name: 'Premium Screen',
2940
+ size: '1920x1080',
2941
+ publisher: { id: 'pub-x', name: 'Publisher X' },
2942
+ schedule: [{ type: 'CUSTOM', date: '2025-03-15', hours: [{ start: 8, end: 18 }] }]
2943
+ },
2944
+ // Inventory with schedule -> individual line item
2945
+ {
2946
+ id: `inv-sched-2-${timestamp}`,
2947
+ name: 'Evening Screen',
2948
+ size: '1920x1080',
2949
+ publisher: { id: 'pub-x', name: 'Publisher X' },
2950
+ schedule: [{ type: 'CUSTOM', date: '2025-03-16', hours: [{ start: 18, end: 22 }] }]
2951
+ },
2952
+ // Non-scheduled inventories -> grouped by publisher + resolution + duration
2953
+ { id: `inv-grp-1-${timestamp}`, name: 'Standard 1', size: '1920x1080', publisher: { id: 'pub-x', name: 'Publisher X' } },
2954
+ { id: `inv-grp-2-${timestamp}`, name: 'Standard 2', size: '1920x1080', publisher: { id: 'pub-x', name: 'Publisher X' } },
2955
+ { id: `inv-grp-3-${timestamp}`, name: 'Portrait 1', size: '1080x1920', publisher: { id: 'pub-x', name: 'Publisher X' } },
2956
+ { id: `inv-grp-4-${timestamp}`, name: 'Standard 3', size: '1920x1080', publisher: { id: 'pub-y', name: 'Publisher Y' } }
2957
+ ]
2958
+ }
2959
+ });
2960
+
2961
+ assert.strictEqual(status, 201, `Import should succeed: ${JSON.stringify(data)}`);
2962
+ assert.strictEqual(data.result.summary.payloadType, 'DIRECT_PUBLISHER_SPLIT');
2963
+ // 2 scheduled + 3 groups (pub-x|1920x1080, pub-x|1080x1920, pub-y|1920x1080) = 5 line items
2964
+ assert.strictEqual(data.result.summary.lineItemsProcessed, 5);
2965
+
2966
+ const dealId = data.result.deal.dealId;
2967
+ const { status: getStatus, data: getData } = await request('GET', `/deals/${dealId}?embed=lineItems`);
2968
+ assert.strictEqual(getStatus, 200);
2969
+ assert.strictEqual(getData.result.lineItems.length, 5);
2970
+
2971
+ // Verify scheduled line items have 1 inventory each
2972
+ const scheduledLIs = getData.result.lineItems.filter(li => li.name.includes('Premium Screen') || li.name.includes('Evening Screen'));
2973
+ assert.strictEqual(scheduledLIs.length, 2);
2974
+
2975
+ await request('DELETE', `/deals/${dealId}`);
2976
+ });
2977
+
2978
+ it('should create individual line items for inventories with valid schedules and group those without', async () => {
2979
+ const timestamp = Date.now();
2980
+ const { status, data } = await request('POST', '/deals/import', {
2981
+ payloadType: 'DIRECT_PUBLISHER_SPLIT',
2982
+ externalPayload: {
2983
+ campaign: {
2984
+ externalId: `test-schedule-key-${timestamp}`,
2985
+ source: 'e2e-test',
2986
+ name: 'Schedule Key Test',
2987
+ status: 'DRAFT',
2988
+ currency: 'USD',
2989
+ brand: 'TestBrand',
2990
+ clientType: 'AGENCY',
2991
+ marketSelection: { country: 'US', currency: 'USD' },
2992
+ budgetSetup: { currency: 'USD', budgetAmount: 100000, budgetType: 'TOTAL' },
2993
+ campaignGoal: { type: 'IMPRESSIONS', targetValue: 1000000 },
2994
+ startDate: '2025-03-01',
2995
+ endDate: '2025-05-31',
2996
+ timezoneId: 'America/New_York',
2997
+ creativeType: 'DISPLAY'
2998
+ },
2999
+ inventories: [
3000
+ // Inventory WITH valid schedule -> individual line item
3001
+ {
3002
+ id: `inv-with-sched-1-${timestamp}`,
3003
+ name: 'Screen with Schedule',
3004
+ size: '1920x1080',
3005
+ publisher: { id: 'pub-x', name: 'Publisher X' },
3006
+ schedule: [{ type: 'CUSTOM', date: '2025-03-15', hours: [{ start: 8, end: 18 }] }]
3007
+ },
3008
+ // Inventory WITH valid weekday schedule -> individual line item
3009
+ {
3010
+ id: `inv-with-weekday-${timestamp}`,
3011
+ name: 'Screen with Weekday Schedule',
3012
+ size: '1920x1080',
3013
+ publisher: { id: 'pub-x', name: 'Publisher X' },
3014
+ schedule: [{ type: 'WEEKDAY', hours: [{ start: 9, end: 17 }] }]
3015
+ },
3016
+ // Inventory WITH null schedule -> grouped (no valid schedule)
3017
+ {
3018
+ id: `inv-null-sched-${timestamp}`,
3019
+ name: 'Screen with Null Schedule',
3020
+ size: '1920x1080',
3021
+ publisher: { id: 'pub-x', name: 'Publisher X' },
3022
+ schedule: null
3023
+ },
3024
+ // Inventory WITH empty schedule array -> grouped (no valid schedule)
3025
+ {
3026
+ id: `inv-empty-sched-${timestamp}`,
3027
+ name: 'Screen with Empty Schedule',
3028
+ size: '1920x1080',
3029
+ publisher: { id: 'pub-x', name: 'Publisher X' },
3030
+ schedule: []
3031
+ },
3032
+ // Inventory WITHOUT schedule key -> grouped
3033
+ {
3034
+ id: `inv-no-sched-1-${timestamp}`,
3035
+ name: 'No Schedule Screen 1',
3036
+ size: '1920x1080',
3037
+ publisher: { id: 'pub-x', name: 'Publisher X' }
3038
+ },
3039
+ // Inventory WITHOUT schedule key, different publisher -> grouped separately
3040
+ {
3041
+ id: `inv-no-sched-2-${timestamp}`,
3042
+ name: 'No Schedule Screen 2',
3043
+ size: '1920x1080',
3044
+ publisher: { id: 'pub-y', name: 'Publisher Y' }
3045
+ }
3046
+ ]
3047
+ }
3048
+ });
3049
+
3050
+ assert.strictEqual(status, 201, `Import should succeed: ${JSON.stringify(data)}`);
3051
+ // 2 with valid schedule -> 2 individual line items
3052
+ // 4 without valid schedule (null, empty, no key) -> 2 grouped line items (pub-x has 3, pub-y has 1)
3053
+ // Total: 4 line items
3054
+ assert.strictEqual(data.result.summary.lineItemsProcessed, 4);
3055
+
3056
+ const dealId = data.result.deal.dealId;
3057
+ const { status: getStatus, data: getData } = await request('GET', `/deals/${dealId}?embed=lineItems`);
3058
+ assert.strictEqual(getStatus, 200);
3059
+ assert.strictEqual(getData.result.lineItems.length, 4);
3060
+
3061
+ await request('DELETE', `/deals/${dealId}`);
3062
+ });
3063
+
3064
+ it('should handle all schedule types (WEEKEND, DEFAULT) as valid schedules', async () => {
3065
+ const timestamp = Date.now();
3066
+ const { status, data } = await request('POST', '/deals/import', {
3067
+ payloadType: 'DIRECT_PUBLISHER_SPLIT',
3068
+ externalPayload: {
3069
+ campaign: {
3070
+ externalId: `test-all-schedule-types-${timestamp}`,
3071
+ source: 'e2e-test',
3072
+ name: 'All Schedule Types Test',
3073
+ status: 'DRAFT',
3074
+ currency: 'USD',
3075
+ brand: 'TestBrand',
3076
+ clientType: 'AGENCY',
3077
+ marketSelection: { country: 'US', currency: 'USD' },
3078
+ budgetSetup: { currency: 'USD', budgetAmount: 50000, budgetType: 'TOTAL' },
3079
+ campaignGoal: { type: 'IMPRESSIONS', targetValue: 500000 },
3080
+ startDate: '2025-04-01',
3081
+ endDate: '2025-06-30',
3082
+ timezoneId: 'UTC',
3083
+ creativeType: 'DISPLAY'
3084
+ },
3085
+ inventories: [
3086
+ { id: `inv-weekend-${timestamp}`, name: 'Weekend Screen', size: '1920x1080', publisher: { id: 'pub-a', name: 'Pub A' }, schedule: [{ type: 'WEEKEND', hours: [{ start: 10, end: 20 }] }] },
3087
+ { id: `inv-default-${timestamp}`, name: 'Default Screen', size: '1920x1080', publisher: { id: 'pub-a', name: 'Pub A' }, schedule: [{ type: 'DEFAULT', hours: [{ start: 0, end: 23 }] }] },
3088
+ { id: `inv-custom-${timestamp}`, name: 'Custom Screen', size: '1920x1080', publisher: { id: 'pub-a', name: 'Pub A' }, schedule: [{ type: 'CUSTOM', date: '2025-04-15', hours: [{ start: 8, end: 18 }] }] },
3089
+ { id: `inv-weekday-${timestamp}`, name: 'Weekday Screen', size: '1920x1080', publisher: { id: 'pub-a', name: 'Pub A' }, schedule: [{ type: 'WEEKDAY', hours: [{ start: 9, end: 17 }] }] },
3090
+ { id: `inv-grouped-${timestamp}`, name: 'Grouped Screen', size: '1920x1080', publisher: { id: 'pub-a', name: 'Pub A' } }
3091
+ ]
3092
+ }
3093
+ });
3094
+
3095
+ assert.strictEqual(status, 201, `Import should succeed: ${JSON.stringify(data)}`);
3096
+ assert.strictEqual(data.result.summary.lineItemsProcessed, 5);
3097
+
3098
+ await request('DELETE', `/deals/${data.result.deal.dealId}`);
3099
+ });
3100
+
3101
+ it('should group inventories by publisher + resolution + duration correctly', async () => {
3102
+ const timestamp = Date.now();
3103
+ const { status, data } = await request('POST', '/deals/import', {
3104
+ payloadType: 'DIRECT_PUBLISHER_SPLIT',
3105
+ externalPayload: {
3106
+ campaign: {
3107
+ externalId: `test-grouping-combos-${timestamp}`,
3108
+ source: 'e2e-test',
3109
+ name: 'Grouping Combinations Test',
3110
+ status: 'DRAFT',
3111
+ currency: 'USD',
3112
+ brand: 'TestBrand',
3113
+ clientType: 'AGENCY',
3114
+ marketSelection: { country: 'US', currency: 'USD' },
3115
+ budgetSetup: { currency: 'USD', budgetAmount: 75000, budgetType: 'TOTAL' },
3116
+ campaignGoal: { type: 'IMPRESSIONS', targetValue: 750000 },
3117
+ startDate: '2025-05-01',
3118
+ endDate: '2025-07-31',
3119
+ timezoneId: 'UTC',
3120
+ creativeType: 'VIDEO',
3121
+ duration: 15
3122
+ },
3123
+ inventories: [
3124
+ { id: `inv-p1-r1-d15-1-${timestamp}`, name: 'P1 R1 D15 #1', size: '1920x1080', duration: 15, publisher: { id: 'pub-1', name: 'Publisher 1' } },
3125
+ { id: `inv-p1-r1-d15-2-${timestamp}`, name: 'P1 R1 D15 #2', size: '1920x1080', duration: 15, publisher: { id: 'pub-1', name: 'Publisher 1' } },
3126
+ { id: `inv-p1-r1-d30-1-${timestamp}`, name: 'P1 R1 D30 #1', size: '1920x1080', duration: 30, publisher: { id: 'pub-1', name: 'Publisher 1' } },
3127
+ { id: `inv-p1-r2-d15-1-${timestamp}`, name: 'P1 R2 D15 #1', size: '1080x1920', duration: 15, publisher: { id: 'pub-1', name: 'Publisher 1' } },
3128
+ { id: `inv-p2-r1-d15-1-${timestamp}`, name: 'P2 R1 D15 #1', size: '1920x1080', duration: 15, publisher: { id: 'pub-2', name: 'Publisher 2' } },
3129
+ { id: `inv-p2-r1-d15-2-${timestamp}`, name: 'P2 R1 D15 #2', size: '1920x1080', duration: 15, publisher: { id: 'pub-2', name: 'Publisher 2' } }
3130
+ ]
3131
+ }
3132
+ });
3133
+
3134
+ assert.strictEqual(status, 201, `Import should succeed: ${JSON.stringify(data)}`);
3135
+ assert.strictEqual(data.result.summary.lineItemsProcessed, 4);
3136
+
3137
+ await request('DELETE', `/deals/${data.result.deal.dealId}`);
3138
+ });
3139
+
3140
+ it('should handle single inventory import', async () => {
3141
+ const timestamp = Date.now();
3142
+ const { status, data } = await request('POST', '/deals/import', {
3143
+ payloadType: 'DIRECT_PUBLISHER_SPLIT',
3144
+ externalPayload: {
3145
+ campaign: {
3146
+ externalId: `test-single-inv-${timestamp}`,
3147
+ source: 'e2e-test',
3148
+ name: 'Single Inventory Test',
3149
+ status: 'DRAFT',
3150
+ currency: 'USD',
3151
+ brand: 'TestBrand',
3152
+ clientType: 'AGENCY',
3153
+ marketSelection: { country: 'US', currency: 'USD' },
3154
+ budgetSetup: { currency: 'USD', budgetAmount: 10000, budgetType: 'TOTAL' },
3155
+ campaignGoal: { type: 'IMPRESSIONS', targetValue: 100000 },
3156
+ startDate: '2025-06-01',
3157
+ endDate: '2025-06-30',
3158
+ timezoneId: 'UTC',
3159
+ creativeType: 'DISPLAY'
3160
+ },
3161
+ inventories: [
3162
+ { id: `single-inv-${timestamp}`, name: 'Solo Screen', size: '1920x1080', publisher: { id: 'pub-solo', name: 'Solo Publisher' } }
3163
+ ]
3164
+ }
3165
+ });
3166
+
3167
+ assert.strictEqual(status, 201, `Import should succeed: ${JSON.stringify(data)}`);
3168
+ assert.strictEqual(data.result.summary.lineItemsProcessed, 1);
3169
+
3170
+ await request('DELETE', `/deals/${data.result.deal.dealId}`);
3171
+ });
3172
+
3173
+ it('should reject empty inventories array for DIRECT_PUBLISHER_SPLIT', async () => {
3174
+ const timestamp = Date.now();
3175
+ const { status, data } = await request('POST', '/deals/import', {
3176
+ payloadType: 'DIRECT_PUBLISHER_SPLIT',
3177
+ externalPayload: {
3178
+ campaign: {
3179
+ externalId: `test-empty-inv-${timestamp}`,
3180
+ source: 'e2e-test',
3181
+ name: 'Empty Inventories Test',
3182
+ status: 'DRAFT',
3183
+ currency: 'USD',
3184
+ brand: 'TestBrand',
3185
+ clientType: 'AGENCY',
3186
+ marketSelection: { country: 'US', currency: 'USD' },
3187
+ budgetSetup: { currency: 'USD', budgetAmount: 5000, budgetType: 'TOTAL' },
3188
+ campaignGoal: { type: 'IMPRESSIONS', targetValue: 50000 },
3189
+ startDate: '2025-07-01',
3190
+ endDate: '2025-07-31',
3191
+ timezoneId: 'UTC',
3192
+ creativeType: 'DISPLAY'
3193
+ },
3194
+ inventories: []
3195
+ }
3196
+ });
3197
+
3198
+ assert.strictEqual(status, 400);
3199
+ assert.ok(data.message || data.errors, `Expected error message: ${JSON.stringify(data)}`);
3200
+ });
3201
+
3202
+ it('should reject import with missing required campaign fields', async () => {
3203
+ const timestamp = Date.now();
3204
+ const { status, data } = await request('POST', '/deals/import', {
3205
+ payloadType: 'DIRECT_PUBLISHER_SPLIT',
3206
+ externalPayload: {
3207
+ campaign: {
3208
+ externalId: `test-missing-fields-${timestamp}`,
3209
+ source: 'e2e-test',
3210
+ name: 'Missing Fields Test'
3211
+ },
3212
+ inventories: []
3213
+ }
3214
+ });
3215
+
3216
+ assert.strictEqual(status, 400);
3217
+ assert.ok(data.error || data.message);
3218
+ });
3219
+
3220
+ it('should reject import with invalid payloadType', async () => {
3221
+ const { status, data } = await request('POST', '/deals/import', {
3222
+ payloadType: 'INVALID_TYPE',
3223
+ externalPayload: { campaign: {}, inventories: [] }
3224
+ });
3225
+
3226
+ assert.strictEqual(status, 400);
3227
+ assert.ok(data.error || data.message);
3228
+ });
3229
+
3230
+ it('should handle inventory with missing publisher gracefully', async () => {
3231
+ const timestamp = Date.now();
3232
+ const { status, data } = await request('POST', '/deals/import', {
3233
+ payloadType: 'DIRECT_PUBLISHER_SPLIT',
3234
+ externalPayload: {
3235
+ campaign: {
3236
+ externalId: `test-no-publisher-${timestamp}`,
3237
+ source: 'e2e-test',
3238
+ name: 'No Publisher Test',
3239
+ status: 'DRAFT',
3240
+ currency: 'USD',
3241
+ brand: 'TestBrand',
3242
+ clientType: 'AGENCY',
3243
+ marketSelection: { country: 'US', currency: 'USD' },
3244
+ budgetSetup: { currency: 'USD', budgetAmount: 10000, budgetType: 'TOTAL' },
3245
+ campaignGoal: { type: 'IMPRESSIONS', targetValue: 100000 },
3246
+ startDate: '2025-08-01',
3247
+ endDate: '2025-08-31',
3248
+ timezoneId: 'UTC',
3249
+ creativeType: 'DISPLAY'
3250
+ },
3251
+ inventories: [
3252
+ { id: `inv-no-pub-${timestamp}`, name: 'No Publisher Screen', size: '1920x1080' }
3253
+ ]
3254
+ }
3255
+ });
3256
+
3257
+ assert.ok([201, 400].includes(status), `Expected 201 or 400, got ${status}`);
3258
+ if (status === 201) {
3259
+ await request('DELETE', `/deals/${data.result.deal.dealId}`);
3260
+ }
3261
+ });
3262
+ });
3263
+
3264
+ describe('17b. Inventory Replacement Tests', () => {
3265
+ let testDealId;
3266
+ let testLineItemId;
3267
+
3268
+ before(async () => {
3269
+ const timestamp = Date.now();
3270
+ const { status, data } = await request('POST', '/deals/import', {
3271
+ payloadType: 'DIRECT_PUBLISHER_SPLIT',
3272
+ externalPayload: {
3273
+ campaign: {
3274
+ externalId: `inv-replace-test-${timestamp}`,
3275
+ source: 'e2e-test',
3276
+ name: 'Inventory Replacement Test Deal',
3277
+ status: 'DRAFT',
3278
+ currency: 'USD',
3279
+ brand: 'TestBrand',
3280
+ clientType: 'AGENCY',
3281
+ marketSelection: { country: 'US', currency: 'USD' },
3282
+ budgetSetup: { currency: 'USD', budgetAmount: 20000, budgetType: 'TOTAL' },
3283
+ campaignGoal: { type: 'IMPRESSIONS', targetValue: 200000 },
3284
+ startDate: '2025-09-01',
3285
+ endDate: '2025-09-30',
3286
+ timezoneId: 'UTC',
3287
+ creativeType: 'DISPLAY'
3288
+ },
3289
+ inventories: [
3290
+ { id: `initial-inv-1-${timestamp}`, name: 'Initial Screen 1', size: '1920x1080', publisher: { id: 'pub-init', name: 'Initial Publisher' } },
3291
+ { id: `initial-inv-2-${timestamp}`, name: 'Initial Screen 2', size: '1920x1080', publisher: { id: 'pub-init', name: 'Initial Publisher' } }
3292
+ ]
3293
+ }
3294
+ });
3295
+
3296
+ if (status === 201) {
3297
+ testDealId = data.result.deal.dealId;
3298
+ const { data: dealData } = await request('GET', `/deals/${testDealId}?embed=lineItems`);
3299
+ if (dealData.result.lineItems && dealData.result.lineItems.length > 0) {
3300
+ testLineItemId = dealData.result.lineItems[0].id;
3301
+ }
3302
+ }
3303
+ });
3304
+
3305
+ after(async () => {
3306
+ if (testDealId) {
3307
+ await request('DELETE', `/deals/${testDealId}`);
3308
+ }
3309
+ });
3310
+
3311
+ it('should replace all inventories on a line item via PUT', async function() {
3312
+ if (!testLineItemId) {
3313
+ this.skip();
3314
+ return;
3315
+ }
3316
+
3317
+ const timestamp = Date.now();
3318
+ const { status, data } = await request('PUT', `/deals/${testDealId}/line-items/${testLineItemId}/inventories`, {
3319
+ inventories: [
3320
+ { id: `replaced-inv-1-${timestamp}`, name: 'Replaced Screen 1', size: '1920x1080', publisher: { id: 'pub-new', name: 'New Publisher' } },
3321
+ { id: `replaced-inv-2-${timestamp}`, name: 'Replaced Screen 2', size: '1920x1080', publisher: { id: 'pub-new', name: 'New Publisher' } },
3322
+ { id: `replaced-inv-3-${timestamp}`, name: 'Replaced Screen 3', size: '1920x1080', publisher: { id: 'pub-new', name: 'New Publisher' } }
3323
+ ]
3324
+ });
3325
+
3326
+ assert.strictEqual(status, 200, `Replace should succeed: ${JSON.stringify(data)}`);
3327
+ const inventoryCount = data.result?.totalInventories ?? data.result?.inventories?.length ?? data.result?.count;
3328
+ assert.ok(inventoryCount === 3 || inventoryCount === undefined, `Inventory count: ${inventoryCount}`);
3329
+ });
3330
+
3331
+ it('should update totalInventories count after replacement', async function() {
3332
+ if (!testLineItemId) {
3333
+ this.skip();
3334
+ return;
3335
+ }
3336
+
3337
+ const { status, data } = await request('GET', `/deals/${testDealId}/line-items/${testLineItemId}`);
3338
+ assert.strictEqual(status, 200);
3339
+ assert.ok(data.result.totalInventories >= 0);
3340
+ });
3341
+ });
3342
+
3343
+ describe('17c. List, Search, Filter, and Pagination Tests', () => {
3344
+ const getDealsArray = (data) => data.result?.data || data.result?.deals || data.result || data.deals || data.data || [];
3345
+
3346
+ it('should list deals with pagination', async () => {
3347
+ const { status, data } = await request('GET', '/deals?limit=5&offset=0');
3348
+ assert.strictEqual(status, 200);
3349
+ const deals = getDealsArray(data);
3350
+ assert.ok(Array.isArray(deals), `Expected array, got: ${JSON.stringify(data).slice(0, 200)}`);
3351
+ });
3352
+
3353
+ it('should filter deals by mode', async () => {
3354
+ const { status, data } = await request('GET', '/deals?mode=DIRECT');
3355
+ assert.strictEqual(status, 200);
3356
+ const deals = getDealsArray(data);
3357
+ if (Array.isArray(deals) && deals.length > 0) {
3358
+ assert.ok(deals.every(d => d.mode === 'DIRECT'));
3359
+ }
3360
+ });
3361
+
3362
+ it('should filter deals by status', async () => {
3363
+ const { status, data } = await request('GET', '/deals?status=DRAFT');
3364
+ assert.strictEqual(status, 200);
3365
+ const deals = getDealsArray(data);
3366
+ if (Array.isArray(deals) && deals.length > 0) {
3367
+ assert.ok(deals.every(d => d.status === 'DRAFT'));
3368
+ }
3369
+ });
3370
+
3371
+ it('should search deals by name', async () => {
3372
+ const { status, data } = await request('GET', '/deals?search=Test');
3373
+ assert.strictEqual(status, 200);
3374
+ const deals = getDealsArray(data);
3375
+ assert.ok(Array.isArray(deals), `Expected array, got: ${JSON.stringify(data).slice(0, 200)}`);
3376
+ });
3377
+
3378
+ it('should combine multiple filters', async () => {
3379
+ const { status, data } = await request('GET', '/deals?mode=DIRECT&status=DRAFT&limit=10');
3380
+ assert.strictEqual(status, 200);
3381
+ const deals = getDealsArray(data);
3382
+ assert.ok(Array.isArray(deals), `Expected array, got: ${JSON.stringify(data).slice(0, 200)}`);
3383
+ if (deals.length > 0) {
3384
+ assert.ok(deals.every(d => d.mode === 'DIRECT' && d.status === 'DRAFT'));
3385
+ }
3386
+ });
3387
+
3388
+ it('should handle pagination with page parameter', async () => {
3389
+ const { status: status1, data: data1 } = await request('GET', '/deals?limit=2&page=1');
3390
+ const { status: status2, data: data2 } = await request('GET', '/deals?limit=2&page=2');
3391
+
3392
+ assert.strictEqual(status1, 200);
3393
+ assert.strictEqual(status2, 200);
3394
+
3395
+ const deals1 = getDealsArray(data1);
3396
+ const deals2 = getDealsArray(data2);
3397
+
3398
+ assert.ok(Array.isArray(deals1), 'First page should return array');
3399
+ assert.ok(Array.isArray(deals2), 'Second page should return array');
3400
+
3401
+ if (deals1.length > 0 && deals2.length > 0) {
3402
+ const ids1 = deals1.map(d => d.dealId);
3403
+ const ids2 = deals2.map(d => d.dealId);
3404
+ const overlap = ids1.filter(id => ids2.includes(id));
3405
+ assert.strictEqual(overlap.length, 0, 'Paginated results should not overlap');
3406
+ }
3407
+ });
3408
+
3409
+ it('should filter deals by date range', async () => {
3410
+ const { status, data } = await request('GET', '/deals?startDateFrom=2025-01-01&startDateTo=2025-12-31');
3411
+ assert.strictEqual(status, 200);
3412
+ const deals = getDealsArray(data);
3413
+ assert.ok(Array.isArray(deals), `Expected array, got: ${JSON.stringify(data).slice(0, 200)}`);
3414
+ });
3415
+
3416
+ it('should return empty array for non-matching filters', async () => {
3417
+ const { status, data } = await request('GET', '/deals?search=ZZZZNONEXISTENT99999');
3418
+ assert.strictEqual(status, 200);
3419
+ const deals = getDealsArray(data);
3420
+ assert.ok(Array.isArray(deals), `Expected array, got: ${JSON.stringify(data).slice(0, 200)}`);
3421
+ assert.strictEqual(deals.length, 0);
3422
+ });
3423
+ });
3424
+
3425
+ describe('18. Cleanup - Archive Deals', () => {
3426
+ it('should archive (soft delete) deals', async () => {
3427
+ for (const key of Object.keys(testData.deals)) {
3428
+ if (testData.deals[key]?.dealId) {
3429
+ const { status } = await request('DELETE', `/deals/${testData.deals[key].dealId}`);
3430
+ assert.ok([200, 404].includes(status));
3431
+ }
3432
+ }
3433
+ });
3434
+
3435
+ it('should verify archived deals have ARCHIVED status', async () => {
3436
+ if (!testData.deals.guaranteed?.dealId) {
3437
+ assert.ok(true, 'Skipping - no deal to verify');
3438
+ return;
3439
+ }
3440
+ const { status, data } = await request('GET', `/deals/${testData.deals.guaranteed.dealId}`);
3441
+ if (status === 200) {
3442
+ assert.strictEqual(data.result.status, 'ARCHIVED');
3443
+ }
3444
+ });
3445
+ });
3446
+ });