thinkwork-cli 0.13.2 → 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 (231) hide show
  1. package/README.md +3 -14
  2. package/dist/{api-client-JYZXYQJR.js → api-client-4P4JZOBX.js} +1 -1
  3. package/dist/{chunk-Z2F6RJFR.js → chunk-4OMDWYZQ.js} +4 -5
  4. package/dist/{chunk-ILZKG5WV.js → chunk-AD2NIWB5.js} +434 -69
  5. package/dist/chunk-MWQ7RO76.js +137 -0
  6. package/dist/{chunk-DVPBI5HM.js → chunk-OLUQ4U2Z.js} +26 -26
  7. package/dist/{chunk-QOERT44A.js → chunk-YQCYD6NE.js} +57 -44
  8. package/dist/cli.js +4400 -16431
  9. package/dist/commands/enterprise/templates/deploy-repo/scripts/apply-release.mjs +0 -1
  10. package/dist/commands/enterprise/templates/deploy-repo/scripts/smoke.mjs +0 -1
  11. package/dist/commands/enterprise/templates/deploy-repo/terraform/main.tf +0 -14
  12. package/dist/{dist-es-CRV46D5B.js → dist-es-2OTNTL4N.js} +4 -4
  13. package/dist/{dist-es-ENBUC3JP.js → dist-es-2XVAH4BQ.js} +2 -2
  14. package/dist/{dist-es-DCY4IC3Y.js → dist-es-3DPDUJAH.js} +2 -2
  15. package/dist/{dist-es-G7PTG75M.js → dist-es-BNZTBBVC.js} +1 -1
  16. package/dist/{dist-es-GKDBE7E6.js → dist-es-CD2RBDAC.js} +1 -1
  17. package/dist/{dist-es-R7FGL2IB.js → dist-es-I4FW57PS.js} +1 -1
  18. package/dist/{dist-es-USORBXZ5.js → dist-es-KVFWJNDY.js} +4 -4
  19. package/dist/{dist-es-HY3535OI.js → dist-es-PS73DWNL.js} +2 -2
  20. package/dist/{dist-es-LDE437AM.js → dist-es-S3NZQFGT.js} +1 -1
  21. package/dist/{dist-es-G54LBMRN.js → dist-es-SGCA4P32.js} +1 -1
  22. package/dist/{dist-es-Q57SZXZ7.js → dist-es-WBM6J4RA.js} +2 -2
  23. package/dist/{dist-es-P26FWFLT.js → dist-es-ZGVADYIF.js} +8 -8
  24. package/dist/drizzle/0227_analyst_reader_role.sql +17 -26
  25. package/dist/drizzle/0230_analyst_rls.sql +28 -45
  26. package/dist/drizzle/0261_native_auth_control_plane.sql +7 -3
  27. package/dist/drizzle/0270_tool_execution_ledger.sql +115 -0
  28. package/dist/drizzle/0271_ontology_twin_declarations.sql +47 -0
  29. package/dist/drizzle/0272_identity_graph_projection_cursor.sql +23 -0
  30. package/dist/drizzle/0273_twin_materialization_suggestions.sql +29 -0
  31. package/dist/drizzle/0274_tenant_mcp_twin_keys.sql +59 -0
  32. package/dist/drizzle/0275_identity_bulk_rebuild_fence.sql +30 -0
  33. package/dist/drizzle/0276_tool_execution_ledger_allow_cascade_delete.sql +18 -0
  34. package/dist/drizzle/0277_knowledge_base_sources.sql +107 -0
  35. package/dist/drizzle/0278_kb_page_transcription.sql +55 -0
  36. package/dist/drizzle/0279_mcp_servers_off_plugin_provenance.sql +93 -0
  37. package/dist/drizzle/0280_work_item_external_refs_drop_plane_provider.sql +46 -0
  38. package/dist/drizzle/0281_tenant_mcp_twin_keys_expiry_suffix.sql +9 -0
  39. package/dist/drizzle/0282_tenant_mcp_twin_keys_grants.sql +24 -0
  40. package/dist/drizzle/0283_agents_agentcore_runtime_dispatch.sql +13 -0
  41. package/dist/drizzle/0284_user_brain_claims.sql +69 -0
  42. package/dist/drizzle/0285_tenant_mcp_twin_keys_trusted_subsystem.sql +30 -0
  43. package/dist/drizzle/0286_brain_analytics_key.sql +26 -0
  44. package/dist/drizzle/0287_brain_subject.sql +25 -0
  45. package/dist/drizzle/0288_multi_lane_identities.sql +25 -0
  46. package/dist/{loadSso-DJVB7BXE.js → loadSso-ETCXTR4I.js} +2 -2
  47. package/dist/{loadSso-JAMNA3WY.js → loadSso-UBD7KUHE.js} +2 -2
  48. package/dist/plugins/README.md +2 -2
  49. package/dist/plugins/catalog/package.json +0 -3
  50. package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +6 -3
  51. package/dist/plugins/catalog/src/__tests__/catalog.test.ts +21 -21
  52. package/dist/plugins/catalog/src/__tests__/contracts.test.ts +102 -25
  53. package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +7 -6
  54. package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +0 -15
  55. package/dist/plugins/catalog/src/contracts.ts +15 -0
  56. package/dist/plugins/catalog/src/registry/generated-first-party.ts +0 -18
  57. package/dist/plugins/n8n/test/integrated-app-contract.test.ts +1 -1
  58. package/dist/plugins/twenty/package.json +2 -1
  59. package/dist/plugins/twenty/scripts/LAMBDA_DEPLOY.md +114 -0
  60. package/dist/plugins/twenty/scripts/build-lambda.sh +29 -0
  61. package/dist/plugins/twenty/scripts/configure-product-views.ts +195 -0
  62. package/dist/plugins/twenty/scripts/lambda-handler.ts +49 -0
  63. package/dist/plugins/twenty/scripts/lib/__tests__/load-attachments.test.ts +38 -0
  64. package/dist/plugins/twenty/scripts/lib/__tests__/load-records.test.ts +145 -2
  65. package/dist/plugins/twenty/scripts/lib/__tests__/mappers.test.ts +397 -151
  66. package/dist/plugins/twenty/scripts/lib/__tests__/members-ensure.test.ts +28 -129
  67. package/dist/plugins/twenty/scripts/lib/__tests__/provision-members-db.test.ts +10 -0
  68. package/dist/plugins/twenty/scripts/lib/__tests__/schema-ensure.test.ts +13 -8
  69. package/dist/plugins/twenty/scripts/lib/lastmile-reader.ts +222 -2
  70. package/dist/plugins/twenty/scripts/lib/load-attachments.ts +29 -0
  71. package/dist/plugins/twenty/scripts/lib/load-records.ts +160 -43
  72. package/dist/plugins/twenty/scripts/lib/mappers.ts +368 -152
  73. package/dist/plugins/twenty/scripts/lib/members-ensure.ts +43 -268
  74. package/dist/plugins/twenty/scripts/lib/provision-members-db.ts +57 -0
  75. package/dist/plugins/twenty/scripts/lib/schema-ensure.ts +136 -31
  76. package/dist/plugins/twenty/scripts/migrate-lastmile.ts +254 -152
  77. package/dist/plugins/twenty/scripts/provision-twenty-members.ts +44 -1
  78. package/dist/plugins/twenty/scripts/purge-lastmile-import.ts +178 -0
  79. package/dist/plugins/twenty/scripts/restructure-products.ts +312 -0
  80. package/dist/plugins/twenty/scripts/retag-stage-values.ts +362 -0
  81. package/dist/plugins/twenty/test/manifest.test.ts +2 -4
  82. package/dist/scripts/post-deploy.sh +79 -1
  83. package/dist/{signin-UGDWTGS4.js → signin-5QZNRSOO.js} +2 -2
  84. package/dist/{sso-oidc-Q4VXANXY.js → sso-oidc-XK673P54.js} +2 -2
  85. package/dist/{sts-CWLSE3GH.js → sts-OXI2UALF.js} +6 -134
  86. package/dist/{sts-DNZLNVOV.js → sts-SQW4NBK3.js} +6 -134
  87. package/dist/terraform/examples/greenfield/main.tf +233 -175
  88. package/dist/terraform/examples/greenfield/terraform.tfvars.example +12 -8
  89. package/dist/terraform/modules/app/agentcore-admin/main.tf +47 -0
  90. package/dist/terraform/modules/app/agentcore-code-interpreter/README.md +39 -47
  91. package/dist/terraform/modules/app/agentcore-code-interpreter/main.tf +13 -66
  92. package/dist/terraform/modules/app/agentcore-identity/main.tf +106 -26
  93. package/dist/terraform/modules/app/agentcore-identity/outputs.tf +6 -6
  94. package/dist/terraform/modules/app/agentcore-identity/scripts/bootstrap_twenty_oauth_client.sh +216 -99
  95. package/dist/terraform/modules/app/agentcore-identity/scripts/delete_identity.sh +6 -9
  96. package/dist/terraform/modules/app/agentcore-identity/scripts/delete_workload_identity.sh +19 -0
  97. package/dist/terraform/modules/app/agentcore-identity/scripts/ensure_workload_identity.sh +29 -0
  98. package/dist/terraform/modules/app/agentcore-identity/scripts/read_identity.sh +22 -7
  99. package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_twenty_identity.sh +17 -46
  100. package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_twenty_provider.mjs +65 -3
  101. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/after.tf +15 -0
  102. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/before.tf +7 -0
  103. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/identity-after.tf +8 -0
  104. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/identity-before.tf +9 -0
  105. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/thinkwork-after.tf +3 -0
  106. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/thinkwork-before.tf +3 -0
  107. package/dist/terraform/modules/app/agentcore-identity/variables.tf +6 -0
  108. package/dist/terraform/modules/app/agentcore-memory/README.md +46 -9
  109. package/dist/terraform/modules/app/agentcore-memory/main.tf +27 -10
  110. package/dist/terraform/modules/app/agentcore-memory/scripts/create_or_find_memory.sh +156 -24
  111. package/dist/terraform/modules/app/agentcore-pi/README.md +1 -3
  112. package/dist/terraform/modules/app/agentcore-pi/main.tf +42 -52
  113. package/dist/terraform/modules/app/agentcore-pi/outputs.tf +3 -3
  114. package/dist/terraform/modules/app/agentcore-pi/scripts/reconcile_pi_runtime.mjs +175 -0
  115. package/dist/terraform/modules/app/agentcore-pi/variables.tf +35 -28
  116. package/dist/terraform/modules/app/agentcore-platform/main.tf +68 -0
  117. package/dist/terraform/modules/app/agentcore-runtime/main.tf +4 -20
  118. package/dist/terraform/modules/app/capability-broker/main.tf +2 -2
  119. package/dist/terraform/modules/app/capability-broker/variables.tf +11 -0
  120. package/dist/terraform/modules/app/deployment-control-plane/README.md +264 -0
  121. package/dist/terraform/modules/app/deployment-control-plane/buildspec.yml +4 -1
  122. package/dist/terraform/modules/app/deployment-control-plane/main.tf +94 -0
  123. package/dist/terraform/modules/app/deployment-control-plane/outputs.tf +5 -0
  124. package/dist/terraform/modules/app/deployment-control-plane/runner.py +2641 -124
  125. package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +2034 -154
  126. package/dist/terraform/modules/app/deployment-control-plane/variables.tf +33 -0
  127. package/dist/terraform/modules/app/lambda-api/auth-state.tf +72 -0
  128. package/dist/terraform/modules/app/lambda-api/chat-latency-observability.tf +466 -0
  129. package/dist/terraform/modules/app/lambda-api/dispatch-dlq.tf +55 -0
  130. package/dist/terraform/modules/app/lambda-api/handlers.tf +411 -723
  131. package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +262 -198
  132. package/dist/terraform/modules/app/lambda-api/main.tf +51 -13
  133. package/dist/terraform/modules/app/lambda-api/mcp-oauth.tf +4 -181
  134. package/dist/terraform/modules/app/lambda-api/memory-alarms.tf +3 -28
  135. package/dist/terraform/modules/app/lambda-api/oauth-secrets.tf +1 -2
  136. package/dist/terraform/modules/app/lambda-api/outputs.tf +17 -45
  137. package/dist/terraform/modules/app/lambda-api/turn-assertion.tf +45 -0
  138. package/dist/terraform/modules/app/lambda-api/variables.tf +219 -126
  139. package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/README.md +1 -1
  140. package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/main.tf +18 -18
  141. package/dist/terraform/modules/app/sandbox-log-scrubber/README.md +11 -10
  142. package/dist/terraform/modules/app/sandbox-log-scrubber/main.tf +5 -5
  143. package/dist/terraform/modules/app/static-site/main.tf +1 -1
  144. package/dist/terraform/modules/app/www-dns/main.tf +9 -27
  145. package/dist/terraform/modules/app/www-dns/variables.tf +4 -10
  146. package/dist/terraform/modules/data/aurora-postgres/README.md +34 -0
  147. package/dist/terraform/modules/data/aurora-postgres/alarms.tf +82 -0
  148. package/dist/terraform/modules/data/aurora-postgres/main.tf +27 -57
  149. package/dist/terraform/modules/data/aurora-postgres/outputs.tf +0 -9
  150. package/dist/terraform/modules/data/aurora-postgres/variables.tf +41 -0
  151. package/dist/terraform/modules/data/s3-buckets/main.tf +5 -6
  152. package/dist/terraform/modules/foundation/cognito/main.tf +81 -8
  153. package/dist/terraform/modules/foundation/cognito/outputs.tf +5 -0
  154. package/dist/terraform/modules/foundation/cognito/variables.tf +24 -12
  155. package/dist/terraform/modules/thinkwork/README.md +5 -1
  156. package/dist/terraform/modules/thinkwork/main.tf +376 -374
  157. package/dist/terraform/modules/thinkwork/outputs.tf +12 -158
  158. package/dist/terraform/modules/thinkwork/variables.tf +246 -89
  159. package/dist/workspace-defaults/files/INSTRUCTIONS.md +9 -11
  160. package/dist/workspace-defaults/files/MEMORY_GUIDE.md +38 -35
  161. package/dist/workspace-defaults/files/ROUTER.md +1 -1
  162. package/dist/workspace-defaults/files/TOOLS.md +9 -39
  163. package/dist/workspace-defaults/files/agents/analyst/INSTRUCTIONS.md +6 -3
  164. package/dist/workspace-defaults/files/catalog-skills/brain-brief-builder/SKILL.md +165 -0
  165. package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/SKILL.md +75 -0
  166. package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/references/mcp-tooling.md +40 -0
  167. package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/references/validation-and-handoff.md +40 -0
  168. package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/references/workflow-authoring.md +46 -0
  169. package/dist/workspace-defaults/files/skills/artifact-builder/references/crm-dashboard.md +1 -1
  170. package/dist/workspace-defaults/files/skills/document-composer/SKILL.md +22 -3
  171. package/package.json +1 -1
  172. package/dist/drizzle/0261_managed_harness_multiplayer.sql +0 -313
  173. package/dist/drizzle/0262_parallel_harness_threads.sql +0 -9
  174. package/dist/drizzle/0264_harness_tool_execution_ledger.sql +0 -113
  175. package/dist/plugins/company-data/README.md +0 -49
  176. package/dist/plugins/company-data/package.json +0 -21
  177. package/dist/plugins/company-data/src/index.ts +0 -30
  178. package/dist/plugins/company-data/src/manifest.ts +0 -26
  179. package/dist/plugins/company-data/test/manifest.test.ts +0 -116
  180. package/dist/plugins/company-data/tsconfig.json +0 -8
  181. package/dist/plugins/company-etl/README.md +0 -46
  182. package/dist/plugins/company-etl/package.json +0 -21
  183. package/dist/plugins/company-etl/src/index.ts +0 -29
  184. package/dist/plugins/company-etl/src/manifest.ts +0 -26
  185. package/dist/plugins/company-etl/test/manifest.test.ts +0 -108
  186. package/dist/plugins/company-etl/tsconfig.json +0 -8
  187. package/dist/plugins/lastmile/README.md +0 -50
  188. package/dist/plugins/lastmile/package.json +0 -23
  189. package/dist/plugins/lastmile/smoke/lastmile-plugin-smoke.mjs +0 -661
  190. package/dist/plugins/lastmile/src/api/tasks-adapter.ts +0 -572
  191. package/dist/plugins/lastmile/src/discovery.fixture.ts +0 -64
  192. package/dist/plugins/lastmile/src/index.ts +0 -46
  193. package/dist/plugins/lastmile/src/manifest.ts +0 -139
  194. package/dist/plugins/lastmile/test/api/tasks-adapter.test.ts +0 -266
  195. package/dist/plugins/lastmile/test/discovery.test.ts +0 -88
  196. package/dist/plugins/lastmile/tsconfig.json +0 -8
  197. package/dist/plugins/n8n/smoke/n8n-managed-app-smoke.mjs +0 -409
  198. package/dist/plugins/twenty/smoke/twenty-managed-app-smoke.mjs +0 -359
  199. package/dist/terraform/modules/app/agentcore-code-interpreter/Dockerfile.sandbox-base +0 -61
  200. package/dist/terraform/modules/app/agentcore-code-interpreter/sandbox/sitecustomize.py +0 -227
  201. package/dist/terraform/modules/app/agentcore-code-interpreter/sandbox/test_sitecustomize.py +0 -277
  202. package/dist/terraform/modules/app/agentcore-code-interpreter/scripts/build_and_push_sandbox_base.sh +0 -70
  203. package/dist/terraform/modules/app/agentcore-gateway/main.tf +0 -305
  204. package/dist/terraform/modules/app/agentcore-gateway/outputs.tf +0 -44
  205. package/dist/terraform/modules/app/agentcore-gateway/scripts/delete_gateway.sh +0 -93
  206. package/dist/terraform/modules/app/agentcore-gateway/scripts/read_gateway.sh +0 -33
  207. package/dist/terraform/modules/app/agentcore-gateway/scripts/reconcile_gateway.sh +0 -543
  208. package/dist/terraform/modules/app/agentcore-gateway/variables.tf +0 -70
  209. package/dist/terraform/modules/app/agentcore-harness/main.tf +0 -330
  210. package/dist/terraform/modules/app/agentcore-harness/outputs.tf +0 -72
  211. package/dist/terraform/modules/app/agentcore-harness/scripts/delete_harness.sh +0 -5
  212. package/dist/terraform/modules/app/agentcore-harness/scripts/harness-lifecycle.mjs +0 -430
  213. package/dist/terraform/modules/app/agentcore-harness/scripts/harness-readback.mjs +0 -180
  214. package/dist/terraform/modules/app/agentcore-harness/scripts/harness-tool-contract.mjs +0 -193
  215. package/dist/terraform/modules/app/agentcore-harness/scripts/prune_harness.sh +0 -5
  216. package/dist/terraform/modules/app/agentcore-harness/scripts/read_harness.sh +0 -5
  217. package/dist/terraform/modules/app/agentcore-harness/scripts/reconcile_harness.sh +0 -5
  218. package/dist/terraform/modules/app/agentcore-harness/variables.tf +0 -96
  219. package/dist/terraform/modules/app/hindsight-memory/README.md +0 -89
  220. package/dist/terraform/modules/app/hindsight-memory/main.tf +0 -411
  221. package/dist/terraform/modules/app/lambda-api/agentcore-harness-alarms.tf +0 -88
  222. package/dist/terraform/modules/app/lambda-api/agentcore-identity-alarms.tf +0 -91
  223. package/dist/terraform/modules/data/bedrock-knowledge-base/main.tf +0 -102
  224. /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/outputs.tf +0 -0
  225. /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/scripts/sync-database.py +0 -0
  226. /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/scripts/test_sync_database.py +0 -0
  227. /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/variables.tf +0 -0
  228. /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/README.md +0 -0
  229. /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/main.tf +0 -0
  230. /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/outputs.tf +0 -0
  231. /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/variables.tf +0 -0
