@freighttech/terminal-tracking 0.7.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 (236) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/dist/generated/entities/terminal_config/index.js +53 -0
  4. package/dist/generated/entities/terminal_config/index.js.map +7 -0
  5. package/dist/generated/entities/terminal_event/index.js +59 -0
  6. package/dist/generated/entities/terminal_event/index.js.map +7 -0
  7. package/dist/generated/entities/terminal_tracking_job/index.js +37 -0
  8. package/dist/generated/entities/terminal_tracking_job/index.js.map +7 -0
  9. package/dist/generated/entities/terminal_vessel_visit/index.js +41 -0
  10. package/dist/generated/entities/terminal_vessel_visit/index.js.map +7 -0
  11. package/dist/generated/entities.ids.generated.js +26 -0
  12. package/dist/generated/entities.ids.generated.js.map +7 -0
  13. package/dist/index.js +20 -0
  14. package/dist/index.js.map +7 -0
  15. package/dist/modules/terminal_tracking/acl.js +14 -0
  16. package/dist/modules/terminal_tracking/acl.js.map +7 -0
  17. package/dist/modules/terminal_tracking/api/openapi.js +27 -0
  18. package/dist/modules/terminal_tracking/api/openapi.js.map +7 -0
  19. package/dist/modules/terminal_tracking/api/terminal-configs/route.js +140 -0
  20. package/dist/modules/terminal_tracking/api/terminal-configs/route.js.map +7 -0
  21. package/dist/modules/terminal_tracking/api/terminal-configs/test/route.js +58 -0
  22. package/dist/modules/terminal_tracking/api/terminal-configs/test/route.js.map +7 -0
  23. package/dist/modules/terminal_tracking/api/tracking-jobs/details/route.js +109 -0
  24. package/dist/modules/terminal_tracking/api/tracking-jobs/details/route.js.map +7 -0
  25. package/dist/modules/terminal_tracking/api/tracking-jobs/poll/route.js +58 -0
  26. package/dist/modules/terminal_tracking/api/tracking-jobs/poll/route.js.map +7 -0
  27. package/dist/modules/terminal_tracking/api/tracking-jobs/route.js +189 -0
  28. package/dist/modules/terminal_tracking/api/tracking-jobs/route.js.map +7 -0
  29. package/dist/modules/terminal_tracking/api/utils.js +12 -0
  30. package/dist/modules/terminal_tracking/api/utils.js.map +7 -0
  31. package/dist/modules/terminal_tracking/api/vessel-visits/route.js +83 -0
  32. package/dist/modules/terminal_tracking/api/vessel-visits/route.js.map +7 -0
  33. package/dist/modules/terminal_tracking/backend/terminal-configs/page.js +669 -0
  34. package/dist/modules/terminal_tracking/backend/terminal-configs/page.js.map +7 -0
  35. package/dist/modules/terminal_tracking/backend/terminal-configs/page.meta.js +19 -0
  36. package/dist/modules/terminal_tracking/backend/terminal-configs/page.meta.js.map +7 -0
  37. package/dist/modules/terminal_tracking/backend/terminal-containers/page.js +275 -0
  38. package/dist/modules/terminal_tracking/backend/terminal-containers/page.js.map +7 -0
  39. package/dist/modules/terminal_tracking/backend/terminal-containers/page.meta.js +19 -0
  40. package/dist/modules/terminal_tracking/backend/terminal-containers/page.meta.js.map +7 -0
  41. package/dist/modules/terminal_tracking/ce.js +35 -0
  42. package/dist/modules/terminal_tracking/ce.js.map +7 -0
  43. package/dist/modules/terminal_tracking/commands/index.js +4 -0
  44. package/dist/modules/terminal_tracking/commands/index.js.map +7 -0
  45. package/dist/modules/terminal_tracking/commands/terminal-configs.js +107 -0
  46. package/dist/modules/terminal_tracking/commands/terminal-configs.js.map +7 -0
  47. package/dist/modules/terminal_tracking/commands/terminal-tracking.js +27 -0
  48. package/dist/modules/terminal_tracking/commands/terminal-tracking.js.map +7 -0
  49. package/dist/modules/terminal_tracking/commands/tracking-jobs.js +56 -0
  50. package/dist/modules/terminal_tracking/commands/tracking-jobs.js.map +7 -0
  51. package/dist/modules/terminal_tracking/components/ContainerDetailsDrawer.js +371 -0
  52. package/dist/modules/terminal_tracking/components/ContainerDetailsDrawer.js.map +7 -0
  53. package/dist/modules/terminal_tracking/data/entities.js +337 -0
  54. package/dist/modules/terminal_tracking/data/entities.js.map +7 -0
  55. package/dist/modules/terminal_tracking/data/validators.js +98 -0
  56. package/dist/modules/terminal_tracking/data/validators.js.map +7 -0
  57. package/dist/modules/terminal_tracking/di.js +39 -0
  58. package/dist/modules/terminal_tracking/di.js.map +7 -0
  59. package/dist/modules/terminal_tracking/encryption.js +14 -0
  60. package/dist/modules/terminal_tracking/encryption.js.map +7 -0
  61. package/dist/modules/terminal_tracking/events.js +45 -0
  62. package/dist/modules/terminal_tracking/events.js.map +7 -0
  63. package/dist/modules/terminal_tracking/i18n/de.json +175 -0
  64. package/dist/modules/terminal_tracking/i18n/en.json +178 -0
  65. package/dist/modules/terminal_tracking/i18n/es.json +175 -0
  66. package/dist/modules/terminal_tracking/i18n/pl.json +206 -0
  67. package/dist/modules/terminal_tracking/index.js +15 -0
  68. package/dist/modules/terminal_tracking/index.js.map +7 -0
  69. package/dist/modules/terminal_tracking/lib/adapters/bct/adapter.js +164 -0
  70. package/dist/modules/terminal_tracking/lib/adapters/bct/adapter.js.map +7 -0
  71. package/dist/modules/terminal_tracking/lib/adapters/bct/auth/basic.js +23 -0
  72. package/dist/modules/terminal_tracking/lib/adapters/bct/auth/basic.js.map +7 -0
  73. package/dist/modules/terminal_tracking/lib/adapters/bct/incos-semantics.js +146 -0
  74. package/dist/modules/terminal_tracking/lib/adapters/bct/incos-semantics.js.map +7 -0
  75. package/dist/modules/terminal_tracking/lib/adapters/bct/types.js +1 -0
  76. package/dist/modules/terminal_tracking/lib/adapters/bct/types.js.map +7 -0
  77. package/dist/modules/terminal_tracking/lib/adapters/gct/adapter.js +106 -0
  78. package/dist/modules/terminal_tracking/lib/adapters/gct/adapter.js.map +7 -0
  79. package/dist/modules/terminal_tracking/lib/adapters/gct/auth/token.js +120 -0
  80. package/dist/modules/terminal_tracking/lib/adapters/gct/auth/token.js.map +7 -0
  81. package/dist/modules/terminal_tracking/lib/adapters/gct/gct-semantics.js +77 -0
  82. package/dist/modules/terminal_tracking/lib/adapters/gct/gct-semantics.js.map +7 -0
  83. package/dist/modules/terminal_tracking/lib/adapters/gct/types.js +1 -0
  84. package/dist/modules/terminal_tracking/lib/adapters/gct/types.js.map +7 -0
  85. package/dist/modules/terminal_tracking/lib/adapters/index.js +12 -0
  86. package/dist/modules/terminal_tracking/lib/adapters/index.js.map +7 -0
  87. package/dist/modules/terminal_tracking/lib/adapters/n4/adapter.js +121 -0
  88. package/dist/modules/terminal_tracking/lib/adapters/n4/adapter.js.map +7 -0
  89. package/dist/modules/terminal_tracking/lib/adapters/n4/auth/ropc.js +81 -0
  90. package/dist/modules/terminal_tracking/lib/adapters/n4/auth/ropc.js.map +7 -0
  91. package/dist/modules/terminal_tracking/lib/adapters/n4/http.js +66 -0
  92. package/dist/modules/terminal_tracking/lib/adapters/n4/http.js.map +7 -0
  93. package/dist/modules/terminal_tracking/lib/adapters/n4/n4-semantics.js +95 -0
  94. package/dist/modules/terminal_tracking/lib/adapters/n4/n4-semantics.js.map +7 -0
  95. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.js +70 -0
  96. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.js.map +7 -0
  97. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.js +44 -0
  98. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.js.map +7 -0
  99. package/dist/modules/terminal_tracking/lib/adapters/n4/proxy.js +15 -0
  100. package/dist/modules/terminal_tracking/lib/adapters/n4/proxy.js.map +7 -0
  101. package/dist/modules/terminal_tracking/lib/adapters/n4/types.js +1 -0
  102. package/dist/modules/terminal_tracking/lib/adapters/n4/types.js.map +7 -0
  103. package/dist/modules/terminal_tracking/lib/adapters/zoned-time.js +27 -0
  104. package/dist/modules/terminal_tracking/lib/adapters/zoned-time.js.map +7 -0
  105. package/dist/modules/terminal_tracking/lib/availability.js +40 -0
  106. package/dist/modules/terminal_tracking/lib/availability.js.map +7 -0
  107. package/dist/modules/terminal_tracking/lib/ensure-schedule.js +51 -0
  108. package/dist/modules/terminal_tracking/lib/ensure-schedule.js.map +7 -0
  109. package/dist/modules/terminal_tracking/lib/holds.js +240 -0
  110. package/dist/modules/terminal_tracking/lib/holds.js.map +7 -0
  111. package/dist/modules/terminal_tracking/lib/logger.js +19 -0
  112. package/dist/modules/terminal_tracking/lib/logger.js.map +7 -0
  113. package/dist/modules/terminal_tracking/lib/rate-limiter.js +30 -0
  114. package/dist/modules/terminal_tracking/lib/rate-limiter.js.map +7 -0
  115. package/dist/modules/terminal_tracking/lib/stops.js +48 -0
  116. package/dist/modules/terminal_tracking/lib/stops.js.map +7 -0
  117. package/dist/modules/terminal_tracking/lib/terminal-adapter.js +1 -0
  118. package/dist/modules/terminal_tracking/lib/terminal-adapter.js.map +7 -0
  119. package/dist/modules/terminal_tracking/lib/totp.js +41 -0
  120. package/dist/modules/terminal_tracking/lib/totp.js.map +7 -0
  121. package/dist/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.js +101 -0
  122. package/dist/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.js.map +7 -0
  123. package/dist/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.js +39 -0
  124. package/dist/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.js.map +7 -0
  125. package/dist/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.js +13 -0
  126. package/dist/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.js.map +7 -0
  127. package/dist/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.js +15 -0
  128. package/dist/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.js.map +7 -0
  129. package/dist/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.js +13 -0
  130. package/dist/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.js.map +7 -0
  131. package/dist/modules/terminal_tracking/services/terminalMatcherService.js +61 -0
  132. package/dist/modules/terminal_tracking/services/terminalMatcherService.js.map +7 -0
  133. package/dist/modules/terminal_tracking/services/terminalRegistry.js +24 -0
  134. package/dist/modules/terminal_tracking/services/terminalRegistry.js.map +7 -0
  135. package/dist/modules/terminal_tracking/services/terminalTrackingService.js +687 -0
  136. package/dist/modules/terminal_tracking/services/terminalTrackingService.js.map +7 -0
  137. package/dist/modules/terminal_tracking/setup.js +23 -0
  138. package/dist/modules/terminal_tracking/setup.js.map +7 -0
  139. package/dist/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.js +22 -0
  140. package/dist/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.js.map +7 -0
  141. package/generated/entities/terminal_config/index.ts +36 -0
  142. package/generated/entities/terminal_event/index.ts +39 -0
  143. package/generated/entities/terminal_tracking_job/index.ts +28 -0
  144. package/generated/entities/terminal_vessel_visit/index.ts +30 -0
  145. package/generated/entities.ids.generated.ts +52 -0
  146. package/package.json +80 -0
  147. package/src/index.ts +44 -0
  148. package/src/modules/terminal_tracking/__integration__/TC-TRACK-301-gct-config-test-connection.spec.ts +66 -0
  149. package/src/modules/terminal_tracking/__integration__/TC-TRACK-317-bct-config-test-connection.spec.ts +68 -0
  150. package/src/modules/terminal_tracking/__integration__/helpers.ts +148 -0
  151. package/src/modules/terminal_tracking/__integration__/meta.ts +4 -0
  152. package/src/modules/terminal_tracking/acl.ts +10 -0
  153. package/src/modules/terminal_tracking/api/openapi.ts +28 -0
  154. package/src/modules/terminal_tracking/api/terminal-configs/route.ts +140 -0
  155. package/src/modules/terminal_tracking/api/terminal-configs/test/route.ts +64 -0
  156. package/src/modules/terminal_tracking/api/tracking-jobs/details/route.ts +116 -0
  157. package/src/modules/terminal_tracking/api/tracking-jobs/poll/route.ts +64 -0
  158. package/src/modules/terminal_tracking/api/tracking-jobs/route.ts +243 -0
  159. package/src/modules/terminal_tracking/api/utils.ts +10 -0
  160. package/src/modules/terminal_tracking/api/vessel-visits/route.ts +95 -0
  161. package/src/modules/terminal_tracking/backend/terminal-configs/page.meta.ts +16 -0
  162. package/src/modules/terminal_tracking/backend/terminal-configs/page.tsx +728 -0
  163. package/src/modules/terminal_tracking/backend/terminal-containers/page.meta.ts +16 -0
  164. package/src/modules/terminal_tracking/backend/terminal-containers/page.tsx +328 -0
  165. package/src/modules/terminal_tracking/ce.ts +31 -0
  166. package/src/modules/terminal_tracking/commands/index.ts +3 -0
  167. package/src/modules/terminal_tracking/commands/terminal-configs.ts +132 -0
  168. package/src/modules/terminal_tracking/commands/terminal-tracking.ts +34 -0
  169. package/src/modules/terminal_tracking/commands/tracking-jobs.ts +69 -0
  170. package/src/modules/terminal_tracking/components/ContainerDetailsDrawer.tsx +540 -0
  171. package/src/modules/terminal_tracking/data/entities.ts +386 -0
  172. package/src/modules/terminal_tracking/data/validators.ts +113 -0
  173. package/src/modules/terminal_tracking/di.ts +46 -0
  174. package/src/modules/terminal_tracking/encryption.ts +12 -0
  175. package/src/modules/terminal_tracking/events.ts +48 -0
  176. package/src/modules/terminal_tracking/i18n/de.json +175 -0
  177. package/src/modules/terminal_tracking/i18n/en.json +178 -0
  178. package/src/modules/terminal_tracking/i18n/es.json +175 -0
  179. package/src/modules/terminal_tracking/i18n/pl.json +206 -0
  180. package/src/modules/terminal_tracking/index.ts +13 -0
  181. package/src/modules/terminal_tracking/lib/__tests__/availability.test.ts +136 -0
  182. package/src/modules/terminal_tracking/lib/__tests__/ensure-schedule.test.ts +67 -0
  183. package/src/modules/terminal_tracking/lib/__tests__/holds.test.ts +73 -0
  184. package/src/modules/terminal_tracking/lib/__tests__/stops.test.ts +80 -0
  185. package/src/modules/terminal_tracking/lib/__tests__/totp.test.ts +38 -0
  186. package/src/modules/terminal_tracking/lib/adapters/bct/__tests__/adapter.test.ts +196 -0
  187. package/src/modules/terminal_tracking/lib/adapters/bct/__tests__/incos-semantics.test.ts +303 -0
  188. package/src/modules/terminal_tracking/lib/adapters/bct/adapter.ts +219 -0
  189. package/src/modules/terminal_tracking/lib/adapters/bct/auth/basic.ts +34 -0
  190. package/src/modules/terminal_tracking/lib/adapters/bct/incos-semantics.ts +291 -0
  191. package/src/modules/terminal_tracking/lib/adapters/bct/types.ts +113 -0
  192. package/src/modules/terminal_tracking/lib/adapters/gct/__tests__/adapter.test.ts +238 -0
  193. package/src/modules/terminal_tracking/lib/adapters/gct/__tests__/gct-semantics.test.ts +133 -0
  194. package/src/modules/terminal_tracking/lib/adapters/gct/adapter.ts +152 -0
  195. package/src/modules/terminal_tracking/lib/adapters/gct/auth/token.ts +219 -0
  196. package/src/modules/terminal_tracking/lib/adapters/gct/gct-semantics.ts +136 -0
  197. package/src/modules/terminal_tracking/lib/adapters/gct/types.ts +58 -0
  198. package/src/modules/terminal_tracking/lib/adapters/index.ts +16 -0
  199. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/adapter-proxy.test.ts +69 -0
  200. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/data-table.test.ts +179 -0
  201. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/http.test.ts +98 -0
  202. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/n4-semantics.test.ts +140 -0
  203. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/proxy.test.ts +19 -0
  204. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/vessel-data-table.test.ts +67 -0
  205. package/src/modules/terminal_tracking/lib/adapters/n4/adapter.ts +163 -0
  206. package/src/modules/terminal_tracking/lib/adapters/n4/auth/ropc.ts +104 -0
  207. package/src/modules/terminal_tracking/lib/adapters/n4/http.ts +127 -0
  208. package/src/modules/terminal_tracking/lib/adapters/n4/n4-semantics.ts +156 -0
  209. package/src/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.ts +102 -0
  210. package/src/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.ts +60 -0
  211. package/src/modules/terminal_tracking/lib/adapters/n4/proxy.ts +24 -0
  212. package/src/modules/terminal_tracking/lib/adapters/n4/types.ts +56 -0
  213. package/src/modules/terminal_tracking/lib/adapters/zoned-time.ts +65 -0
  214. package/src/modules/terminal_tracking/lib/availability.ts +119 -0
  215. package/src/modules/terminal_tracking/lib/ensure-schedule.ts +83 -0
  216. package/src/modules/terminal_tracking/lib/holds.ts +344 -0
  217. package/src/modules/terminal_tracking/lib/logger.ts +21 -0
  218. package/src/modules/terminal_tracking/lib/rate-limiter.ts +52 -0
  219. package/src/modules/terminal_tracking/lib/stops.ts +124 -0
  220. package/src/modules/terminal_tracking/lib/terminal-adapter.ts +136 -0
  221. package/src/modules/terminal_tracking/lib/totp.ts +63 -0
  222. package/src/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.ts +104 -0
  223. package/src/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.ts +40 -0
  224. package/src/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.ts +11 -0
  225. package/src/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.ts +13 -0
  226. package/src/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.ts +16 -0
  227. package/src/modules/terminal_tracking/services/__tests__/availabilityEmit.test.ts +277 -0
  228. package/src/modules/terminal_tracking/services/__tests__/createJob.test.ts +59 -0
  229. package/src/modules/terminal_tracking/services/__tests__/pollAllBatch.test.ts +147 -0
  230. package/src/modules/terminal_tracking/services/__tests__/terminalMatcher.test.ts +70 -0
  231. package/src/modules/terminal_tracking/services/__tests__/vesselResolution.test.ts +453 -0
  232. package/src/modules/terminal_tracking/services/terminalMatcherService.ts +97 -0
  233. package/src/modules/terminal_tracking/services/terminalRegistry.ts +30 -0
  234. package/src/modules/terminal_tracking/services/terminalTrackingService.ts +912 -0
  235. package/src/modules/terminal_tracking/setup.ts +24 -0
  236. package/src/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.ts +32 -0
