@open-mercato/tillio 0.7.1-develop.7176.1.d229567a50

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 (135) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/build.mjs +7 -0
  3. package/dist/index.js +5 -0
  4. package/dist/index.js.map +7 -0
  5. package/dist/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.js +17 -0
  6. package/dist/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.js.map +7 -0
  7. package/dist/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.js +27 -0
  8. package/dist/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.js.map +7 -0
  9. package/dist/modules/tillio/__integration__/TC-PHONE-003-operators.spec.js +47 -0
  10. package/dist/modules/tillio/__integration__/TC-PHONE-003-operators.spec.js.map +7 -0
  11. package/dist/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.js +11 -0
  12. package/dist/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.js.map +7 -0
  13. package/dist/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.js +13 -0
  14. package/dist/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.js.map +7 -0
  15. package/dist/modules/tillio/acl.js +9 -0
  16. package/dist/modules/tillio/acl.js.map +7 -0
  17. package/dist/modules/tillio/api/operators/[id]/route.js +78 -0
  18. package/dist/modules/tillio/api/operators/[id]/route.js.map +7 -0
  19. package/dist/modules/tillio/api/operators/route.js +171 -0
  20. package/dist/modules/tillio/api/operators/route.js.map +7 -0
  21. package/dist/modules/tillio/api/pull/route.js +180 -0
  22. package/dist/modules/tillio/api/pull/route.js.map +7 -0
  23. package/dist/modules/tillio/cli.js +108 -0
  24. package/dist/modules/tillio/cli.js.map +7 -0
  25. package/dist/modules/tillio/di.js +16 -0
  26. package/dist/modules/tillio/di.js.map +7 -0
  27. package/dist/modules/tillio/index.js +12 -0
  28. package/dist/modules/tillio/index.js.map +7 -0
  29. package/dist/modules/tillio/integration.js +62 -0
  30. package/dist/modules/tillio/integration.js.map +7 -0
  31. package/dist/modules/tillio/lib/adapter.js +107 -0
  32. package/dist/modules/tillio/lib/adapter.js.map +7 -0
  33. package/dist/modules/tillio/lib/client.js +249 -0
  34. package/dist/modules/tillio/lib/client.js.map +7 -0
  35. package/dist/modules/tillio/lib/environment.js +34 -0
  36. package/dist/modules/tillio/lib/environment.js.map +7 -0
  37. package/dist/modules/tillio/lib/error-codes.js +59 -0
  38. package/dist/modules/tillio/lib/error-codes.js.map +7 -0
  39. package/dist/modules/tillio/lib/errors.js +12 -0
  40. package/dist/modules/tillio/lib/errors.js.map +7 -0
  41. package/dist/modules/tillio/lib/health.js +41 -0
  42. package/dist/modules/tillio/lib/health.js.map +7 -0
  43. package/dist/modules/tillio/lib/locking.js +18 -0
  44. package/dist/modules/tillio/lib/locking.js.map +7 -0
  45. package/dist/modules/tillio/lib/normalizer.js +134 -0
  46. package/dist/modules/tillio/lib/normalizer.js.map +7 -0
  47. package/dist/modules/tillio/lib/operators-store.js +62 -0
  48. package/dist/modules/tillio/lib/operators-store.js.map +7 -0
  49. package/dist/modules/tillio/lib/operators.js +168 -0
  50. package/dist/modules/tillio/lib/operators.js.map +7 -0
  51. package/dist/modules/tillio/lib/preset.js +122 -0
  52. package/dist/modules/tillio/lib/preset.js.map +7 -0
  53. package/dist/modules/tillio/lib/pull-job.js +265 -0
  54. package/dist/modules/tillio/lib/pull-job.js.map +7 -0
  55. package/dist/modules/tillio/lib/pull-readiness.js +36 -0
  56. package/dist/modules/tillio/lib/pull-readiness.js.map +7 -0
  57. package/dist/modules/tillio/lib/queue.js +22 -0
  58. package/dist/modules/tillio/lib/queue.js.map +7 -0
  59. package/dist/modules/tillio/lib/tz.js +85 -0
  60. package/dist/modules/tillio/lib/tz.js.map +7 -0
  61. package/dist/modules/tillio/lib/url-guard.js +24 -0
  62. package/dist/modules/tillio/lib/url-guard.js.map +7 -0
  63. package/dist/modules/tillio/setup.js +45 -0
  64. package/dist/modules/tillio/setup.js.map +7 -0
  65. package/dist/modules/tillio/widgets/injection/operators-config/widget.client.js +208 -0
  66. package/dist/modules/tillio/widgets/injection/operators-config/widget.client.js.map +7 -0
  67. package/dist/modules/tillio/widgets/injection/operators-config/widget.js +17 -0
  68. package/dist/modules/tillio/widgets/injection/operators-config/widget.js.map +7 -0
  69. package/dist/modules/tillio/widgets/injection/pull-calls/widget.client.js +148 -0
  70. package/dist/modules/tillio/widgets/injection/pull-calls/widget.client.js.map +7 -0
  71. package/dist/modules/tillio/widgets/injection/pull-calls/widget.js +17 -0
  72. package/dist/modules/tillio/widgets/injection/pull-calls/widget.js.map +7 -0
  73. package/dist/modules/tillio/widgets/injection-table.js +21 -0
  74. package/dist/modules/tillio/widgets/injection-table.js.map +7 -0
  75. package/dist/modules/tillio/workers/tillio-pull.js +17 -0
  76. package/dist/modules/tillio/workers/tillio-pull.js.map +7 -0
  77. package/jest.config.cjs +35 -0
  78. package/package.json +92 -0
  79. package/src/index.ts +1 -0
  80. package/src/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.ts +38 -0
  81. package/src/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.ts +44 -0
  82. package/src/modules/tillio/__integration__/TC-PHONE-003-operators.spec.ts +76 -0
  83. package/src/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.ts +22 -0
  84. package/src/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.ts +27 -0
  85. package/src/modules/tillio/__tests__/adapter-fetch-calls.test.ts +148 -0
  86. package/src/modules/tillio/__tests__/client.test.ts +203 -0
  87. package/src/modules/tillio/__tests__/health.test.ts +122 -0
  88. package/src/modules/tillio/__tests__/normalizer.test.ts +143 -0
  89. package/src/modules/tillio/__tests__/operators-routes.test.ts +100 -0
  90. package/src/modules/tillio/__tests__/operators-store.test.ts +104 -0
  91. package/src/modules/tillio/__tests__/operators.test.ts +250 -0
  92. package/src/modules/tillio/__tests__/preset.test.ts +337 -0
  93. package/src/modules/tillio/__tests__/pull-body.test.ts +43 -0
  94. package/src/modules/tillio/__tests__/pull-job.test.ts +250 -0
  95. package/src/modules/tillio/__tests__/pull-readiness.test.ts +143 -0
  96. package/src/modules/tillio/__tests__/pull-route.test.ts +148 -0
  97. package/src/modules/tillio/__tests__/tz.test.ts +53 -0
  98. package/src/modules/tillio/__tests__/url-guard.test.ts +46 -0
  99. package/src/modules/tillio/acl.ts +5 -0
  100. package/src/modules/tillio/api/operators/[id]/route.ts +90 -0
  101. package/src/modules/tillio/api/operators/route.ts +191 -0
  102. package/src/modules/tillio/api/pull/route.ts +213 -0
  103. package/src/modules/tillio/cli.ts +125 -0
  104. package/src/modules/tillio/di.ts +16 -0
  105. package/src/modules/tillio/i18n/de.json +73 -0
  106. package/src/modules/tillio/i18n/en.json +73 -0
  107. package/src/modules/tillio/i18n/es.json +73 -0
  108. package/src/modules/tillio/i18n/ko.json +73 -0
  109. package/src/modules/tillio/i18n/pl.json +73 -0
  110. package/src/modules/tillio/index.ts +8 -0
  111. package/src/modules/tillio/integration.ts +58 -0
  112. package/src/modules/tillio/lib/adapter.ts +135 -0
  113. package/src/modules/tillio/lib/client.ts +329 -0
  114. package/src/modules/tillio/lib/environment.ts +53 -0
  115. package/src/modules/tillio/lib/error-codes.ts +67 -0
  116. package/src/modules/tillio/lib/errors.ts +11 -0
  117. package/src/modules/tillio/lib/health.ts +57 -0
  118. package/src/modules/tillio/lib/locking.ts +30 -0
  119. package/src/modules/tillio/lib/normalizer.ts +184 -0
  120. package/src/modules/tillio/lib/operators-store.ts +77 -0
  121. package/src/modules/tillio/lib/operators.ts +269 -0
  122. package/src/modules/tillio/lib/preset.ts +226 -0
  123. package/src/modules/tillio/lib/pull-job.ts +390 -0
  124. package/src/modules/tillio/lib/pull-readiness.ts +77 -0
  125. package/src/modules/tillio/lib/queue.ts +33 -0
  126. package/src/modules/tillio/lib/tz.ts +121 -0
  127. package/src/modules/tillio/lib/url-guard.ts +34 -0
  128. package/src/modules/tillio/setup.ts +49 -0
  129. package/src/modules/tillio/widgets/injection/operators-config/widget.client.tsx +304 -0
  130. package/src/modules/tillio/widgets/injection/operators-config/widget.ts +16 -0
  131. package/src/modules/tillio/widgets/injection/pull-calls/widget.client.tsx +214 -0
  132. package/src/modules/tillio/widgets/injection/pull-calls/widget.ts +16 -0
  133. package/src/modules/tillio/widgets/injection-table.ts +19 -0
  134. package/src/modules/tillio/workers/tillio-pull.ts +18 -0
  135. package/tsconfig.json +9 -0