@@ -2,75 +2,43 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import type {
4
4
  LastmileContact,
5
+ LastmileCrmTask,
5
6
  LastmileCustomerNote,
6
- LastmileLead,
7
- LastmileOpportunity,
8
7
  } from "../lastmile-reader";
9
8
  import {
10
9
  buildOwnerIndex,
11
10
  contentHash,
11
+ dedupeContactEmails,
12
12
  deriveRepEmail,
13
13
  isMobilBrand,
14
14
  mapAccount,
15
15
  mapContact,
16
+ mapDispatchCustomer,
16
17
  mapCrmComment,
18
+ mapCrmTask,
19
+ mapOrganization,
20
+ mapProduct,
21
+ normalizeProductName,
22
+ PRODUCT_CATALOG,
23
+ productSourceId,
24
+ mapTaskProducts,
25
+ mapTaskStatusActivity,
26
+ mapTaskStatusToStage,
17
27
  mapCustomerNote,
18
- mapLead,
19
- mapLeadStatusToStage,
20
- mapOpportunity,
21
28
  mapOpportunityProduct,
22
- mapOpportunityStage,
23
29
  normalizeEmail,
24
30
  normalizePhone,
25
31
  resolveOwner,
26
32
  sourceId,
27
33
  stableStringify,
28
34
  toAmountMicros,
35
+ toIsoTimestamp,
29
36
  toQuantity,
30
37
  } from "../mappers";