@@ -0,0 +1,238 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+
3
+ // The adapter's fetches run through `n4Request`, which drives undici's `fetch`.
4
+ // Mock the undici module, keeping the real ProxyAgent/Agent so the dispatcher is
5
+ // built exactly as in production.
6
+ const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }))
7
+ vi.mock('undici', async (orig) => {
8
+ const actual = await orig<typeof import('undici')>()
9
+ return { ...actual, fetch: fetchMock }
10
+ })
11
+
12
+ import { GctTerminalAdapter } from '../adapter'
13
+ import type { ResolvedTerminalConfig } from '../../../terminal-adapter'
14
+
15
+ let counter = 0
16
+ function cfg(overrides: Partial<ResolvedTerminalConfig> = {}): ResolvedTerminalConfig {
17
+ // Unique terminalCode per config to bypass the process-level token cache.
18
+ return {
19
+ terminalCode: `gcttest-${counter++}`,
20
+ adapterType: 'gct',
21
+ displayName: 'GCT',
22
+ baseUrl: 'https://api.gct.example',
23
+ proxyUrl: null,
24
+ endpoints: { unit: '/gctapi/GetContainerDetails' },
25
+ authType: 'gct_token',
26
+ // Authenticator secret (base32) — GCT's login takes a TOTP code as password.
27
+ // `JBSWY3DPEHPK3PXP` is the canonical RFC test seed ("Hello!").
28
+ authConfig: { companyCode: 'ACME', loginName: 'user', totpSecret: 'JBSWY3DPEHPK3PXP' },
29
+ rateLimitRequests: 60,
30
+ rateLimitWindowSeconds: 60,
31
+ unlocode: 'PLGDY',
32
+ ...overrides,
33
+ }
34
+ }
35
+
36
+ const FUTURE = '2999-01-01T00:00:00Z'
37
+ const PAST = '2000-01-01T00:00:00Z'
38
+
39
+ function tokenResponse(token = 'tok-123', expires: string = FUTURE) {
40
+ return new Response(JSON.stringify({ token, token_type: 'Bearer', expires }), { status: 200 })
41
+ }
42
+ function detailsResponse(containers: unknown[]) {
43
+ return new Response(JSON.stringify({ Total: containers.length, Containers: containers }), { status: 200 })
44
+ }
45
+
46
+ const isAuthCall = (c: any[]) => (c[1] as any)?.method === 'GET'
47
+ const isDetailsCall = (c: any[]) => (c[1] as any)?.method === 'POST'
48
+ // Mint (full auth) vs refresh (keep-alive) are both GET — tell them apart by URL.
49
+ const isMintCall = (c: any[]) => String(c[0]).includes('/gctapi/Auth')
50
+ const isRefreshCall = (c: any[]) => String(c[0]).includes('/gctapi/ping')
51
+
52
+ afterEach(() => fetchMock.mockReset())
53
+
54
+ describe('GctTerminalAdapter.testConnection', () => {
55
+ // TC-TRACK-301
56
+ it('mints a token and reports success; a generated TOTP code goes in the path, token in the Authorization header', async () => {
57
+ fetchMock.mockResolvedValueOnce(tokenResponse('abc'))
58
+ const config = cfg()
59
+ const res = await new GctTerminalAdapter().testConnection(config)
60
+
61
+ expect(res.success).toBe(true)
62
+ const authCall = fetchMock.mock.calls.find(isAuthCall)!
63
+ // company/login in the path; the password segment is a fresh 6-digit TOTP code.
64
+ expect(String(authCall[0])).toMatch(/\/gctapi\/Auth\/ACME\/user\/\d{6}$/)
65
+ // The raw authenticator secret must NEVER appear in the URL — only the code.
66
+ expect(String(authCall[0])).not.toContain('JBSWY3DPEHPK3PXP')
67
+ })
68
+
69
+ it('honours a legacy static loginPassword when no totpSecret is configured', async () => {
70
+ fetchMock.mockResolvedValueOnce(tokenResponse('abc'))
71
+ const config = cfg({ authConfig: { companyCode: 'ACME', loginName: 'user', loginPassword: 'legacy-pass' } })
72
+ const res = await new GctTerminalAdapter().testConnection(config)
73
+
74
+ expect(res.success).toBe(true)
75
+ const authCall = fetchMock.mock.calls.find(isAuthCall)!
76
+ expect(String(authCall[0])).toContain('/gctapi/Auth/ACME/user/legacy-pass')
77
+ })
78
+
79
+ it('reports failure with only the status (never the secret) on a bad token', async () => {
80
+ fetchMock.mockResolvedValueOnce(new Response('nope', { status: 403 }))
81
+ const res = await new GctTerminalAdapter().testConnection(cfg())
82
+ expect(res.success).toBe(false)
83
+ expect(res.message).toContain('403')
84
+ expect(res.message).not.toContain('JBSWY3DPEHPK3PXP')
85
+ })
86
+
87
+ it('fails clearly when auth_config is missing credentials', async () => {
88
+ const res = await new GctTerminalAdapter().testConnection(cfg({ authConfig: {} }))
89
+ expect(res.success).toBe(false)
90
+ expect(res.message).toMatch(/companyCode|totpSecret/)
91
+ expect(fetchMock).not.toHaveBeenCalled()
92
+ })
93
+ })
94
+
95
+ describe('GctTerminalAdapter.fetchEvents', () => {
96
+ // TC-TRACK-306 (mapping + Authorization header form)
97
+ it('replays the raw token in the Authorization header and maps the container', async () => {
98
+ fetchMock.mockResolvedValueOnce(tokenResponse('tok-xyz'))
99
+ fetchMock.mockResolvedValueOnce(
100
+ detailsResponse([{ CntrID: 'GCTU1234567', VisitNo: 'V9', GroundingDateTime: '2026-08-10T09:30:00Z' }]),
101
+ )
102
+
103
+ const { events } = await new GctTerminalAdapter().fetchEvents({
104
+ containerNumber: 'GCTU1234567',
105
+ config: cfg(),
106
+ })
107
+
108
+ expect(events).toHaveLength(1)
109
+ expect(events[0].sourceEventId).toBe(events[0].sourceEventId) // stable
110
+ expect(events[0].eventCode).toBe('GTIN')
111
+
112
+ const detailsCall = fetchMock.mock.calls.find(isDetailsCall)!
113
+ expect((detailsCall[1] as any).headers.Authorization).toBe('tok-xyz')
114
+ expect(JSON.parse((detailsCall[1] as any).body)).toMatchObject({ CntrID: 'GCTU1234567' })
115
+ })
116
+
117
+ // TC-TRACK-309
118
+ it('returns [] for an empty/malformed Containers payload without throwing', async () => {
119
+ fetchMock.mockResolvedValueOnce(tokenResponse())
120
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ Total: 0 }), { status: 200 }))
121
+ const { events } = await new GctTerminalAdapter().fetchEvents({ containerNumber: 'X', config: cfg() })
122
+ expect(events).toEqual([])
123
+ })
124
+
125
+ it('throws on a non-JSON details body', async () => {
126
+ fetchMock.mockResolvedValueOnce(tokenResponse())
127
+ fetchMock.mockResolvedValueOnce(new Response('<html>500</html>', { status: 200 }))
128
+ await expect(
129
+ new GctTerminalAdapter().fetchEvents({ containerNumber: 'X', config: cfg() }),
130
+ ).rejects.toThrow(/valid JSON/)
131
+ })
132
+ })
133
+
134
+ describe('GctTerminalAdapter token cache + 401 handling', () => {
135
+ // TC-TRACK-305
136
+ it('reuses the cached token across calls, then re-acquires after a 401', async () => {
137
+ const config = cfg()
138
+ // 1st fetchEvents: token GET + details POST(200)
139
+ fetchMock.mockResolvedValueOnce(tokenResponse('t1'))
140
+ fetchMock.mockResolvedValueOnce(detailsResponse([]))
141
+ await new GctTerminalAdapter().fetchEvents({ containerNumber: 'A', config })
142
+ expect(fetchMock.mock.calls.filter(isAuthCall)).toHaveLength(1)
143
+
144
+ // 2nd fetchEvents: cache hit (no new token GET) + details POST that 401s
145
+ fetchMock.mockResolvedValueOnce(new Response('unauthorized', { status: 401 }))
146
+ await expect(
147
+ new GctTerminalAdapter().fetchEvents({ containerNumber: 'A', config }),
148
+ ).rejects.toThrow(/401/)
149
+ expect(fetchMock.mock.calls.filter(isAuthCall)).toHaveLength(1) // still cached until invalidated
150
+
151
+ // 3rd fetchEvents: the 401 invalidated the cache, so a fresh token is minted
152
+ fetchMock.mockResolvedValueOnce(tokenResponse('t2'))
153
+ fetchMock.mockResolvedValueOnce(detailsResponse([]))
154
+ await new GctTerminalAdapter().fetchEvents({ containerNumber: 'A', config })
155
+ expect(fetchMock.mock.calls.filter(isAuthCall)).toHaveLength(2)
156
+ })
157
+ })
158
+
159
+ describe('GctTerminalAdapter token refresh (keep-alive)', () => {
160
+ // TC-TRACK-307 — an expired token is refreshed via /gctapi/ping, not re-minted.
161
+ it('refreshes an expired token via /gctapi/ping instead of re-authenticating', async () => {
162
+ const config = cfg()
163
+ // 1st fetchEvents mints a token that is ALREADY expired (past `expires`).
164
+ fetchMock.mockResolvedValueOnce(tokenResponse('t-mint', PAST))
165
+ fetchMock.mockResolvedValueOnce(detailsResponse([]))
166
+ await new GctTerminalAdapter().fetchEvents({ containerNumber: 'A', config })
167
+
168
+ // 2nd fetchEvents: token is stale → keep-alive refresh returns a fresh token.
169
+ fetchMock.mockResolvedValueOnce(tokenResponse('t-refreshed', FUTURE)) // ping
170
+ fetchMock.mockResolvedValueOnce(detailsResponse([]))
171
+ await new GctTerminalAdapter().fetchEvents({ containerNumber: 'A', config })
172
+
173
+ expect(fetchMock.mock.calls.filter(isRefreshCall)).toHaveLength(1)
174
+ expect(fetchMock.mock.calls.filter(isMintCall)).toHaveLength(1) // no re-auth
175
+ const details = fetchMock.mock.calls.filter(isDetailsCall)
176
+ expect((details[1][1] as any).headers.Authorization).toBe('t-refreshed')
177
+ })
178
+
179
+ // TC-TRACK-308 — a failed refresh falls back to a full re-authentication.
180
+ it('re-authenticates when the refresh call fails', async () => {
181
+ const config = cfg()
182
+ fetchMock.mockResolvedValueOnce(tokenResponse('t1', PAST)) // mint (already expired)
183
+ fetchMock.mockResolvedValueOnce(detailsResponse([]))
184
+ await new GctTerminalAdapter().fetchEvents({ containerNumber: 'A', config })
185
+
186
+ fetchMock.mockResolvedValueOnce(new Response('unauthorized', { status: 401 })) // ping fails
187
+ fetchMock.mockResolvedValueOnce(tokenResponse('t2', FUTURE)) // re-auth mint
188
+ fetchMock.mockResolvedValueOnce(detailsResponse([]))
189
+ await new GctTerminalAdapter().fetchEvents({ containerNumber: 'A', config })
190
+
191
+ expect(fetchMock.mock.calls.filter(isRefreshCall)).toHaveLength(1) // tried once
192
+ expect(fetchMock.mock.calls.filter(isMintCall)).toHaveLength(2) // initial + re-auth
193
+ const details = fetchMock.mock.calls.filter(isDetailsCall)
194
+ expect((details[1][1] as any).headers.Authorization).toBe('t2')
195
+ })
196
+
197
+ it('re-authenticates when the refresh response carries no token', async () => {
198
+ const config = cfg()
199
+ fetchMock.mockResolvedValueOnce(tokenResponse('t1', PAST))
200
+ fetchMock.mockResolvedValueOnce(detailsResponse([]))
201
+ await new GctTerminalAdapter().fetchEvents({ containerNumber: 'A', config })
202
+
203
+ fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ status: 'ok' }), { status: 200 })) // ping, no token
204
+ fetchMock.mockResolvedValueOnce(tokenResponse('t2', FUTURE))
205
+ fetchMock.mockResolvedValueOnce(detailsResponse([]))
206
+ await new GctTerminalAdapter().fetchEvents({ containerNumber: 'A', config })
207
+
208
+ expect(fetchMock.mock.calls.filter(isRefreshCall)).toHaveLength(1)
209
+ expect(fetchMock.mock.calls.filter(isMintCall)).toHaveLength(2)
210
+ })
211
+ })
212
+
213
+ describe('GctTerminalAdapter.fetchEventsBatch', () => {
214
+ it('loops per container and isolates a single container failure', async () => {
215
+ const config = cfg()
216
+ fetchMock.mockResolvedValueOnce(tokenResponse()) // up-front acquire
217
+ // container A (token cached now): details 500 → isolated
218
+ fetchMock.mockResolvedValueOnce(new Response('err', { status: 500 }))
219
+ // container B: details 200 with a mappable row
220
+ fetchMock.mockResolvedValueOnce(
221
+ detailsResponse([{ CntrID: 'B', VisitNo: 'VB', GroundingDateTime: '2026-08-10T09:30:00Z' }]),
222
+ )
223
+
224
+ const { events } = await new GctTerminalAdapter().fetchEventsBatch({
225
+ containerNumbers: ['A', 'B'],
226
+ config,
227
+ })
228
+ expect(events).toHaveLength(1)
229
+ expect(events[0].containerNumber).toBe('B')
230
+ })
231
+
232
+ it('fails the whole batch when the up-front token acquire fails', async () => {
233
+ fetchMock.mockResolvedValueOnce(new Response('nope', { status: 403 }))
234
+ await expect(
235
+ new GctTerminalAdapter().fetchEventsBatch({ containerNumbers: ['A', 'B'], config: cfg() }),
236
+ ).rejects.toThrow()
237
+ })
238
+ })
@@ -0,0 +1,133 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mapContainerToEvents, directionFromStatus, parseGctDateTime } from '../gct-semantics'
3
+ import type { GctContainer } from '../types'
4
+ import type { ResolvedTerminalConfig } from '../../../terminal-adapter'
5
+
6
+ const config: ResolvedTerminalConfig = {
7
+ terminalCode: 'gct',
8
+ adapterType: 'gct',
9
+ displayName: 'Gdynia Container Terminal',
10
+ baseUrl: 'https://api.gct.example',
11
+ endpoints: { unit: '/gctapi/GetContainerDetails' },
12
+ authType: 'gct_token',
13
+ rateLimitRequests: 60,
14
+ rateLimitWindowSeconds: 60,
15
+ unlocode: 'PLGDY',
16
+ }
17
+
18
+ function container(partial: Partial<GctContainer>): GctContainer {
19
+ return { CntrID: 'GCTU1234567', VisitNo: 'V-001', ...partial }
20
+ }
21
+
22
+ describe('directionFromStatus', () => {
23
+ it('maps X* to export and I* to import', () => {
24
+ expect(directionFromStatus('XF')).toBe('export')
25
+ expect(directionFromStatus('XM')).toBe('export')
26
+ expect(directionFromStatus('IF')).toBe('import')
27
+ })
28
+ it('returns null for empty-no-purpose, unknown, and blank', () => {
29
+ expect(directionFromStatus('EM')).toBeNull()
30
+ expect(directionFromStatus('ZZ')).toBeNull()
31
+ expect(directionFromStatus('')).toBeNull()
32
+ expect(directionFromStatus(null)).toBeNull()
33
+ })
34
+ it('does not crash on the undocumented XI status', () => {
35
+ expect(directionFromStatus('XI')).toBe('export')
36
+ })
37
+ })
38
+
39
+ describe('parseGctDateTime', () => {
40
+ it('parses ISO-8601', () => {
41
+ expect(parseGctDateTime('2026-08-10T09:30:00Z')?.toISOString()).toBe('2026-08-10T09:30:00.000Z')
42
+ })
43
+ it('parses the N4-style "yyyy-mm-dd hh:mm" as terminal-local (Europe/Warsaw) → UTC', () => {
44
+ // 09:30 CEST (+02:00) on 2026-08-10 is 07:30 UTC.
45
+ expect(parseGctDateTime('2026-08-10 09:30')?.toISOString()).toBe('2026-08-10T07:30:00.000Z')
46
+ })
47
+ it('returns null for empty/garbage', () => {
48
+ expect(parseGctDateTime(null)).toBeNull()
49
+ expect(parseGctDateTime(' ')).toBeNull()
50
+ expect(parseGctDateTime('not-a-date')).toBeNull()
51
+ })
52
+ })
53
+
54
+ describe('mapContainerToEvents', () => {
55
+ // TC-TRACK-302
56
+ it('emits one gate_in (GTIN) event from GroundingDateTime, keyed on VisitNo', () => {
57
+ const events = mapContainerToEvents(
58
+ container({ GroundingDateTime: '2026-08-10T09:30:00Z', CntrStatus: 'IF', VGMWeight: 24000 }),
59
+ config,
60
+ )
61
+ expect(events).toHaveLength(1)
62
+ const ev = events[0]
63
+ expect(ev.eventType).toBe('EQUIPMENT')
64
+ expect(ev.eventCode).toBe('GTIN')
65
+ expect(ev.eventClassifierCode).toBe('ACT')
66
+ expect(ev.ufvGkey).toBe('V-001')
67
+ expect(ev.sourceEventId).toBe('gct:V-001:GTIN')
68
+ expect(ev.containerNumber).toBe('GCTU1234567')
69
+ expect(ev.unlocode).toBe('PLGDY')
70
+ expect(ev.vgmWeightKg).toBe(24000)
71
+ expect(ev.transitState).toBe('IF')
72
+ expect(ev.eventDateTime.toISOString()).toBe('2026-08-10T09:30:00.000Z')
73
+ })
74
+
75
+ // TC-TRACK-303
76
+ it('adds a departed (DEPA) event when PickupDateTime is present', () => {
77
+ const events = mapContainerToEvents(
78
+ container({ GroundingDateTime: '2026-08-10T09:30:00Z', PickupDateTime: '2026-08-12T14:00:00Z' }),
79
+ config,
80
+ )
81
+ expect(events.map((e) => e.eventCode)).toEqual(['GTIN', 'DEPA'])
82
+ const dep = events.find((e) => e.eventCode === 'DEPA')!
83
+ expect(dep.eventType).toBe('TRANSPORT')
84
+ expect(dep.sourceEventId).toBe('gct:V-001:DEPA')
85
+ expect(dep.eventDateTime.toISOString()).toBe('2026-08-12T14:00:00.000Z')
86
+ })
87
+
88
+ it('emits no events when neither timestamp is present', () => {
89
+ expect(mapContainerToEvents(container({ CntrStatus: 'XF' }), config)).toEqual([])
90
+ })
91
+
92
+ // TC-TRACK-304
93
+ it('passes HoldCodesList through as impediments (dropping blanks)', () => {
94
+ const events = mapContainerToEvents(
95
+ container({ GroundingDateTime: '2026-08-10T09:30:00Z', HoldCodesList: ['CUSTOMS', '', ' ', 'VGM'] }),
96
+ config,
97
+ )
98
+ expect(events[0].impediments).toEqual(['CUSTOMS', 'VGM'])
99
+ })
100
+
101
+ it('maps SealList to seal objects and preserves the full snapshot in rawData', () => {
102
+ const events = mapContainerToEvents(
103
+ container({
104
+ GroundingDateTime: '2026-08-10T09:30:00Z',
105
+ SealList: ['SEAL1'],
106
+ CntrStatus: 'XF',
107
+ VesselName: 'MSC ISABELLA',
108
+ GCTVoyage: 'V123',
109
+ }),
110
+ config,
111
+ )
112
+ const ev = events[0]
113
+ expect(ev.seals).toEqual([{ number: 'SEAL1', source: 'gct' }])
114
+ expect(ev.vesselName).toBe('MSC ISABELLA')
115
+ expect(ev.voyageNumber).toBe('V123')
116
+ expect(ev.modeOfTransport).toBe('VESSEL')
117
+ expect((ev.rawData as Record<string, unknown>).Category).toBe('export')
118
+ expect((ev.rawData as Record<string, unknown>).CntrID).toBe('GCTU1234567')
119
+ })
120
+
121
+ it('falls back to CntrID for the dedup key when VisitNo is missing', () => {
122
+ const events = mapContainerToEvents(
123
+ container({ VisitNo: null, GroundingDateTime: '2026-08-10T09:30:00Z' }),
124
+ config,
125
+ )
126
+ expect(events[0].ufvGkey).toBe('GCTU1234567')
127
+ expect(events[0].sourceEventId).toBe('gct:GCTU1234567:GTIN')
128
+ })
129
+
130
+ it('returns [] for a container with no CntrID', () => {
131
+ expect(mapContainerToEvents({ CntrID: '' } as GctContainer, config)).toEqual([])
132
+ })
133
+ })
@@ -0,0 +1,152 @@
1
+ import type {
2
+ TerminalAdapter,
3
+ TerminalFetchResult,
4
+ TerminalFetchedEvent,
5
+ TerminalAdapterTestResult,
6
+ ResolvedTerminalConfig,
7
+ } from '../../terminal-adapter'
8
+ import type { GctContainer, GctContainerDetailsResponse } from './types'
9
+ import { acquireGctToken, invalidateGctToken, GctAuthError } from './auth/token'
10
+ import { mapContainerToEvents } from './gct-semantics'
11
+ import { n4Request } from '../n4/http'
12
+ import { terminalLogger } from '../../logger'
13
+
14
+ function joinUrl(base: string, path: string): string {
15
+ return `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}`
16
+ }
17
+
18
+ /**
19
+ * Config-driven adapter for the GCT (Gdynia Container Terminal) API. One
20
+ * instance serves every GCT terminal; per-terminal differences live in the
21
+ * TerminalConfig.
22
+ *
23
+ * Differences from the N4 adapter that shape this code:
24
+ * - Auth is a path-minted token replayed in the `Authorization` header
25
+ * (see ./auth/token.ts), not an OAuth2 Bearer grant.
26
+ * - There is no fetch-many-by-container-number endpoint, so `fetchEventsBatch`
27
+ * simply loops `fetchEvents` (bounded upstream by the per-terminal limiter).
28
+ * - There is no vessel endpoint, so `fetchVesselVisit` is intentionally omitted
29
+ * — the service feature-detects it and skips vessel resolution for GCT.
30
+ */
31
+ export class GctTerminalAdapter implements TerminalAdapter {
32
+ readonly adapterType = 'gct'
33
+
34
+ readonly defaultEndpoints = {
35
+ // GCT's container-details endpoint occupies the generic `unit` slot.
36
+ unit: '/gctapi/GetContainerDetails',
37
+ }
38
+
39
+ private detailsUrl(config: ResolvedTerminalConfig): string {
40
+ const path = config.endpoints?.unit?.trim() || this.defaultEndpoints.unit
41
+ return joinUrl(config.baseUrl, path)
42
+ }
43
+
44
+ private async getContainerDetails(
45
+ containerNumber: string,
46
+ config: ResolvedTerminalConfig,
47
+ token: string,
48
+ ): Promise<GctContainer[]> {
49
+ const { status, ok, text } = await n4Request(
50
+ this.detailsUrl(config),
51
+ {
52
+ method: 'POST',
53
+ headers: {
54
+ Authorization: token,
55
+ Accept: 'application/json',
56
+ 'Content-Type': 'application/json',
57
+ },
58
+ body: JSON.stringify({ CntrID: containerNumber, pageno: 1, pagesize: 100 }),
59
+ },
60
+ { proxyUrl: config.proxyUrl, label: `${config.terminalCode} GetContainerDetails` },
61
+ )
62
+
63
+ if (status === 401) {
64
+ invalidateGctToken(config)
65
+ throw new GctAuthError(`GetContainerDetails unauthorized (401) for ${config.terminalCode}`)
66
+ }
67
+ if (!ok) {
68
+ throw new Error(`GetContainerDetails failed (${status}) for ${config.terminalCode}`)
69
+ }
70
+
71
+ let parsed: GctContainerDetailsResponse
72
+ try {
73
+ parsed = JSON.parse(text) as GctContainerDetailsResponse
74
+ } catch {
75
+ throw new Error(`GetContainerDetails response was not valid JSON for ${config.terminalCode}`)
76
+ }
77
+ return Array.isArray(parsed.Containers) ? parsed.Containers : []
78
+ }
79
+
80
+ async fetchEvents(input: {
81
+ containerNumber: string
82
+ config: ResolvedTerminalConfig
83
+ }): Promise<TerminalFetchResult> {
84
+ const { containerNumber, config } = input
85
+ const token = await acquireGctToken(config)
86
+ const containers = await this.getContainerDetails(containerNumber, config, token)
87
+
88
+ const events: TerminalFetchedEvent[] = []
89
+ for (const container of containers) {
90
+ events.push(...mapContainerToEvents(container, config))
91
+ }
92
+
93
+ terminalLogger.debug('gct fetchEvents', {
94
+ terminalCode: config.terminalCode,
95
+ container: containerNumber,
96
+ rows: containers.length,
97
+ events: events.length,
98
+ })
99
+
100
+ return { events, containerNumber }
101
+ }
102
+
103
+ /**
104
+ * GCT has no batch-by-container-number call, so fetch each container in turn.
105
+ * Kept as a distinct method (rather than falling through to per-container in
106
+ * the service) so the scheduler's batch path stays a single call site; the
107
+ * per-terminal rate limiter still throttles the underlying requests. A single
108
+ * container's failure is isolated so one bad number can't sink the whole poll.
109
+ */
110
+ async fetchEventsBatch(input: {
111
+ containerNumbers: string[]
112
+ config: ResolvedTerminalConfig
113
+ }): Promise<{ events: TerminalFetchedEvent[] }> {
114
+ const { containerNumbers, config } = input
115
+ if (containerNumbers.length === 0) return { events: [] }
116
+
117
+ // Acquire once up front so a genuine auth failure fails the whole batch
118
+ // (marking the job failed) instead of being swallowed 100× as per-container
119
+ // data errors below.
120
+ await acquireGctToken(config)
121
+
122
+ const events: TerminalFetchedEvent[] = []
123
+ for (const containerNumber of containerNumbers) {
124
+ try {
125
+ const result = await this.fetchEvents({ containerNumber, config })
126
+ events.push(...result.events)
127
+ } catch (err) {
128
+ // An auth error is fatal to the batch (token revoked/expired mid-run);
129
+ // let it propagate. Data/transport errors are isolated so one bad
130
+ // container can't sink the poll for the rest.
131
+ if (err instanceof GctAuthError) throw err
132
+ terminalLogger.warn('gct fetchEventsBatch: container failed', {
133
+ terminalCode: config.terminalCode,
134
+ container: containerNumber,
135
+ message: err instanceof Error ? err.message : String(err),
136
+ })
137
+ }
138
+ }
139
+ return { events }
140
+ }
141
+
142
+ async testConnection(config: ResolvedTerminalConfig): Promise<TerminalAdapterTestResult> {
143
+ const started = Date.now()
144
+ try {
145
+ await acquireGctToken(config)
146
+ return { success: true, message: 'Token acquired', latencyMs: Date.now() - started }
147
+ } catch (err) {
148
+ const message = err instanceof Error ? err.message : String(err)
149
+ return { success: false, message, latencyMs: Date.now() - started }
150
+ }
151
+ }
152
+ }