package/package.json ADDED
@@ -0,0 +1,92 @@
1
+ {
2
+ "name": "@open-mercato/tillio",
3
+ "version": "0.7.1-develop.7176.1.d229567a50",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "scripts": {
8
+ "build": "node build.mjs",
9
+ "test": "jest --config jest.config.cjs",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "exports": {
13
+ ".": "./dist/index.js",
14
+ "./*.ts": {
15
+ "types": "./src/*.ts",
16
+ "default": "./dist/*.js"
17
+ },
18
+ "./*.tsx": {
19
+ "types": "./src/*.tsx",
20
+ "default": "./dist/*.js"
21
+ },
22
+ "./*.json": "./src/*.json",
23
+ "./*": {
24
+ "types": [
25
+ "./src/*.ts",
26
+ "./src/*.tsx"
27
+ ],
28
+ "default": "./dist/*.js"
29
+ },
30
+ "./*/*.json": "./src/*/*.json",
31
+ "./*/*": {
32
+ "types": [
33
+ "./src/*/*.ts",
34
+ "./src/*/*.tsx"
35
+ ],
36
+ "default": "./dist/*/*.js"
37
+ },
38
+ "./*/*/*.json": "./src/*/*/*.json",
39
+ "./*/*/*": {
40
+ "types": [
41
+ "./src/*/*/*.ts",
42
+ "./src/*/*/*.tsx"
43
+ ],
44
+ "default": "./dist/*/*/*.js"
45
+ },
46
+ "./*/*/*/*.json": "./src/*/*/*/*.json",
47
+ "./*/*/*/*": {
48
+ "types": [
49
+ "./src/*/*/*/*.ts",
50
+ "./src/*/*/*/*.tsx"
51
+ ],
52
+ "default": "./dist/*/*/*/*.js"
53
+ }
54
+ },
55
+ "dependencies": {
56
+ "@open-mercato/core": "0.7.1-develop.7176.1.d229567a50",
57
+ "@open-mercato/queue": "0.7.1-develop.7176.1.d229567a50",
58
+ "@open-mercato/ui": "0.7.1-develop.7176.1.d229567a50"
59
+ },
60
+ "peerDependencies": {
61
+ "@mikro-orm/postgresql": "^7.0.14",
62
+ "@open-mercato/ai-assistant": "0.7.1-develop.7176.1.d229567a50",
63
+ "@open-mercato/shared": "0.7.1-develop.7176.1.d229567a50",
64
+ "react": "^19.0.0",
65
+ "react-dom": "^19.0.0",
66
+ "react-is": ">=18.0.0"
67
+ },
68
+ "devDependencies": {
69
+ "@open-mercato/ai-assistant": "0.7.1-develop.7176.1.d229567a50",
70
+ "@open-mercato/shared": "0.7.1-develop.7176.1.d229567a50",
71
+ "@types/jest": "^30.0.0",
72
+ "@types/react": "^19.2.18",
73
+ "@types/react-dom": "^19.2.5",
74
+ "esbuild": "^0.28.1",
75
+ "glob": "^13.0.6",
76
+ "jest": "^30.4.2",
77
+ "react": "19.2.8",
78
+ "react-dom": "19.2.8",
79
+ "react-is": "^19.2.8",
80
+ "ts-jest": "^29.4.11",
81
+ "typescript": "^6.0.3"
82
+ },
83
+ "publishConfig": {
84
+ "access": "public"
85
+ },
86
+ "repository": {
87
+ "type": "git",
88
+ "url": "https://github.com/open-mercato/open-mercato",
89
+ "directory": "packages/tillio"
90
+ },
91
+ "stableVersion": "0.7.0"
92
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { metadata } from './modules/tillio/index'
@@ -0,0 +1,38 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
+ import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
4
+
5
+ type ReadinessBody = {
6
+ ok?: boolean
7
+ environmentReady?: boolean
8
+ operatorAttached?: boolean
9
+ envDrift?: boolean
10
+ blocker?: string | null
11
+ operatorId?: string | null
12
+ plugin?: string | null
13
+ }
14
+
15
+ /**
16
+ * TC-PHONE-001 — the pull readiness probe reports an unconfigured Tillio environment.
17
+ *
18
+ * GET /api/tillio/pull only reads config out of the database; it never calls Tillio.
19
+ * On a fresh environment no Tillio credentials exist, so readiness must report the
20
+ * `environment_not_ready` blocker rather than erroring. This asserts the module's own
21
+ * readiness contract — the blocker precedence rules themselves are unit-tested in
22
+ * packages/tillio/src/modules/tillio/__tests__/pull-readiness.test.ts.
23
+ */
24
+ test.describe('TC-PHONE-001: Tillio pull readiness', () => {
25
+ test('reports environment_not_ready when Tillio is not configured', async ({ request }) => {
26
+ const token = await getAuthToken(request, 'superadmin')
27
+ const response = await apiRequest(request, 'GET', '/api/tillio/pull', { token })
28
+
29
+ expect(response.status(), 'the readiness probe should be reachable').toBe(200)
30
+
31
+ const body = await readJsonSafe<ReadinessBody>(response)
32
+ expect(body?.ok, 'readiness responds ok even when blocked').toBe(true)
33
+ expect(body?.environmentReady, 'no Tillio credentials means the environment is not ready').toBe(false)
34
+ expect(body?.operatorAttached, 'no operator can be attached without an environment').toBe(false)
35
+ expect(body?.blocker, 'the environment blocker should be reported').toBe('environment_not_ready')
36
+ expect(body?.operatorId, 'no operator id without an operator').toBeNull()
37
+ })
38
+ })
@@ -0,0 +1,44 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
+ import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
4
+
5
+ type BlockedBody = { ok?: boolean; code?: string; section?: string; message?: string }
6
+
7
+ /**
8
+ * TC-PHONE-002 — the pull refuses to run before Tillio is configured.
9
+ *
10
+ * The readiness gate returns 409 before `tillioAdapter.fetchCalls` is ever reached, so
11
+ * this exercises the real POST path without any network traffic to Tillio and without
12
+ * ingesting anything. That ordering is the point of the test: a regression that moved
13
+ * the gate below the fetch would turn an unconfigured tenant into a live provider call.
14
+ *
15
+ * A pull against a real Tillio environment needs Ringostat credentials and is therefore
16
+ * a deliberate manual check, not an automated one.
17
+ */
18
+ test.describe('TC-PHONE-002: Tillio pull blocked before reaching the provider', () => {
19
+ test('returns a structured 409 when the environment is not configured', async ({ request }) => {
20
+ const token = await getAuthToken(request, 'superadmin')
21
+ const response = await apiRequest(request, 'POST', '/api/tillio/pull', {
22
+ token,
23
+ data: { from: '2026-03-01', to: '2026-03-02' },
24
+ })
25
+
26
+ expect(response.status(), 'an unconfigured environment must block the pull').toBe(409)
27
+
28
+ const body = await readJsonSafe<BlockedBody>(response)
29
+ expect(body?.ok).toBe(false)
30
+ expect(body?.code, 'the blocker code drives which settings section the UI opens').toBe('environment_not_ready')
31
+ expect(body?.section, 'environment blockers route to the environment section').toBe('environment')
32
+ expect(body?.message, 'the blocker carries an actionable message').toBeTruthy()
33
+ })
34
+
35
+ test('rejects a reversed day range before touching readiness', async ({ request }) => {
36
+ const token = await getAuthToken(request, 'superadmin')
37
+ const response = await apiRequest(request, 'POST', '/api/tillio/pull', {
38
+ token,
39
+ data: { from: '2026-03-02', to: '2026-03-01' },
40
+ })
41
+
42
+ expect(response.status(), 'from must not be after to').toBe(400)
43
+ })
44
+ })
@@ -0,0 +1,76 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
+ import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
4
+
5
+ type OperatorsBody = {
6
+ ok?: boolean
7
+ environmentReady?: boolean
8
+ tenantSystemId?: string | null
9
+ supportedPlugins?: string[]
10
+ operators?: Array<{ id?: string; plugin?: string; stale?: boolean }>
11
+ defaultOperatorId?: string | null
12
+ envDrift?: boolean
13
+ }
14
+ type BlockedBody = { ok?: boolean; code?: string; section?: string; message?: string }
15
+
16
+ /**
17
+ * TC-PHONE-003 — the operator routes answer correctly before Tillio is configured.
18
+ *
19
+ * Attaching a real operator calls Tillio (`addConfig`) and needs a Ringostat key, so the
20
+ * live path stays a manual check. Everything asserted here stops before the network:
21
+ * the listing reads credentials out of the database, the attach is refused by the
22
+ * environment gate ahead of `attachOperator`, and detaching an unknown id returns early
23
+ * without resolving an environment at all.
24
+ */
25
+ test.describe('TC-PHONE-003: Tillio operators', () => {
26
+ test('lists an empty operator set on an unconfigured environment', async ({ request }) => {
27
+ const token = await getAuthToken(request, 'superadmin')
28
+ const response = await apiRequest(request, 'GET', '/api/tillio/operators', { token })
29
+
30
+ expect(response.status(), 'the operator listing should be reachable').toBe(200)
31
+
32
+ const body = await readJsonSafe<OperatorsBody>(response)
33
+ expect(body?.ok).toBe(true)
34
+ expect(body?.environmentReady, 'no credentials means the environment is not ready').toBe(false)
35
+ expect(body?.operators, 'no operators can exist without an environment').toEqual([])
36
+ expect(body?.defaultOperatorId).toBeNull()
37
+ expect(body?.tenantSystemId).toBeNull()
38
+ expect(body?.envDrift, 'drift is meaningless with no operators attached').toBe(false)
39
+ expect(body?.supportedPlugins, 'the UI builds its plugin picker from this list').toEqual(['Ringostat'])
40
+ })
41
+
42
+ test('refuses to attach an operator before the environment is healthy', async ({ request }) => {
43
+ const token = await getAuthToken(request, 'superadmin')
44
+ const response = await apiRequest(request, 'POST', '/api/tillio/operators', {
45
+ token,
46
+ data: { plugin: 'Ringostat', config: { key: 'qa-tillio-003-never-used' } },
47
+ })
48
+
49
+ expect(response.status(), 'attaching must be gated on a healthy environment').toBe(409)
50
+
51
+ const body = await readJsonSafe<BlockedBody>(response)
52
+ expect(body?.ok).toBe(false)
53
+ expect(body?.code).toBe('environment_not_ready')
54
+ expect(body?.section, 'the blocker routes the UI to the environment section').toBe('environment')
55
+ })
56
+
57
+ test('rejects an unsupported plugin', async ({ request }) => {
58
+ const token = await getAuthToken(request, 'superadmin')
59
+ const response = await apiRequest(request, 'POST', '/api/tillio/operators', {
60
+ token,
61
+ data: { plugin: 'Twilio', config: { key: 'qa-tillio-003-never-used' } },
62
+ })
63
+
64
+ expect(response.status(), 'only Ringostat is a supported plugin today').toBe(400)
65
+ })
66
+
67
+ test('detaching an unknown operator reports that nothing was detached', async ({ request }) => {
68
+ const token = await getAuthToken(request, 'superadmin')
69
+ const response = await apiRequest(request, 'DELETE', '/api/tillio/operators/qa-tillio-003-missing', { token })
70
+
71
+ expect(response.status()).toBe(200)
72
+ const body = await readJsonSafe<{ ok?: boolean; detached?: boolean }>(response)
73
+ expect(body?.ok).toBe(true)
74
+ expect(body?.detached, 'an unknown id detaches nothing rather than erroring').toBe(false)
75
+ })
76
+ })
@@ -0,0 +1,22 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { login } from '@open-mercato/core/helpers/integration/auth'
3
+
4
+ /**
5
+ * TC-PHONE-004 — the Pull widget reaches the hub's call list toolbar.
6
+ *
7
+ * This module injects `tillio.injection.pull-calls` into `data-table:phone_calls.calls:toolbar`,
8
+ * a spot owned by phone_calls in another package. Nothing in either package fails to compile if
9
+ * that wiring breaks — the button simply stops rendering — so the injection is asserted here.
10
+ *
11
+ * It lives in the provider package on purpose: phone_calls must keep passing with tillio
12
+ * disabled, so its own page spec cannot assert a Tillio button.
13
+ */
14
+ test.describe('TC-PHONE-004: Tillio pull widget injection', () => {
15
+ test('renders the pull action on the phone calls list', async ({ page }) => {
16
+ await login(page, 'admin')
17
+ await page.goto('/backend/phone_calls')
18
+
19
+ await expect(page.getByRole('heading', { name: 'Phone Calls' })).toBeVisible()
20
+ await expect(page.getByRole('button', { name: 'Pull calls' })).toBeVisible()
21
+ })
22
+ })
@@ -0,0 +1,27 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { login } from '@open-mercato/core/helpers/integration/auth'
3
+
4
+ /**
5
+ * TC-PHONE-005 — the operator configuration tab reaches the Tillio integration page.
6
+ *
7
+ * `tillio.injection.operators` is injected as a tab into this integration's detail spot,
8
+ * which the integrations module renders from the `IntegrationDefinition`. The registration
9
+ * chain (definition -> spot id -> injection table) has no compile-time link to the page, so
10
+ * a break would silently drop the tab.
11
+ *
12
+ * The page opens on Credentials, so the tab is activated before asserting its body. With no
13
+ * credentials stored the widget must guide the user to configure them instead of offering an
14
+ * operator form — that guidance is the state a fresh tenant actually sees.
15
+ */
16
+ test.describe('TC-PHONE-005: Tillio operators widget injection', () => {
17
+ test('renders the operator configuration tab with not-ready guidance', async ({ page }) => {
18
+ await login(page, 'admin')
19
+ await page.goto('/backend/integrations/tillio')
20
+
21
+ await page.getByRole('tab', { name: 'Operator configuration' }).click()
22
+
23
+ await expect(
24
+ page.getByText('Save the Tillio API URL and key in the Credentials tab, then run the health Check before attaching an operator.'),
25
+ ).toBeVisible()
26
+ })
27
+ })
@@ -0,0 +1,148 @@
1
+ import { createTillioAdapter } from '../lib/adapter'
2
+
3
+ const credentials = {
4
+ apiUrl: 'https://x.example.com',
5
+ apiKey: 'k',
6
+ tenantSystemId: 'OM-abc',
7
+ operator: {
8
+ id: 'ringostat-1',
9
+ plugin: 'Ringostat' as const,
10
+ token: 'tok',
11
+ tenantDomain: 'app.example.com/OM-abc-ringostat-1',
12
+ },
13
+ }
14
+
15
+ const scope = { organizationId: 'org', tenantId: 'ten' }
16
+
17
+ function call(id: string) {
18
+ return { id, date: '2026-04-11T12:47:28+0200', type: 'IN', caller: '48111', destination: '48222', status: 'ANSWERED', billSec: '5' }
19
+ }
20
+
21
+ function page(calls: unknown[], pageNumber: number, pages: number): Response {
22
+ return new Response(
23
+ JSON.stringify({ calls, pagination: { total: String(calls.length * pages), page: String(pageNumber), pages: String(pages) } }),
24
+ { status: 200, headers: { 'content-type': 'application/json' } },
25
+ )
26
+ }
27
+
28
+ function queryOf(fetchCall: unknown[]): URLSearchParams {
29
+ return new URL(fetchCall[0] as string).searchParams
30
+ }
31
+
32
+ describe('tillioAdapter.fetchCalls', () => {
33
+ const fetchMock = jest.fn()
34
+ // The client now validates and DNS-pins every outbound URL, so the test injects both
35
+ // seams instead of stubbing the global fetch.
36
+ const tillioAdapter = createTillioAdapter({
37
+ fetchImpl: fetchMock as unknown as typeof fetch,
38
+ lookupHost: async () => [{ address: '93.184.216.34', family: 4 }],
39
+ })
40
+
41
+ beforeEach(() => {
42
+ fetchMock.mockReset()
43
+ })
44
+
45
+ it('walks every page reported by pagination and reports no cursor at the end', async () => {
46
+ fetchMock
47
+ .mockResolvedValueOnce(page([call('a')], 1, 3))
48
+ .mockResolvedValueOnce(page([call('b')], 2, 3))
49
+ .mockResolvedValueOnce(page([call('c')], 3, 3))
50
+
51
+ const batch = await tillioAdapter.fetchCalls({ credentials, scope })
52
+
53
+ expect(fetchMock).toHaveBeenCalledTimes(3)
54
+ expect(batch.calls.map((entry) => entry.externalCallId)).toEqual(['a', 'b', 'c'])
55
+ expect(batch.nextCursor).toBeNull()
56
+ })
57
+
58
+ it('sends the Warsaw wall-clock window and the operator token', async () => {
59
+ fetchMock.mockResolvedValue(page([call('a')], 1, 1))
60
+
61
+ await tillioAdapter.fetchCalls({
62
+ credentials,
63
+ scope,
64
+ from: new Date('2026-06-10T22:00:00.000Z'),
65
+ to: new Date('2026-06-11T21:59:00.000Z'),
66
+ })
67
+
68
+ const query = queryOf(fetchMock.mock.calls[0])
69
+ expect(query.get('from')).toBe('2026-06-11 00:00')
70
+ expect(query.get('to')).toBe('2026-06-11 23:59')
71
+ expect(query.get('page')).toBe('1')
72
+ const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record<string, string>
73
+ expect(headers['X-Token']).toBe('tok')
74
+ expect(headers['X-Tenant-Domain']).toBe('app.example.com/OM-abc-ringostat-1')
75
+ })
76
+
77
+ it('sends the window in the configured zone instead of the default one', async () => {
78
+ fetchMock.mockResolvedValue(page([call('a')], 1, 1))
79
+
80
+ await tillioAdapter.fetchCalls({
81
+ credentials: { ...credentials, timeZone: 'UTC' },
82
+ scope,
83
+ from: new Date('2026-06-10T22:00:00.000Z'),
84
+ to: new Date('2026-06-11T21:59:00.000Z'),
85
+ })
86
+
87
+ const query = queryOf(fetchMock.mock.calls[0])
88
+ expect(query.get('from')).toBe('2026-06-10 22:00')
89
+ expect(query.get('to')).toBe('2026-06-11 21:59')
90
+ })
91
+
92
+ it('stops at the batch limit but drains the current page first', async () => {
93
+ fetchMock
94
+ .mockResolvedValueOnce(page([call('a'), call('b')], 1, 4))
95
+ .mockResolvedValueOnce(page([call('c'), call('d')], 2, 4))
96
+
97
+ const batch = await tillioAdapter.fetchCalls({ credentials, scope, limit: 3 })
98
+
99
+ expect(fetchMock).toHaveBeenCalledTimes(2)
100
+ expect(batch.calls).toHaveLength(4)
101
+ expect(batch.nextCursor).toBe('3')
102
+ })
103
+
104
+ it('resumes from the cursor', async () => {
105
+ fetchMock.mockResolvedValueOnce(page([call('c')], 2, 2))
106
+
107
+ const batch = await tillioAdapter.fetchCalls({ credentials, scope, cursor: '2' })
108
+
109
+ expect(queryOf(fetchMock.mock.calls[0]).get('page')).toBe('2')
110
+ expect(batch.calls.map((entry) => entry.externalCallId)).toEqual(['c'])
111
+ expect(batch.nextCursor).toBeNull()
112
+ })
113
+
114
+ // These three exits used to be indistinguishable from reaching the last page, which let a
115
+ // provider that ignores `page` pass off one page as the whole range.
116
+ it('reports the anomaly when the server ignores the requested page', async () => {
117
+ fetchMock.mockResolvedValue(page([call('a')], 1, 5))
118
+
119
+ const batch = await tillioAdapter.fetchCalls({ credentials, scope, cursor: '3' })
120
+
121
+ expect(fetchMock).toHaveBeenCalledTimes(1)
122
+ expect(batch.calls).toHaveLength(1)
123
+ expect(batch.nextCursor).toBeNull()
124
+ expect(batch.anomaly).toBe('page_not_echoed')
125
+ expect(batch.anomalyCursor).toBe('3')
126
+ })
127
+
128
+ it('reports the anomaly on an empty page even when more pages are claimed', async () => {
129
+ fetchMock.mockResolvedValueOnce(page([], 1, 9))
130
+
131
+ const batch = await tillioAdapter.fetchCalls({ credentials, scope })
132
+
133
+ expect(fetchMock).toHaveBeenCalledTimes(1)
134
+ expect(batch.calls).toHaveLength(0)
135
+ expect(batch.nextCursor).toBeNull()
136
+ expect(batch.anomaly).toBe('empty_page')
137
+ })
138
+
139
+ it('reports the anomaly when pagination claims zero pages', async () => {
140
+ fetchMock.mockResolvedValueOnce(page([call('a')], 1, 0))
141
+
142
+ const batch = await tillioAdapter.fetchCalls({ credentials, scope })
143
+
144
+ expect(batch.anomaly).toBe('invalid_page_count')
145
+ expect(fetchMock).toHaveBeenCalledTimes(1)
146
+ expect(batch.nextCursor).toBeNull()
147
+ })
148
+ })
@@ -0,0 +1,203 @@
1
+ import { createTillioClient, redactTillioUrl, type TillioClientDeps } from '../lib/client'
2
+
3
+ const env = { apiUrl: 'https://x.example.com', apiKey: 'k', tenantSystemId: 'OM-abc' }
4
+
5
+ const publicLookupHost: TillioClientDeps['lookupHost'] = async () => [{ address: '93.184.216.34', family: 4 }]
6
+
7
+ function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
8
+ return new Response(JSON.stringify(body), {
9
+ status,
10
+ headers: { 'content-type': 'application/json', ...headers },
11
+ })
12
+ }
13
+
14
+ function redirectResponse(status: number, location?: string): Response {
15
+ return new Response(null, {
16
+ status,
17
+ headers: location ? { location } : {},
18
+ })
19
+ }
20
+
21
+ function headersOf(call: unknown[]): Record<string, string> {
22
+ return (call[1] as RequestInit).headers as Record<string, string>
23
+ }
24
+
25
+ describe('createTillioClient', () => {
26
+ const fetchMock = jest.fn()
27
+
28
+ function client(deps: TillioClientDeps = {}) {
29
+ return createTillioClient(env, {
30
+ fetchImpl: fetchMock as unknown as typeof fetch,
31
+ lookupHost: publicLookupHost,
32
+ ...deps,
33
+ })
34
+ }
35
+
36
+ beforeEach(() => {
37
+ fetchMock.mockReset()
38
+ })
39
+
40
+ it('sends the environment identity headers on getPlugins', async () => {
41
+ fetchMock.mockResolvedValue(jsonResponse({ plugins: [] }))
42
+ await client().getPlugins('test_connection')
43
+ const [url] = fetchMock.mock.calls[0]
44
+ expect(url).toBe('https://x.example.com/api/plugins')
45
+ const headers = headersOf(fetchMock.mock.calls[0])
46
+ expect(headers['X-Api-Key']).toBe('k')
47
+ expect(headers['X-System']).toBe('OM-abc')
48
+ expect(headers['X-Tenant']).toBe('OM-abc')
49
+ expect(headers['X-Tenant-Domain']).toBe('test_connection')
50
+ })
51
+
52
+ it('throws TillioApiError on an error-in-200 body', async () => {
53
+ fetchMock.mockResolvedValue(jsonResponse({ error: 'unauthorized', message: 'Bad key' }))
54
+ await expect(client().getPlugins('test_connection')).rejects.toMatchObject({ name: 'TillioApiError', detail: 'unauthorized' })
55
+ expect(fetchMock).toHaveBeenCalledTimes(1)
56
+ })
57
+
58
+ it('retries on 503 then succeeds', async () => {
59
+ fetchMock
60
+ .mockResolvedValueOnce(jsonResponse({}, 503))
61
+ .mockResolvedValueOnce(jsonResponse({ plugins: [] }))
62
+ await client().getPlugins('test_connection')
63
+ expect(fetchMock).toHaveBeenCalledTimes(2)
64
+ })
65
+
66
+ it('fails fast on a network error without retrying', async () => {
67
+ fetchMock.mockRejectedValue(new Error('ECONNREFUSED'))
68
+ await expect(client().getPlugins('test_connection')).rejects.toMatchObject({ detail: 'network' })
69
+ expect(fetchMock).toHaveBeenCalledTimes(1)
70
+ })
71
+
72
+ it('addConfig posts plugin+config with the operator tenant-domain and returns the token', async () => {
73
+ fetchMock.mockResolvedValue(jsonResponse({ token: 'tok' }))
74
+ const res = await client().addConfig('Ringostat', { key: 'rk' }, 'x.example.com/OM-abc-ringostat')
75
+ const [url, init] = fetchMock.mock.calls[0]
76
+ expect(url).toBe('https://x.example.com/api/config')
77
+ expect(JSON.parse((init as RequestInit).body as string)).toEqual({ plugin: 'Ringostat', config: { key: 'rk' } })
78
+ expect(headersOf(fetchMock.mock.calls[0])['X-Tenant-Domain']).toBe('x.example.com/OM-abc-ringostat')
79
+ expect(res.token).toBe('tok')
80
+ })
81
+
82
+ describe('outbound URL safety', () => {
83
+ it('refuses to build a client for a private API URL', () => {
84
+ expect(() => createTillioClient({ ...env, apiUrl: 'http://169.254.169.254' })).toThrow()
85
+ expect(() => createTillioClient({ ...env, apiUrl: 'http://10.0.0.5' })).toThrow()
86
+ expect(() => createTillioClient({ ...env, apiUrl: 'http://localhost:9000' })).toThrow()
87
+ })
88
+
89
+ it('blocks a public hostname that resolves to a private address', async () => {
90
+ const lookupHost = jest.fn(async () => [{ address: '10.0.0.5', family: 4 }])
91
+ await expect(client({ lookupHost }).getPlugins('test_connection')).rejects.toMatchObject({
92
+ name: 'TillioApiError',
93
+ detail: 'private_ip_resolved',
94
+ })
95
+ expect(lookupHost).toHaveBeenCalledWith('x.example.com')
96
+ expect(fetchMock).not.toHaveBeenCalled()
97
+ })
98
+
99
+ it('revalidates DNS on every request, so a rebind between calls is caught', async () => {
100
+ fetchMock.mockResolvedValue(jsonResponse({ plugins: [] }))
101
+ const lookupHost = jest.fn()
102
+ .mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }])
103
+ .mockResolvedValueOnce([{ address: '127.0.0.1', family: 4 }])
104
+ const tillio = client({ lookupHost })
105
+
106
+ await tillio.getPlugins('test_connection')
107
+ await expect(tillio.getPlugins('test_connection')).rejects.toMatchObject({ detail: 'private_ip_resolved' })
108
+ expect(fetchMock).toHaveBeenCalledTimes(1)
109
+ })
110
+
111
+ it('follows a same-host redirect and revalidates the target', async () => {
112
+ const lookupHost = jest.fn(publicLookupHost)
113
+ fetchMock
114
+ .mockResolvedValueOnce(redirectResponse(302, 'https://x.example.com/api/plugins/v2'))
115
+ .mockResolvedValueOnce(jsonResponse({ plugins: [] }))
116
+
117
+ await client({ lookupHost }).getPlugins('test_connection')
118
+
119
+ expect(fetchMock.mock.calls[1][0]).toBe('https://x.example.com/api/plugins/v2')
120
+ expect(lookupHost).toHaveBeenCalledTimes(2)
121
+ })
122
+
123
+ it('refuses a redirect that leaves the configured host', async () => {
124
+ fetchMock.mockResolvedValueOnce(redirectResponse(302, 'https://attacker.example.net/api/plugins'))
125
+ await expect(client().getPlugins('test_connection')).rejects.toMatchObject({ detail: 'redirect' })
126
+ expect(fetchMock).toHaveBeenCalledTimes(1)
127
+ })
128
+
129
+ it('refuses a redirect pointing at a private address before it is followed', async () => {
130
+ const lookupHost = jest.fn(publicLookupHost)
131
+ fetchMock.mockResolvedValueOnce(redirectResponse(302, 'http://169.254.169.254/latest/meta-data'))
132
+
133
+ await expect(client({ lookupHost }).getPlugins('test_connection')).rejects.toMatchObject({ detail: 'redirect' })
134
+
135
+ // The target is never requested, and never even resolved.
136
+ expect(fetchMock).toHaveBeenCalledTimes(1)
137
+ expect(lookupHost).toHaveBeenCalledTimes(1)
138
+ })
139
+
140
+ it('refuses a same-host redirect once that host resolves to a private address', async () => {
141
+ fetchMock
142
+ .mockResolvedValueOnce(redirectResponse(302, 'https://x.example.com/api/plugins/v2'))
143
+ .mockResolvedValueOnce(jsonResponse({ plugins: [] }))
144
+ const lookupHost = jest.fn()
145
+ .mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }])
146
+ .mockResolvedValueOnce([{ address: '169.254.169.254', family: 4 }])
147
+
148
+ await expect(client({ lookupHost }).getPlugins('test_connection')).rejects.toMatchObject({
149
+ detail: 'private_ip_resolved',
150
+ })
151
+ expect(fetchMock).toHaveBeenCalledTimes(1)
152
+ })
153
+
154
+ it('refuses a redirect that downgrades https to http', async () => {
155
+ fetchMock.mockResolvedValueOnce(redirectResponse(301, 'http://x.example.com/api/plugins'))
156
+ await expect(client().getPlugins('test_connection')).rejects.toMatchObject({ detail: 'redirect' })
157
+ })
158
+
159
+ it('refuses a redirect without a Location header', async () => {
160
+ fetchMock.mockResolvedValueOnce(redirectResponse(302))
161
+ await expect(client().getPlugins('test_connection')).rejects.toMatchObject({ detail: 'redirect' })
162
+ })
163
+
164
+ it('stops after too many redirects', async () => {
165
+ fetchMock.mockResolvedValue(redirectResponse(302, 'https://x.example.com/api/loop'))
166
+ await expect(client().getPlugins('test_connection')).rejects.toMatchObject({ detail: 'redirect' })
167
+ expect(fetchMock).toHaveBeenCalledTimes(4)
168
+ })
169
+
170
+ it('drops the body when a 303 turns the request into a GET', async () => {
171
+ fetchMock
172
+ .mockResolvedValueOnce(redirectResponse(303, 'https://x.example.com/api/config/result'))
173
+ .mockResolvedValueOnce(jsonResponse({ token: 'tok' }))
174
+
175
+ await client().addConfig('Ringostat', { key: 'rk' }, 'x.example.com/OM-abc-ringostat')
176
+
177
+ const [, followUpInit] = fetchMock.mock.calls[1]
178
+ expect((followUpInit as RequestInit).method).toBe('GET')
179
+ expect((followUpInit as RequestInit).body).toBeUndefined()
180
+ })
181
+ })
182
+
183
+ describe('token redaction', () => {
184
+ it('keeps the token out of a network failure message', async () => {
185
+ fetchMock.mockRejectedValue(new Error('ECONNRESET'))
186
+ const promise = client().deleteConfig('Ringostat', 'super-secret-token', 'x.example.com/OM-abc-ringostat')
187
+ await expect(promise).rejects.toMatchObject({ detail: 'network' })
188
+ await expect(promise).rejects.toThrow(/token=\*\*\*/)
189
+ await expect(promise).rejects.not.toThrow(/super-secret-token/)
190
+ })
191
+
192
+ it('masks only the token parameter', () => {
193
+ expect(redactTillioUrl('https://x.example.com/api/config?plugin=Ringostat&token=abc')).toBe(
194
+ 'https://x.example.com/api/config?plugin=Ringostat&token=***',
195
+ )
196
+ expect(redactTillioUrl('https://x.example.com/api/call?page=2')).toBe('https://x.example.com/api/call?page=2')
197
+ })
198
+
199
+ it('drops the whole query when the URL cannot be parsed', () => {
200
+ expect(redactTillioUrl('not a url?token=abc')).toBe('not a url')
201
+ })
202
+ })
203
+ })