31
38
 
32
39
  const ownerMap = new Map([["rep_jane", "member-jane-uuid"]]);
33
40
  const companyMap = new Map([[sourceId("account", "acct_1"), "company-uuid-1"]]);
34
41
 
35
- function lead(overrides: Partial<LastmileLead> = {}): LastmileLead {
36
- return {
37
- id: "lead_1",
38
- status: "00-New",
39
- companyName: "Acme Fuel",
40
- firstName: "Ann",
41
- lastName: "Lee",
42
- email: null,
43
- phone: null,
44
- source: null,
45
- description: null,
46
- ownerRepId: null,
47
- dateCreated: null,
48
- ...overrides,
49
- };
50
- }
51
-
52
- function opportunity(
53
- overrides: Partial<LastmileOpportunity> = {},
54
- ): LastmileOpportunity {
55
- return {
56
- id: "opp_1",
57
- name: "Bulk diesel contract",
58
- stage: "10-Prospect",
59
- amount: "1234.56",
60
- quantity: "100",
61
- productType: "Diesel",
62
- brand: "Mobil",
63
- closed: null,
64
- won: null,
65
- accountId: "acct_1",
66
- ownerRepId: "rep_jane",
67
- expectedCloseDate: "2026-08-01",
68
- description: null,
69
- dateCreated: null,
70
- ...overrides,
71
- };
72
- }
73
-
74
42
  describe("toAmountMicros", () => {
75
43
  it("converts $1,234.56 to 1234560000 micros", () => {
76
44
  expect(toAmountMicros("$1,234.56")).toBe(1_234_560_000);
@@ -122,60 +90,27 @@ describe("normalizeEmail", () => {
122
90
  });
123
91
  });
124
92
 
125
- describe("stage mapping", () => {
126
- it("maps messy lead statuses into the lead band", () => {
127
- expect(mapLeadStatusToStage("00-New").stage).toBe("LEAD");
128
- expect(mapLeadStatusToStage("new").stage).toBe("LEAD");
129
- expect(mapLeadStatusToStage(null).stage).toBe("LEAD");
130
- expect(mapLeadStatusToStage("20-Working").stage).toBe("LEAD_WORKING");
131
- expect(mapLeadStatusToStage("30-Nurturing").stage).toBe("LEAD_WORKING");
132
- expect(mapLeadStatusToStage("50-Qualified").stage).toBe("LEAD_QUALIFIED");
133
- expect(mapLeadStatusToStage("90-Unqualified").stage).toBe(
134
- "LEAD_UNQUALIFIED",
93
+ describe("mapDispatchCustomer", () => {
94
+ it("maps a cohort customer to a company keyed customer:<id>", () => {
95
+ const mapped = mapDispatchCustomer(
96
+ { id: "cust_1", name: "Thunder Well Service", ownerRepId: "rep_jane" },
97
+ ownerMap,
135
98
  );
136
- expect(mapLeadStatusToStage("Converted").stage).toBe("LEAD_QUALIFIED");
137
- });
138
-
139
- it("flags unknown lead statuses", () => {
140
- expect(mapLeadStatusToStage("13-Mystery")).toEqual({
141
- stage: "LEAD",
142
- unknown: true,
99
+ expect(mapped.input).toMatchObject({
100
+ name: "Thunder Well Service",
101
+ accountOwnerId: "member-jane-uuid",
102
+ sourceId: "customer:cust_1",
143
103
  });
104
+ expect(mapped.warnings).toEqual([]);
144
105
  });
145
106
 
146
- it("maps opportunity stages including both Negotiate spellings", () => {
147
- expect(
148
- mapOpportunityStage({ stage: "60-Won", closed: null, won: null }).stage,
149
- ).toBe("WON");
150
- expect(
151
- mapOpportunityStage({ stage: "90-Lost", closed: null, won: null }).stage,
152
- ).toBe("LOST");
153
- expect(
154
- mapOpportunityStage({
155
- stage: "40-Negotiate to Close",
156
- closed: null,
157
- won: null,
158
- }).stage,
159
- ).toBe("NEGOTIATE");
160
- expect(
161
- mapOpportunityStage({
162
- stage: "40-Negotiation to Close",
163
- closed: null,
164
- won: null,
165
- }).stage,
166
- ).toBe("NEGOTIATE");
167
- });
168
-
169
- it("falls back to closed/won flags for blank stages", () => {
170
- expect(
171
- mapOpportunityStage({ stage: null, closed: "true", won: "true" }).stage,
172
- ).toBe("WON");
173
- expect(
174
- mapOpportunityStage({ stage: null, closed: "true", won: "false" }).stage,
175
- ).toBe("LOST");
176
- expect(
177
- mapOpportunityStage({ stage: null, closed: null, won: null }).stage,
178
- ).toBe("NEW");
107
+ it("warns on an unprovisioned owner and keeps the company", () => {
108
+ const mapped = mapDispatchCustomer(
109
+ { id: "cust_2", name: "Beta Fuels", ownerRepId: "rep_gone" },
110
+ ownerMap,
111
+ );
112
+ expect(mapped.input).not.toHaveProperty("accountOwnerId");
113
+ expect(mapped.warnings[0]).toMatch(/rep_gone/);
179
114
  });
180
115
  });
181
116
 
@@ -247,59 +182,6 @@ describe("mapContact", () => {
247
182
  });
248
183
  });
249
184
 
250
- describe("mapOpportunity", () => {
251
- it("maps stage, micros, custom fields, links, and owner (AE1 shape)", () => {
252
- const mapped = mapOpportunity(opportunity(), ownerMap, companyMap);
253
- expect(mapped.input).toMatchObject({
254
- name: "Bulk diesel contract",
255
- stage: "PROSPECT",
256
- amount: { amountMicros: 1_234_560_000, currencyCode: "USD" },
257
- ownerId: "member-jane-uuid",
258
- companyId: "company-uuid-1",
259
- product: "Diesel",
260
- quantity: 100,
261
- isMobil: true,
262
- sourceId: "opportunity:opp_1",
263
- });
264
- });
265
-
266
- it("is not Mobil when neither product nor brand mentions Mobil", () => {
267
- const mapped = mapOpportunity(
268
- opportunity({ brand: "Shell", productType: "Unleaded" }),
269
- ownerMap,
270
- companyMap,
271
- );
272
- expect(mapped.input.isMobil).toBe(false);
273
- });
274
-
275
- it("flags an unresolvable company and unprovisioned owner", () => {
276
- const mapped = mapOpportunity(
277
- opportunity({ accountId: "acct_missing", ownerRepId: "rep_gone" }),
278
- ownerMap,
279
- companyMap,
280
- );
281
- expect(mapped.input).not.toHaveProperty("companyId");
282
- expect(mapped.input).not.toHaveProperty("ownerId");
283
- expect(mapped.warnings).toHaveLength(2);
284
- });
285
- });
286
-
287
- describe("mapLead", () => {
288
- it("maps a lead row to the early Lead stage in the opportunity pipeline (AE3)", () => {
289
- const mapped = mapLead(lead(), ownerMap);
290
- expect(mapped.input).toMatchObject({
291
- name: "Acme Fuel",
292
- stage: "LEAD",
293
- sourceId: "lead:lead_1",
294
- });
295
- });
296
-
297
- it("names person-only leads by their person name", () => {
298
- const mapped = mapLead(lead({ companyName: null }), ownerMap);
299
- expect(mapped.input.name).toBe("Ann Lee");
300
- });
301
- });
302
-
303
185
  describe("notes mapping", () => {
304
186
  it("maps a CRM comment onto its lead/opportunity target", () => {
305
187
  const mapped = mapCrmComment({
@@ -309,6 +191,7 @@ describe("notes mapping", () => {
309
191
  content: "Called the buyer.\nFollow up Friday.",
310
192
  isDeleted: false,
311
193
  createdAt: null,
194
+ authorName: null,
312
195
  });
313
196
  expect(mapped).toMatchObject({
314
197
  sourceId: "task_comment:tc_1",
@@ -318,6 +201,22 @@ describe("notes mapping", () => {
318
201
  });
319
202
  });
320
203
 
204
+ it("carries the LastMile authored-at time and author onto the note", () => {
205
+ const mapped = mapCrmComment({
206
+ id: "tc_2",
207
+ entityType: "opportunity",
208
+ entityId: "opp_1",
209
+ content: "Sold them some products on cash account.",
210
+ isDeleted: false,
211
+ createdAt: new Date("2026-07-10T02:27:10.507Z"),
212
+ authorName: "Reyes Valdez",
213
+ });
214
+ // Twenty's activity feed sorts on createdAt; without this the whole history
215
+ // collapses onto the day the import ran.
216
+ expect(mapped.createdAt).toBe("2026-07-10T02:27:10.507Z");
217
+ expect(mapped.authorName).toBe("Reyes Valdez");
218
+ });
219
+
321
220
  it("maps a customer note only when the customer uniquely matches an account", () => {
322
221
  const base: LastmileCustomerNote = {
323
222
  id: "n_1",
@@ -330,6 +229,72 @@ describe("notes mapping", () => {
330
229
  expect(mapCustomerNote(base)?.targetSourceId).toBe("account:acct_1");
331
230
  expect(mapCustomerNote({ ...base, matchedAccountId: null })).toBeNull();
332
231
  });
232
+
233
+ it("reconstructs a status change as a dated note on its opportunity", () => {
234
+ const mapped = mapTaskStatusActivity({
235
+ id: "act_1",
236
+ entityType: "opportunity",
237
+ entityId: "opp_1",
238
+ oldStatusName: "00-New",
239
+ newStatusName: "10-Prospect",
240
+ createdAt: new Date("2026-05-14T20:19:03.740Z"),
241
+ authorName: "Dean Kittel",
242
+ });
243
+ expect(mapped).toMatchObject({
244
+ sourceId: "task_activity:act_1",
245
+ title: "Stage → 10-Prospect",
246
+ bodyMarkdown: "00-New → 10-Prospect",
247
+ targetSourceId: "opportunity:opp_1",
248
+ targetKind: "opportunity",
249
+ // True transition time, so the feed sorts correctly against the comments.
250
+ createdAt: "2026-05-14T20:19:03.740Z",
251
+ authorName: "Dean Kittel",
252
+ });
253
+ });
254
+
255
+ it("labels a status change with no prior stage as a plain set", () => {
256
+ const mapped = mapTaskStatusActivity({
257
+ id: "act_2",
258
+ entityType: "lead",
259
+ entityId: "lead_1",
260
+ oldStatusName: null,
261
+ newStatusName: "60-Won",
262
+ createdAt: null,
263
+ authorName: null,
264
+ });
265
+ expect(mapped?.bodyMarkdown).toBe("Set to 60-Won");
266
+ expect(mapped?.targetSourceId).toBe("lead:lead_1");
267
+ });
268
+
269
+ it("drops a status event that names no destination stage", () => {
270
+ expect(
271
+ mapTaskStatusActivity({
272
+ id: "act_3",
273
+ entityType: "opportunity",
274
+ entityId: "opp_1",
275
+ oldStatusName: "10-Prospect",
276
+ newStatusName: null,
277
+ createdAt: null,
278
+ authorName: null,
279
+ }),
280
+ ).toBeNull();
281
+ });
282
+ });
283
+
284
+ describe("toIsoTimestamp", () => {
285
+ it("converts a Date to ISO-8601 and passes through parseable strings", () => {
286
+ expect(toIsoTimestamp(new Date("2025-07-21T12:00:00Z"))).toBe(
287
+ "2025-07-21T12:00:00.000Z",
288
+ );
289
+ expect(toIsoTimestamp("2026-07-10T02:27:10.507Z")).toBe(
290
+ "2026-07-10T02:27:10.507Z",
291
+ );
292
+ });
293
+
294
+ it("returns null for missing or unparseable values", () => {
295
+ expect(toIsoTimestamp(null)).toBeNull();
296
+ expect(toIsoTimestamp("not a date")).toBeNull();
297
+ });
333
298
  });
334
299
 
335
300
  describe("contentHash", () => {
@@ -443,13 +408,13 @@ describe("mapOpportunityProduct (multiple products per opportunity)", () => {
443
408
  expect(mapped.sourceId).toBe("opportunity_item:opp_1#0");
444
409
  expect(mapped.opportunitySourceId).toBe("opportunity:opp_1");
445
410
  expect(mapped.input).toMatchObject({
446
- name: "MOBIL",
447
- product: "MOBIL",
411
+ name: "Mobil",
448
412
  quantity: 6500,
449
413
  amount: { amountMicros: 50_000_000_000, currencyCode: "USD" },
450
414
  isMobil: true,
451
415
  lineNumber: 1,
452
416
  });
417
+ expect(mapped.productSourceId).toBe("product:mobil");
453
418
  });
454
419
 
455
420
  it("gives each line on one opportunity a distinct, stable sourceId", () => {
@@ -465,6 +430,11 @@ describe("mapOpportunityProduct (multiple products per opportunity)", () => {
465
430
  ]);
466
431
  expect(new Set(lines.map((l) => l.opportunitySourceId)).size).toBe(1);
467
432
  expect(lines.map((l) => l.input.isMobil)).toEqual([false, false, true]);
433
+ expect(lines.map((l) => l.productSourceId)).toEqual([
434
+ "product:def",
435
+ "product:golden_west",
436
+ "product:mobil",
437
+ ]);
468
438
  expect(lines.map((l) => l.input.lineNumber)).toEqual([1, 2, 3]);
469
439
  });
470
440
 
@@ -473,7 +443,7 @@ describe("mapOpportunityProduct (multiple products per opportunity)", () => {
473
443
  item({ brand: null, quantity: "n/a", amount: "" }),
474
444
  );
475
445
  expect(mapped.input.name).toBe("Line 1");
476
- expect(mapped.input).not.toHaveProperty("product");
446
+ expect(mapped.productSourceId).toBeNull();
477
447
  expect(mapped.input).not.toHaveProperty("quantity");
478
448
  expect(mapped.input).not.toHaveProperty("amount");
479
449
  expect(mapped.input.isMobil).toBe(false);
@@ -487,3 +457,279 @@ describe("mapOpportunityProduct (multiple products per opportunity)", () => {
487
457
  expect(isMobilBrand(null)).toBe(false);
488
458
  });
489
459
  });
460
+
461
+ describe("mapTaskStatusToStage", () => {
462
+ it("maps LastMile status names verbatim to pipeline stages", () => {
463
+ expect(mapTaskStatusToStage("10-Prospect").stage).toBe("PROSPECT");
464
+ expect(mapTaskStatusToStage("60-Won").stage).toBe("WON");
465
+ expect(mapTaskStatusToStage("20-Account Needs").stage).toBe(
466
+ "ACCOUNT_NEEDS",
467
+ );
468
+ expect(mapTaskStatusToStage("Converted").stage).toBe("CONVERTED");
469
+ expect(mapTaskStatusToStage("90-Unqualified").stage).toBe("UNQUALIFIED");
470
+ });
471
+
472
+ it("flags an unmapped status instead of silently bucketing it", () => {
473
+ expect(mapTaskStatusToStage("Zz-Unknown")).toEqual({
474
+ stage: "NEW",
475
+ unknown: true,
476
+ });
477
+ expect(mapTaskStatusToStage(null).unknown).toBe(true);
478
+ });
479
+ });
480
+
481
+ describe("mapCrmTask (task table is the CRM authority)", () => {
482
+ const ownerIndex = new Map([["rep_chad", "member-chad"]]);
483
+ const companyMap = new Map([["account:acct_reign", "company-reign"]]);
484
+ const orgMap = new Map([["organization:org_gwo", "org-gwo-300"]]);
485
+
486
+ function task(overrides: Partial<LastmileCrmTask> = {}): LastmileCrmTask {
487
+ return {
488
+ taskId: "task_xdh6577weuhsc2ttlct1acyl",
489
+ entityType: "opportunity",
490
+ entityId: "opp_xdh6577weuhsc2ttlct1acyl",
491
+ title: "Reign Rentals GW Lubes",
492
+ description: "start up oilfield rental company with brand new equipment",
493
+ accountId: "acct_reign",
494
+ leadCompanyName: null,
495
+ statusName: "10-Prospect",
496
+ organizationId: "org_gwo",
497
+ assigneeRepId: "rep_chad",
498
+ dueDate: new Date("2026-07-23T00:00:00Z"),
499
+ createdAt: null,
500
+ items: [{ brand: "Golden West", amount: 12000, quantity: 40 }],
501
+ ...overrides,
502
+ };
503
+ }
504
+
505
+ it("reproduces the reference opportunity exactly", () => {
506
+ const mapped = mapCrmTask(task(), ownerIndex, companyMap, orgMap);
507
+ expect(mapped.sourceId).toBe("opportunity:opp_xdh6577weuhsc2ttlct1acyl");
508
+ expect(mapped.input).toMatchObject({
509
+ name: "Reign Rentals GW Lubes",
510
+ stage: "PROSPECT",
511
+ amount: { amountMicros: 12_000_000_000, currencyCode: "USD" },
512
+ ownerId: "member-chad",
513
+ companyId: "company-reign",
514
+ organizationId: "org-gwo-300",
515
+ isMobil: false,
516
+ });
517
+ expect(mapped.warnings).toEqual([]);
518
+ });
519
+
520
+ it("replays the LastMile creation time onto Twenty's createdAt", () => {
521
+ const mapped = mapCrmTask(
522
+ task({ createdAt: new Date("2026-05-13T15:37:15.268Z") }),
523
+ ownerIndex,
524
+ companyMap,
525
+ orgMap,
526
+ );
527
+ expect(mapped.input.createdAt).toBe("2026-05-13T15:37:15.268Z");
528
+ });
529
+
530
+ it("omits createdAt when the task has no creation time", () => {
531
+ const mapped = mapCrmTask(task(), ownerIndex, companyMap, orgMap);
532
+ expect(mapped.input).not.toHaveProperty("createdAt");
533
+ });
534
+
535
+ it("takes status from the task, not the stale opportunity.stage column", () => {
536
+ // The opportunity row for this task says "30-Formulate Offer"; the task
537
+ // says 10-Prospect, and the task wins.
538
+ expect(mapCrmTask(task(), ownerIndex, companyMap, orgMap).input.stage).toBe(
539
+ "PROSPECT",
540
+ );
541
+ });
542
+
543
+ it("sums product lines into the deal amount and rolls up isMobil", () => {
544
+ const mapped = mapCrmTask(
545
+ task({
546
+ items: [
547
+ { brand: "DEF", amount: 1000, quantity: 1000 },
548
+ { brand: "GOLDEN WEST", amount: 1000, quantity: 1000 },
549
+ { brand: "MOBIL", amount: 1000, quantity: 500 },
550
+ ],
551
+ }),
552
+ ownerIndex,
553
+ companyMap,
554
+ orgMap,
555
+ );
556
+ expect(mapped.input.amount).toEqual({
557
+ amountMicros: 3_000_000_000,
558
+ currencyCode: "USD",
559
+ });
560
+ expect(mapped.input.isMobil).toBe(true);
561
+ });
562
+
563
+ it("maps a lead with no account to a named opportunity with no company", () => {
564
+ const mapped = mapCrmTask(
565
+ task({
566
+ entityType: "lead",
567
+ entityId: "lead_1",
568
+ title: null,
569
+ accountId: null,
570
+ leadCompanyName: "Alpine Silica",
571
+ statusName: "50-Qualified",
572
+ items: null,
573
+ }),
574
+ ownerIndex,
575
+ companyMap,
576
+ orgMap,
577
+ );
578
+ expect(mapped.sourceId).toBe("lead:lead_1");
579
+ expect(mapped.input).toMatchObject({
580
+ name: "Alpine Silica",
581
+ stage: "QUALIFIED",
582
+ });
583
+ expect(mapped.input).not.toHaveProperty("companyId");
584
+ expect(mapped.input).not.toHaveProperty("amount");
585
+ });
586
+
587
+ it("flags an unassigned task and an unmigrated organization", () => {
588
+ const mapped = mapCrmTask(
589
+ task({ assigneeRepId: null, organizationId: "org_missing" }),
590
+ ownerIndex,
591
+ companyMap,
592
+ orgMap,
593
+ );
594
+ expect(mapped.input).not.toHaveProperty("ownerId");
595
+ expect(mapped.input).not.toHaveProperty("organizationId");
596
+ expect(mapped.warnings).toEqual([
597
+ "task has no assignee; no owner",
598
+ "organization org_missing not migrated",
599
+ ]);
600
+ });
601
+
602
+ it("derives product lines from the task's items array", () => {
603
+ const lines = mapTaskProducts(task());
604
+ expect(lines).toHaveLength(1);
605
+ expect(lines[0]).toMatchObject({
606
+ sourceId: "opportunity_item:opp_xdh6577weuhsc2ttlct1acyl#0",
607
+ opportunitySourceId: "opportunity:opp_xdh6577weuhsc2ttlct1acyl",
608
+ });
609
+ expect(lines[0].input).toMatchObject({
610
+ name: "Golden West",
611
+ quantity: 40,
612
+ amount: { amountMicros: 12_000_000_000, currencyCode: "USD" },
613
+ });
614
+ expect(lines[0].productSourceId).toBe("product:golden_west");
615
+ });
616
+ });
617
+
618
+ describe("mapOrganization", () => {
619
+ it("names the record by its abbreviation and keeps the full name", () => {
620
+ const mapped = mapOrganization({
621
+ id: "org_x3wdgjtw4x4jqx2a937amutm",
622
+ name: "Golden West Oil Co..San Antonio (300)",
623
+ abbv: "GWO 300",
624
+ archived: false,
625
+ });
626
+ expect(mapped.sourceId).toBe("organization:org_x3wdgjtw4x4jqx2a937amutm");
627
+ expect(mapped.input).toMatchObject({
628
+ name: "GWO 300",
629
+ fullName: "Golden West Oil Co..San Antonio (300)",
630
+ });
631
+ });
632
+
633
+ it("falls back to the full name when there is no abbreviation", () => {
634
+ const mapped = mapOrganization({
635
+ id: "org_1",
636
+ name: "UNKNOWN",
637
+ abbv: null,
638
+ archived: false,
639
+ });
640
+ expect(mapped.input.name).toBe("UNKNOWN");
641
+ });
642
+ });
643
+
644
+ describe("dedupeContactEmails", () => {
645
+ it("gives a shared address to the first contact by id and clears the rest", () => {
646
+ const result = dedupeContactEmails([
647
+ { id: "cont_b", email: "test@test.com" },
648
+ { id: "cont_a", email: "Test@Test.com" },
649
+ { id: "cont_c", email: "unique@tei.com" },
650
+ ]);
651
+ const byId = new Map(result.map((c) => [c.id, c.email]));
652
+ expect(byId.get("cont_a")).toBe("Test@Test.com"); // first by id keeps it
653
+ expect(byId.get("cont_b")).toBeNull();
654
+ expect(byId.get("cont_c")).toBe("unique@tei.com");
655
+ });
656
+
657
+ it("is deterministic regardless of input order", () => {
658
+ const rows = [
659
+ { id: "cont_a", email: "x@y.com" },
660
+ { id: "cont_b", email: "x@y.com" },
661
+ ];
662
+ const forward = dedupeContactEmails(rows);
663
+ const reversed = dedupeContactEmails([...rows].reverse());
664
+ expect(forward.map((c) => [c.id, c.email])).toEqual(
665
+ reversed.map((c) => [c.id, c.email]),
666
+ );
667
+ });
668
+
669
+ it("leaves invalid and missing emails alone", () => {
670
+ const result = dedupeContactEmails([
671
+ { id: "a", email: null },
672
+ { id: "b", email: "garbage" },
673
+ { id: "c", email: "garbage" },
674
+ ]);
675
+ expect(result.map((c) => c.email)).toEqual([null, "garbage", "garbage"]);
676
+ });
677
+ });
678
+
679
+ describe("normalizeProductName (19 LastMile spellings -> 7 catalog products)", () => {
680
+ it("folds channel suffixes into the parent product", () => {
681
+ // CVL / PVL / INDUSTRIAL denote the sales channel, not a different product.
682
+ expect(normalizeProductName("MOBIL - CVL")).toBe("Mobil");
683
+ expect(normalizeProductName("MOBIL - PVL")).toBe("Mobil");
684
+ expect(normalizeProductName("MOBIL - INDUSTRIAL")).toBe("Mobil");
685
+ expect(normalizeProductName("GWO - CVL")).toBe("Golden West");
686
+ expect(normalizeProductName("GWO - PVL")).toBe("Golden West");
687
+ expect(normalizeProductName("GWO - INDUSTRIAL")).toBe("Golden West");
688
+ });
689
+
690
+ it("is case-insensitive across every observed spelling", () => {
691
+ expect(normalizeProductName("MOBIL")).toBe("Mobil");
692
+ expect(normalizeProductName("Mobil")).toBe("Mobil");
693
+ expect(normalizeProductName("GOLDEN WEST")).toBe("Golden West");
694
+ expect(normalizeProductName("Golden West")).toBe("Golden West");
695
+ expect(normalizeProductName("FUEL")).toBe("Fuel");
696
+ expect(normalizeProductName("DEF")).toBe("DEF");
697
+ expect(normalizeProductName("MIGHTY")).toBe("Mighty");
698
+ expect(normalizeProductName("Mighty")).toBe("Mighty");
699
+ expect(normalizeProductName("ANCILLARY")).toBe("Ancillary");
700
+ expect(normalizeProductName("HOTSY")).toBe("Hotsy");
701
+ expect(normalizeProductName("Hotsy")).toBe("Hotsy");
702
+ });
703
+
704
+ it("every catalog name maps to itself", () => {
705
+ for (const name of PRODUCT_CATALOG) {
706
+ expect(normalizeProductName(name)).toBe(name);
707
+ }
708
+ });
709
+
710
+ it("refuses to guess: UNKNOWN and blanks map to nothing", () => {
711
+ expect(normalizeProductName("UNKNOWN")).toBeNull();
712
+ expect(normalizeProductName("")).toBeNull();
713
+ expect(normalizeProductName(" ")).toBeNull();
714
+ expect(normalizeProductName(null)).toBeNull();
715
+ expect(normalizeProductName("Kerosene")).toBeNull();
716
+ });
717
+
718
+ it("isMobil follows the catalog, not the raw string", () => {
719
+ expect(isMobilBrand("MOBIL - CVL")).toBe(true);
720
+ expect(isMobilBrand("Mobil")).toBe(true);
721
+ expect(isMobilBrand("GOLDEN WEST")).toBe(false);
722
+ expect(isMobilBrand("UNKNOWN")).toBe(false);
723
+ });
724
+ });
725
+
726
+ describe("mapProduct", () => {
727
+ it("gives each catalog product a stable sourceId", () => {
728
+ expect(mapProduct("Golden West")).toMatchObject({
729
+ sourceId: "product:golden_west",
730
+ input: { name: "Golden West", sourceId: "product:golden_west" },
731
+ });
732
+ expect(mapProduct("DEF").sourceId).toBe("product:def");
733
+ expect(productSourceId("Mobil")).toBe("product:mobil");
734
+ });
735
+ });