@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.
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/dist/generated/entities/terminal_config/index.js +53 -0
- package/dist/generated/entities/terminal_config/index.js.map +7 -0
- package/dist/generated/entities/terminal_event/index.js +59 -0
- package/dist/generated/entities/terminal_event/index.js.map +7 -0
- package/dist/generated/entities/terminal_tracking_job/index.js +37 -0
- package/dist/generated/entities/terminal_tracking_job/index.js.map +7 -0
- package/dist/generated/entities/terminal_vessel_visit/index.js +41 -0
- package/dist/generated/entities/terminal_vessel_visit/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +26 -0
- package/dist/generated/entities.ids.generated.js.map +7 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/terminal_tracking/acl.js +14 -0
- package/dist/modules/terminal_tracking/acl.js.map +7 -0
- package/dist/modules/terminal_tracking/api/openapi.js +27 -0
- package/dist/modules/terminal_tracking/api/openapi.js.map +7 -0
- package/dist/modules/terminal_tracking/api/terminal-configs/route.js +140 -0
- package/dist/modules/terminal_tracking/api/terminal-configs/route.js.map +7 -0
- package/dist/modules/terminal_tracking/api/terminal-configs/test/route.js +58 -0
- package/dist/modules/terminal_tracking/api/terminal-configs/test/route.js.map +7 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/details/route.js +109 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/details/route.js.map +7 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/poll/route.js +58 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/poll/route.js.map +7 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/route.js +189 -0
- package/dist/modules/terminal_tracking/api/tracking-jobs/route.js.map +7 -0
- package/dist/modules/terminal_tracking/api/utils.js +12 -0
- package/dist/modules/terminal_tracking/api/utils.js.map +7 -0
- package/dist/modules/terminal_tracking/api/vessel-visits/route.js +83 -0
- package/dist/modules/terminal_tracking/api/vessel-visits/route.js.map +7 -0
- package/dist/modules/terminal_tracking/backend/terminal-configs/page.js +669 -0
- package/dist/modules/terminal_tracking/backend/terminal-configs/page.js.map +7 -0
- package/dist/modules/terminal_tracking/backend/terminal-configs/page.meta.js +19 -0
- package/dist/modules/terminal_tracking/backend/terminal-configs/page.meta.js.map +7 -0
- package/dist/modules/terminal_tracking/backend/terminal-containers/page.js +275 -0
- package/dist/modules/terminal_tracking/backend/terminal-containers/page.js.map +7 -0
- package/dist/modules/terminal_tracking/backend/terminal-containers/page.meta.js +19 -0
- package/dist/modules/terminal_tracking/backend/terminal-containers/page.meta.js.map +7 -0
- package/dist/modules/terminal_tracking/ce.js +35 -0
- package/dist/modules/terminal_tracking/ce.js.map +7 -0
- package/dist/modules/terminal_tracking/commands/index.js +4 -0
- package/dist/modules/terminal_tracking/commands/index.js.map +7 -0
- package/dist/modules/terminal_tracking/commands/terminal-configs.js +107 -0
- package/dist/modules/terminal_tracking/commands/terminal-configs.js.map +7 -0
- package/dist/modules/terminal_tracking/commands/terminal-tracking.js +27 -0
- package/dist/modules/terminal_tracking/commands/terminal-tracking.js.map +7 -0
- package/dist/modules/terminal_tracking/commands/tracking-jobs.js +56 -0
- package/dist/modules/terminal_tracking/commands/tracking-jobs.js.map +7 -0
- package/dist/modules/terminal_tracking/components/ContainerDetailsDrawer.js +371 -0
- package/dist/modules/terminal_tracking/components/ContainerDetailsDrawer.js.map +7 -0
- package/dist/modules/terminal_tracking/data/entities.js +337 -0
- package/dist/modules/terminal_tracking/data/entities.js.map +7 -0
- package/dist/modules/terminal_tracking/data/validators.js +98 -0
- package/dist/modules/terminal_tracking/data/validators.js.map +7 -0
- package/dist/modules/terminal_tracking/di.js +39 -0
- package/dist/modules/terminal_tracking/di.js.map +7 -0
- package/dist/modules/terminal_tracking/encryption.js +14 -0
- package/dist/modules/terminal_tracking/encryption.js.map +7 -0
- package/dist/modules/terminal_tracking/events.js +45 -0
- package/dist/modules/terminal_tracking/events.js.map +7 -0
- package/dist/modules/terminal_tracking/i18n/de.json +175 -0
- package/dist/modules/terminal_tracking/i18n/en.json +178 -0
- package/dist/modules/terminal_tracking/i18n/es.json +175 -0
- package/dist/modules/terminal_tracking/i18n/pl.json +206 -0
- package/dist/modules/terminal_tracking/index.js +15 -0
- package/dist/modules/terminal_tracking/index.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/adapter.js +164 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/adapter.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/auth/basic.js +23 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/auth/basic.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/incos-semantics.js +146 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/incos-semantics.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/types.js +1 -0
- package/dist/modules/terminal_tracking/lib/adapters/bct/types.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/adapter.js +106 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/adapter.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/auth/token.js +120 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/auth/token.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/gct-semantics.js +77 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/gct-semantics.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/types.js +1 -0
- package/dist/modules/terminal_tracking/lib/adapters/gct/types.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/index.js +12 -0
- package/dist/modules/terminal_tracking/lib/adapters/index.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/adapter.js +121 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/adapter.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/auth/ropc.js +81 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/auth/ropc.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/http.js +66 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/http.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/n4-semantics.js +95 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/n4-semantics.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.js +70 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.js +44 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/proxy.js +15 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/proxy.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/types.js +1 -0
- package/dist/modules/terminal_tracking/lib/adapters/n4/types.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/adapters/zoned-time.js +27 -0
- package/dist/modules/terminal_tracking/lib/adapters/zoned-time.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/availability.js +40 -0
- package/dist/modules/terminal_tracking/lib/availability.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/ensure-schedule.js +51 -0
- package/dist/modules/terminal_tracking/lib/ensure-schedule.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/holds.js +240 -0
- package/dist/modules/terminal_tracking/lib/holds.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/logger.js +19 -0
- package/dist/modules/terminal_tracking/lib/logger.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/rate-limiter.js +30 -0
- package/dist/modules/terminal_tracking/lib/rate-limiter.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/stops.js +48 -0
- package/dist/modules/terminal_tracking/lib/stops.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/terminal-adapter.js +1 -0
- package/dist/modules/terminal_tracking/lib/terminal-adapter.js.map +7 -0
- package/dist/modules/terminal_tracking/lib/totp.js +41 -0
- package/dist/modules/terminal_tracking/lib/totp.js.map +7 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.js +101 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.js.map +7 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.js +39 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.js.map +7 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.js +13 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.js.map +7 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.js +15 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.js.map +7 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.js +13 -0
- package/dist/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.js.map +7 -0
- package/dist/modules/terminal_tracking/services/terminalMatcherService.js +61 -0
- package/dist/modules/terminal_tracking/services/terminalMatcherService.js.map +7 -0
- package/dist/modules/terminal_tracking/services/terminalRegistry.js +24 -0
- package/dist/modules/terminal_tracking/services/terminalRegistry.js.map +7 -0
- package/dist/modules/terminal_tracking/services/terminalTrackingService.js +687 -0
- package/dist/modules/terminal_tracking/services/terminalTrackingService.js.map +7 -0
- package/dist/modules/terminal_tracking/setup.js +23 -0
- package/dist/modules/terminal_tracking/setup.js.map +7 -0
- package/dist/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.js +22 -0
- package/dist/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.js.map +7 -0
- package/generated/entities/terminal_config/index.ts +36 -0
- package/generated/entities/terminal_event/index.ts +39 -0
- package/generated/entities/terminal_tracking_job/index.ts +28 -0
- package/generated/entities/terminal_vessel_visit/index.ts +30 -0
- package/generated/entities.ids.generated.ts +52 -0
- package/package.json +80 -0
- package/src/index.ts +44 -0
- package/src/modules/terminal_tracking/__integration__/TC-TRACK-301-gct-config-test-connection.spec.ts +66 -0
- package/src/modules/terminal_tracking/__integration__/TC-TRACK-317-bct-config-test-connection.spec.ts +68 -0
- package/src/modules/terminal_tracking/__integration__/helpers.ts +148 -0
- package/src/modules/terminal_tracking/__integration__/meta.ts +4 -0
- package/src/modules/terminal_tracking/acl.ts +10 -0
- package/src/modules/terminal_tracking/api/openapi.ts +28 -0
- package/src/modules/terminal_tracking/api/terminal-configs/route.ts +140 -0
- package/src/modules/terminal_tracking/api/terminal-configs/test/route.ts +64 -0
- package/src/modules/terminal_tracking/api/tracking-jobs/details/route.ts +116 -0
- package/src/modules/terminal_tracking/api/tracking-jobs/poll/route.ts +64 -0
- package/src/modules/terminal_tracking/api/tracking-jobs/route.ts +243 -0
- package/src/modules/terminal_tracking/api/utils.ts +10 -0
- package/src/modules/terminal_tracking/api/vessel-visits/route.ts +95 -0
- package/src/modules/terminal_tracking/backend/terminal-configs/page.meta.ts +16 -0
- package/src/modules/terminal_tracking/backend/terminal-configs/page.tsx +728 -0
- package/src/modules/terminal_tracking/backend/terminal-containers/page.meta.ts +16 -0
- package/src/modules/terminal_tracking/backend/terminal-containers/page.tsx +328 -0
- package/src/modules/terminal_tracking/ce.ts +31 -0
- package/src/modules/terminal_tracking/commands/index.ts +3 -0
- package/src/modules/terminal_tracking/commands/terminal-configs.ts +132 -0
- package/src/modules/terminal_tracking/commands/terminal-tracking.ts +34 -0
- package/src/modules/terminal_tracking/commands/tracking-jobs.ts +69 -0
- package/src/modules/terminal_tracking/components/ContainerDetailsDrawer.tsx +540 -0
- package/src/modules/terminal_tracking/data/entities.ts +386 -0
- package/src/modules/terminal_tracking/data/validators.ts +113 -0
- package/src/modules/terminal_tracking/di.ts +46 -0
- package/src/modules/terminal_tracking/encryption.ts +12 -0
- package/src/modules/terminal_tracking/events.ts +48 -0
- package/src/modules/terminal_tracking/i18n/de.json +175 -0
- package/src/modules/terminal_tracking/i18n/en.json +178 -0
- package/src/modules/terminal_tracking/i18n/es.json +175 -0
- package/src/modules/terminal_tracking/i18n/pl.json +206 -0
- package/src/modules/terminal_tracking/index.ts +13 -0
- package/src/modules/terminal_tracking/lib/__tests__/availability.test.ts +136 -0
- package/src/modules/terminal_tracking/lib/__tests__/ensure-schedule.test.ts +67 -0
- package/src/modules/terminal_tracking/lib/__tests__/holds.test.ts +73 -0
- package/src/modules/terminal_tracking/lib/__tests__/stops.test.ts +80 -0
- package/src/modules/terminal_tracking/lib/__tests__/totp.test.ts +38 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/__tests__/adapter.test.ts +196 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/__tests__/incos-semantics.test.ts +303 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/adapter.ts +219 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/auth/basic.ts +34 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/incos-semantics.ts +291 -0
- package/src/modules/terminal_tracking/lib/adapters/bct/types.ts +113 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/__tests__/adapter.test.ts +238 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/__tests__/gct-semantics.test.ts +133 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/adapter.ts +152 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/auth/token.ts +219 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/gct-semantics.ts +136 -0
- package/src/modules/terminal_tracking/lib/adapters/gct/types.ts +58 -0
- package/src/modules/terminal_tracking/lib/adapters/index.ts +16 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/adapter-proxy.test.ts +69 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/data-table.test.ts +179 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/http.test.ts +98 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/n4-semantics.test.ts +140 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/proxy.test.ts +19 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/vessel-data-table.test.ts +67 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/adapter.ts +163 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/auth/ropc.ts +104 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/http.ts +127 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/n4-semantics.ts +156 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.ts +102 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.ts +60 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/proxy.ts +24 -0
- package/src/modules/terminal_tracking/lib/adapters/n4/types.ts +56 -0
- package/src/modules/terminal_tracking/lib/adapters/zoned-time.ts +65 -0
- package/src/modules/terminal_tracking/lib/availability.ts +119 -0
- package/src/modules/terminal_tracking/lib/ensure-schedule.ts +83 -0
- package/src/modules/terminal_tracking/lib/holds.ts +344 -0
- package/src/modules/terminal_tracking/lib/logger.ts +21 -0
- package/src/modules/terminal_tracking/lib/rate-limiter.ts +52 -0
- package/src/modules/terminal_tracking/lib/stops.ts +124 -0
- package/src/modules/terminal_tracking/lib/terminal-adapter.ts +136 -0
- package/src/modules/terminal_tracking/lib/totp.ts +63 -0
- package/src/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.ts +104 -0
- package/src/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.ts +40 -0
- package/src/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.ts +11 -0
- package/src/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.ts +13 -0
- package/src/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.ts +16 -0
- package/src/modules/terminal_tracking/services/__tests__/availabilityEmit.test.ts +277 -0
- package/src/modules/terminal_tracking/services/__tests__/createJob.test.ts +59 -0
- package/src/modules/terminal_tracking/services/__tests__/pollAllBatch.test.ts +147 -0
- package/src/modules/terminal_tracking/services/__tests__/terminalMatcher.test.ts +70 -0
- package/src/modules/terminal_tracking/services/__tests__/vesselResolution.test.ts +453 -0
- package/src/modules/terminal_tracking/services/terminalMatcherService.ts +97 -0
- package/src/modules/terminal_tracking/services/terminalRegistry.ts +30 -0
- package/src/modules/terminal_tracking/services/terminalTrackingService.ts +912 -0
- package/src/modules/terminal_tracking/setup.ts +24 -0
- package/src/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.ts +32 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// `n4Request` drives requests with undici's `fetch` (same instance as the
|
|
4
|
+
// dispatcher), so the mock targets the undici module, not the global fetch.
|
|
5
|
+
const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }))
|
|
6
|
+
vi.mock('undici', async (orig) => {
|
|
7
|
+
const actual = await orig<typeof import('undici')>()
|
|
8
|
+
return { ...actual, fetch: fetchMock }
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
import { n4Request, isTransientNetworkError } from '../http'
|
|
12
|
+
|
|
13
|
+
function socketClose(): TypeError {
|
|
14
|
+
// Mirrors undici's mid-flight peer close: `TypeError: terminated` with an
|
|
15
|
+
// UND_ERR_SOCKET cause — the exact error that crashed the queue worker.
|
|
16
|
+
return Object.assign(new TypeError('terminated'), {
|
|
17
|
+
cause: Object.assign(new Error('other side closed'), { code: 'UND_ERR_SOCKET' }),
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
afterEach(() => fetchMock.mockReset())
|
|
22
|
+
|
|
23
|
+
describe('isTransientNetworkError', () => {
|
|
24
|
+
it('treats undici socket close / terminated as transient', () => {
|
|
25
|
+
expect(isTransientNetworkError(socketClose())).toBe(true)
|
|
26
|
+
expect(isTransientNetworkError(Object.assign(new Error('x'), { code: 'ECONNRESET' }))).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('does not treat a generic error or an HTTP status as transient', () => {
|
|
30
|
+
expect(isTransientNetworkError(new TypeError('bad argument'))).toBe(false)
|
|
31
|
+
expect(isTransientNetworkError(new Error('Unit request failed (403)'))).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('n4Request', () => {
|
|
36
|
+
it('returns status + fully-read body on success', async () => {
|
|
37
|
+
fetchMock.mockResolvedValue(new Response('{"ok":true}', { status: 200 }))
|
|
38
|
+
|
|
39
|
+
const res = await n4Request('https://t/unit', { method: 'GET' }, { retries: 0 })
|
|
40
|
+
expect(res).toEqual({ status: 200, ok: true, text: '{"ok":true}' })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('ALWAYS drains the body, even on a non-ok status (no dangling stream)', async () => {
|
|
44
|
+
// The crash root cause: a non-ok response whose body was never read left a
|
|
45
|
+
// stream undrained on a keep-alive socket. Assert the body is consumed.
|
|
46
|
+
const resp = new Response('403 forbidden', { status: 403 })
|
|
47
|
+
fetchMock.mockResolvedValue(resp)
|
|
48
|
+
|
|
49
|
+
const out = await n4Request('https://t/unit', { method: 'GET' }, { retries: 0 })
|
|
50
|
+
expect(out).toEqual({ status: 403, ok: false, text: '403 forbidden' })
|
|
51
|
+
expect(resp.bodyUsed).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('retries a transient socket close, then succeeds', async () => {
|
|
55
|
+
fetchMock
|
|
56
|
+
.mockRejectedValueOnce(socketClose())
|
|
57
|
+
.mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 }))
|
|
58
|
+
|
|
59
|
+
const res = await n4Request('https://t/unit', { method: 'GET' }, { retries: 2, retryBackoffMs: 0 })
|
|
60
|
+
expect(res.ok).toBe(true)
|
|
61
|
+
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('gives up after exhausting retries and rethrows the transient error', async () => {
|
|
65
|
+
fetchMock.mockRejectedValue(socketClose())
|
|
66
|
+
|
|
67
|
+
await expect(
|
|
68
|
+
n4Request('https://t/unit', { method: 'GET' }, { retries: 2, retryBackoffMs: 0 }),
|
|
69
|
+
).rejects.toThrow(/terminated/)
|
|
70
|
+
expect(fetchMock).toHaveBeenCalledTimes(3) // 1 initial + 2 retries
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('does NOT retry a non-transient error', async () => {
|
|
74
|
+
fetchMock.mockRejectedValue(new TypeError('bad argument'))
|
|
75
|
+
|
|
76
|
+
await expect(
|
|
77
|
+
n4Request('https://t/unit', { method: 'GET' }, { retries: 3, retryBackoffMs: 0 }),
|
|
78
|
+
).rejects.toThrow(/bad argument/)
|
|
79
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('does NOT retry an HTTP error status (a real answer, not a drop)', async () => {
|
|
83
|
+
fetchMock.mockResolvedValue(new Response('nope', { status: 500 }))
|
|
84
|
+
|
|
85
|
+
const res = await n4Request('https://t/unit', { method: 'GET' }, { retries: 3, retryBackoffMs: 0 })
|
|
86
|
+
expect(res.status).toBe(500)
|
|
87
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('passes a proxy dispatcher and an abort signal to fetch', async () => {
|
|
91
|
+
fetchMock.mockResolvedValue(new Response('{}', { status: 200 }))
|
|
92
|
+
|
|
93
|
+
await n4Request('https://t/unit', { method: 'GET' }, { proxyUrl: 'http://127.0.0.1:8888', retries: 0 })
|
|
94
|
+
const init = fetchMock.mock.calls[0]?.[1] as { dispatcher?: unknown; signal?: unknown }
|
|
95
|
+
expect(init?.dispatcher).toBeDefined()
|
|
96
|
+
expect(init?.signal).toBeInstanceOf(AbortSignal)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { mapRowToEvent, buildSourceEventId, parseCargoCategory, parseN4DateTime, pickVesselVisit } from '../n4-semantics'
|
|
2
|
+
import type { N4UnitRow } from '../types'
|
|
3
|
+
import type { ResolvedTerminalConfig } from '../../../terminal-adapter'
|
|
4
|
+
|
|
5
|
+
const config: ResolvedTerminalConfig = {
|
|
6
|
+
terminalCode: 'bct',
|
|
7
|
+
adapterType: 'n4',
|
|
8
|
+
displayName: 'Baltic Hub',
|
|
9
|
+
baseUrl: 'https://api2.baltichub.com/V1/',
|
|
10
|
+
endpoints: { unit: '/unit' },
|
|
11
|
+
authType: 'oauth2_password',
|
|
12
|
+
rateLimitRequests: 200,
|
|
13
|
+
rateLimitWindowSeconds: 60,
|
|
14
|
+
unlocode: 'PLGDN',
|
|
15
|
+
facilityCode: 'GDNBCT',
|
|
16
|
+
facilityCodeListProvider: 'SMDG',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function row(partial: Partial<N4UnitRow>): N4UnitRow {
|
|
20
|
+
return { ufvGkey: '1', unitNbr: 'GCXU5598460', raw: {}, ...partial }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('mapRowToEvent', () => {
|
|
24
|
+
it('maps an inbound export leg to an EQUIPMENT/GTIN actual event', () => {
|
|
25
|
+
const ev = mapRowToEvent(
|
|
26
|
+
row({ ufvGkey: '11412344487', tState: 'Inbound', ibActualVisit: 'GEN_TRUCK', obActualVisit: '26WIKI624', vgmWeight: 28300 }),
|
|
27
|
+
config,
|
|
28
|
+
)
|
|
29
|
+
expect(ev).not.toBeNull()
|
|
30
|
+
expect(ev!.source).toBe('terminal')
|
|
31
|
+
expect(ev!.eventType).toBe('EQUIPMENT')
|
|
32
|
+
expect(ev!.eventCode).toBe('GTIN')
|
|
33
|
+
expect(ev!.eventClassifierCode).toBe('ACT')
|
|
34
|
+
expect(ev!.transitState).toBe('S20_INBOUND')
|
|
35
|
+
expect(ev!.sourceEventId).toBe('bct:11412344487:GTIN')
|
|
36
|
+
expect(ev!.containerNumber).toBe('GCXU5598460')
|
|
37
|
+
expect(ev!.facilityCode).toBe('GDNBCT')
|
|
38
|
+
expect(ev!.facilityCodeListProvider).toBe('SMDG')
|
|
39
|
+
expect(ev!.unlocode).toBe('PLGDN')
|
|
40
|
+
expect(ev!.vgmWeightKg).toBe(28300)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('maps a departed leg to a TRANSPORT/DEPA event dated from Time Out', () => {
|
|
44
|
+
const ev = mapRowToEvent(
|
|
45
|
+
row({ ufvGkey: '11341499051', tState: 'Departed', timeIn: '2026-05-27 23:41', timeOut: '2026-06-03 01:58', loaded: '2026-06-03 01:47' }),
|
|
46
|
+
config,
|
|
47
|
+
)
|
|
48
|
+
expect(ev!.eventType).toBe('TRANSPORT')
|
|
49
|
+
expect(ev!.eventCode).toBe('DEPA')
|
|
50
|
+
expect(ev!.transitState).toBe('S70_DEPARTED')
|
|
51
|
+
expect(ev!.sourceEventId).toBe('bct:11341499051:DEPA')
|
|
52
|
+
// Terminal-local (Europe/Warsaw, CEST +02:00 in June) → UTC.
|
|
53
|
+
expect(ev!.eventDateTime.toISOString()).toBe('2026-06-02T23:58:00.000Z')
|
|
54
|
+
// loadedAt is parsed from `Loaded` even though this row is Departed (not
|
|
55
|
+
// S60_LOADED) — independent of the event timestamp (which came from Time Out).
|
|
56
|
+
expect(ev!.loadedAt?.toISOString()).toBe('2026-06-02T23:47:00.000Z')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('leaves loadedAt null when the row has no Loaded value', () => {
|
|
60
|
+
const ev = mapRowToEvent(row({ tState: 'Inbound', ibActualVisit: 'GEN_TRUCK' }), config)
|
|
61
|
+
expect(ev!.loadedAt).toBeNull()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('returns null for an unmapped transit state', () => {
|
|
65
|
+
expect(mapRowToEvent(row({ tState: 'Advised' }), config)).toBeNull()
|
|
66
|
+
expect(mapRowToEvent(row({ tState: null }), config)).toBeNull()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('infers mode of transport from the visit refs', () => {
|
|
70
|
+
expect(mapRowToEvent(row({ tState: 'Inbound', ibActualVisit: 'GEN_TRUCK', obActualVisit: null }), config)!.modeOfTransport).toBe('TRUCK')
|
|
71
|
+
expect(mapRowToEvent(row({ tState: 'Departed', obActualVisit: 'TUX3W31-25_IMP' }), config)!.modeOfTransport).toBe('RAIL')
|
|
72
|
+
expect(mapRowToEvent(row({ tState: 'Departed', obActualVisit: '25CHAM531' }), config)!.modeOfTransport).toBe('VESSEL')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('produces a stable sourceEventId (idempotent across polls)', () => {
|
|
76
|
+
const r = row({ ufvGkey: '777', tState: 'Loaded' })
|
|
77
|
+
const a = mapRowToEvent(r, config)!
|
|
78
|
+
const b = mapRowToEvent(r, config)!
|
|
79
|
+
expect(a.sourceEventId).toBe(b.sourceEventId)
|
|
80
|
+
expect(a.sourceEventId).toBe('bct:777:LOAD')
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('buildSourceEventId', () => {
|
|
85
|
+
it('joins terminalCode, gkey and eventCode', () => {
|
|
86
|
+
expect(buildSourceEventId('bct', '123', 'GTIN')).toBe('bct:123:GTIN')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('parseCargoCategory', () => {
|
|
91
|
+
it('normalizes the N4 Category column to a direction', () => {
|
|
92
|
+
expect(parseCargoCategory({ Category: 'Import' })).toBe('import')
|
|
93
|
+
expect(parseCargoCategory({ Category: ' export ' })).toBe('export')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('is null for non-directional or missing categories', () => {
|
|
97
|
+
expect(parseCargoCategory({ Category: 'Storage' })).toBeNull() // empties
|
|
98
|
+
expect(parseCargoCategory({})).toBeNull()
|
|
99
|
+
expect(parseCargoCategory(null)).toBeNull()
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('pickVesselVisit', () => {
|
|
104
|
+
it('picks the I/B visit for an import leg', () => {
|
|
105
|
+
expect(
|
|
106
|
+
pickVesselVisit({ visitRefIn: '26JAPA038', visitRefOut: 'POS75955', rawData: { Category: 'Import' } }),
|
|
107
|
+
).toEqual({ ref: '26JAPA038', dir: 'in' })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('picks the O/B visit for an export leg (vessel on the outbound side)', () => {
|
|
111
|
+
expect(
|
|
112
|
+
pickVesselVisit({ visitRefIn: 'GDA56850', visitRefOut: '26OOFX010', rawData: { Category: 'Export' } }),
|
|
113
|
+
).toEqual({ ref: '26OOFX010', dir: 'out' })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('returns null for an unknown category or missing rawData', () => {
|
|
117
|
+
expect(pickVesselVisit({ visitRefIn: '26X', visitRefOut: '26Y', rawData: { Category: 'Storage' } })).toBeNull()
|
|
118
|
+
expect(pickVesselVisit({ visitRefIn: '26X', visitRefOut: '26Y', rawData: null })).toBeNull()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('returns null when the relevant side is missing', () => {
|
|
122
|
+
expect(pickVesselVisit({ visitRefIn: null, visitRefOut: '26Y', rawData: { Category: 'Import' } })).toBeNull()
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('parseN4DateTime', () => {
|
|
127
|
+
it('parses "yyyy-mm-dd hh:mm" as terminal-local (Europe/Warsaw) → UTC', () => {
|
|
128
|
+
// 23:41 CEST (+02:00) on 2026-05-27 is 21:41 UTC.
|
|
129
|
+
expect(parseN4DateTime('2026-05-27 23:41')!.toISOString()).toBe('2026-05-27T21:41:00.000Z')
|
|
130
|
+
})
|
|
131
|
+
it('honours an explicit timezone argument', () => {
|
|
132
|
+
// Same wall-clock, read as UTC when the terminal's zone is UTC.
|
|
133
|
+
expect(parseN4DateTime('2026-05-27 23:41', 'UTC')!.toISOString()).toBe('2026-05-27T23:41:00.000Z')
|
|
134
|
+
})
|
|
135
|
+
it('returns null for empty / invalid input', () => {
|
|
136
|
+
expect(parseN4DateTime(null)).toBeNull()
|
|
137
|
+
expect(parseN4DateTime('')).toBeNull()
|
|
138
|
+
expect(parseN4DateTime('not a date')).toBeNull()
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { proxyDispatcher } from '../proxy'
|
|
3
|
+
|
|
4
|
+
describe('proxyDispatcher', () => {
|
|
5
|
+
it('returns undefined when no proxy is configured', () => {
|
|
6
|
+
expect(proxyDispatcher(undefined)).toBeUndefined()
|
|
7
|
+
expect(proxyDispatcher(null)).toBeUndefined()
|
|
8
|
+
expect(proxyDispatcher('')).toBeUndefined()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('memoises one agent per proxy URL', () => {
|
|
12
|
+
const a = proxyDispatcher('http://127.0.0.1:8888')
|
|
13
|
+
const b = proxyDispatcher('http://127.0.0.1:8888')
|
|
14
|
+
expect(a).toBeDefined()
|
|
15
|
+
expect(a).toBe(b) // same instance reused
|
|
16
|
+
const c = proxyDispatcher('http://127.0.0.1:9999')
|
|
17
|
+
expect(c).not.toBe(a)
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { parseVesselDataTable } from '../parsers/vessel-data-table'
|
|
3
|
+
import type { N4DataTableResponse } from '../parsers/data-table'
|
|
4
|
+
|
|
5
|
+
const COLUMNS = [
|
|
6
|
+
'Visit', 'Vessel Name', 'I/B Vyg', 'O/B Vyg', 'Line', 'Phase',
|
|
7
|
+
'ETA', 'ETD', 'ATA', 'ATD', 'Begin Receive', 'DryCutoff',
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
// Real Baltic Hub /VESSEL response for VISIT_REF=26NUBA625 (single row).
|
|
11
|
+
const REAL_RESPONSE: N4DataTableResponse = {
|
|
12
|
+
'query-response': {
|
|
13
|
+
'data-table': {
|
|
14
|
+
'@count': '1',
|
|
15
|
+
columns: { column: COLUMNS },
|
|
16
|
+
rows: {
|
|
17
|
+
row: {
|
|
18
|
+
'@primary-key': '11000443969',
|
|
19
|
+
field: [
|
|
20
|
+
'26NUBA625', 'MAERSK NUBA', '623N', '625S', 'MAE', 'Inbound',
|
|
21
|
+
'2026-06-14 10:00', '2026-06-15 18:00', null, null,
|
|
22
|
+
'2026-06-07 10:00', null,
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('parseVesselDataTable', () => {
|
|
31
|
+
it('maps the real /VESSEL row by header name', () => {
|
|
32
|
+
const row = parseVesselDataTable(REAL_RESPONSE)
|
|
33
|
+
expect(row).not.toBeNull()
|
|
34
|
+
expect(row).toMatchObject({
|
|
35
|
+
visitRef: '26NUBA625',
|
|
36
|
+
vesselName: 'MAERSK NUBA',
|
|
37
|
+
ibVoyage: '623N',
|
|
38
|
+
obVoyage: '625S',
|
|
39
|
+
line: 'MAE',
|
|
40
|
+
phase: 'Inbound',
|
|
41
|
+
eta: '2026-06-14 10:00',
|
|
42
|
+
etd: '2026-06-15 18:00',
|
|
43
|
+
ata: null,
|
|
44
|
+
atd: null,
|
|
45
|
+
beginReceive: '2026-06-07 10:00',
|
|
46
|
+
dryCutoff: null,
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('preserves the full header->value map in raw', () => {
|
|
51
|
+
const row = parseVesselDataTable(REAL_RESPONSE)!
|
|
52
|
+
expect(row.raw['Line']).toBe('MAE')
|
|
53
|
+
expect(row.raw['Begin Receive']).toBe('2026-06-07 10:00')
|
|
54
|
+
expect(row.raw['DryCutoff']).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('returns null when there are no rows', () => {
|
|
58
|
+
const empty: N4DataTableResponse = {
|
|
59
|
+
'query-response': { 'data-table': { '@count': '0', columns: { column: COLUMNS }, rows: null } },
|
|
60
|
+
}
|
|
61
|
+
expect(parseVesselDataTable(empty)).toBeNull()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('returns null on an empty envelope', () => {
|
|
65
|
+
expect(parseVesselDataTable({} as N4DataTableResponse)).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TerminalAdapter,
|
|
3
|
+
TerminalFetchResult,
|
|
4
|
+
TerminalFetchedEvent,
|
|
5
|
+
TerminalAdapterTestResult,
|
|
6
|
+
ResolvedTerminalConfig,
|
|
7
|
+
NormalizedVesselVisit,
|
|
8
|
+
} from '../../terminal-adapter'
|
|
9
|
+
import { acquireToken, invalidateToken, TerminalAuthError } from './auth/ropc'
|
|
10
|
+
import { parseUnitDataTable, type N4DataTableResponse } from './parsers/data-table'
|
|
11
|
+
import { parseVesselDataTable } from './parsers/vessel-data-table'
|
|
12
|
+
import { mapRowToEvent, parseN4DateTime } from './n4-semantics'
|
|
13
|
+
import { n4Request } from './http'
|
|
14
|
+
import { terminalLogger } from '../../logger'
|
|
15
|
+
|
|
16
|
+
function joinUrl(base: string, path: string): string {
|
|
17
|
+
return `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Parse an N4 JSON body, turning a malformed payload into a clear adapter error. */
|
|
21
|
+
function parseN4Json(text: string, terminalCode: string, what: string): N4DataTableResponse {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(text) as N4DataTableResponse
|
|
24
|
+
} catch {
|
|
25
|
+
throw new Error(`${what} response was not valid JSON for ${terminalCode}`)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Config-driven adapter for Navis N4 terminals. One instance serves all N4
|
|
31
|
+
* terminals; per-terminal differences live in the TerminalConfig.
|
|
32
|
+
*/
|
|
33
|
+
export class N4TerminalAdapter implements TerminalAdapter {
|
|
34
|
+
readonly adapterType = 'n4'
|
|
35
|
+
|
|
36
|
+
readonly defaultEndpoints = {
|
|
37
|
+
unit: '/unit',
|
|
38
|
+
vessel: '/VESSEL',
|
|
39
|
+
trainVisits: '/TRAIN_VISITS',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async getUnitMany(
|
|
43
|
+
containerNumbers: string[],
|
|
44
|
+
config: ResolvedTerminalConfig,
|
|
45
|
+
token: string,
|
|
46
|
+
): Promise<N4DataTableResponse> {
|
|
47
|
+
// N4 /unit accepts a comma-separated UNIT_NBR list; encode each part so the
|
|
48
|
+
// commas stay as the list separator.
|
|
49
|
+
const joined = containerNumbers.map((c) => encodeURIComponent(c)).join(',')
|
|
50
|
+
const url = `${joinUrl(config.baseUrl, config.endpoints.unit)}?UNIT_NBR=${joined}`
|
|
51
|
+
const { status, ok, text } = await n4Request(
|
|
52
|
+
url,
|
|
53
|
+
{ method: 'GET', headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } },
|
|
54
|
+
{ proxyUrl: config.proxyUrl, label: `${config.terminalCode} /unit` },
|
|
55
|
+
)
|
|
56
|
+
if (status === 401) {
|
|
57
|
+
invalidateToken(config)
|
|
58
|
+
throw new TerminalAuthError(`Unit request unauthorized (401) for ${config.terminalCode}`)
|
|
59
|
+
}
|
|
60
|
+
if (!ok) {
|
|
61
|
+
throw new Error(`Unit request failed (${status}) for ${config.terminalCode}`)
|
|
62
|
+
}
|
|
63
|
+
return parseN4Json(text, config.terminalCode, 'Unit')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async getVessel(
|
|
67
|
+
visitRef: string,
|
|
68
|
+
config: ResolvedTerminalConfig,
|
|
69
|
+
token: string,
|
|
70
|
+
): Promise<N4DataTableResponse> {
|
|
71
|
+
const url = `${joinUrl(config.baseUrl, config.endpoints.vessel as string)}?VISIT_REF=${encodeURIComponent(visitRef)}`
|
|
72
|
+
const { status, ok, text } = await n4Request(
|
|
73
|
+
url,
|
|
74
|
+
{ method: 'GET', headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } },
|
|
75
|
+
{ proxyUrl: config.proxyUrl, label: `${config.terminalCode} /VESSEL` },
|
|
76
|
+
)
|
|
77
|
+
if (status === 401) {
|
|
78
|
+
invalidateToken(config)
|
|
79
|
+
throw new TerminalAuthError(`Vessel request unauthorized (401) for ${config.terminalCode}`)
|
|
80
|
+
}
|
|
81
|
+
if (!ok) {
|
|
82
|
+
throw new Error(`Vessel request failed (${status}) for ${config.terminalCode}`)
|
|
83
|
+
}
|
|
84
|
+
return parseN4Json(text, config.terminalCode, 'Vessel')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async fetchVesselVisit(input: {
|
|
88
|
+
visitRef: string
|
|
89
|
+
config: ResolvedTerminalConfig
|
|
90
|
+
}): Promise<NormalizedVesselVisit | null> {
|
|
91
|
+
const { visitRef, config } = input
|
|
92
|
+
if (!config.endpoints.vessel) return null
|
|
93
|
+
|
|
94
|
+
const token = await acquireToken(config)
|
|
95
|
+
const body = await this.getVessel(visitRef, config, token)
|
|
96
|
+
const row = parseVesselDataTable(body)
|
|
97
|
+
if (!row) return null
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
visitRef: row.visitRef,
|
|
101
|
+
vesselName: row.vesselName,
|
|
102
|
+
ibVoyage: row.ibVoyage,
|
|
103
|
+
obVoyage: row.obVoyage,
|
|
104
|
+
line: row.line,
|
|
105
|
+
phase: row.phase,
|
|
106
|
+
eta: parseN4DateTime(row.eta),
|
|
107
|
+
etd: parseN4DateTime(row.etd),
|
|
108
|
+
ata: parseN4DateTime(row.ata),
|
|
109
|
+
atd: parseN4DateTime(row.atd),
|
|
110
|
+
beginReceive: parseN4DateTime(row.beginReceive),
|
|
111
|
+
dryCutoff: parseN4DateTime(row.dryCutoff),
|
|
112
|
+
rawData: row.raw,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async fetchEvents(input: {
|
|
117
|
+
containerNumber: string
|
|
118
|
+
config: ResolvedTerminalConfig
|
|
119
|
+
}): Promise<TerminalFetchResult> {
|
|
120
|
+
const { containerNumber, config } = input
|
|
121
|
+
const { events } = await this.fetchEventsBatch({ containerNumbers: [containerNumber], config })
|
|
122
|
+
return { events, containerNumber }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async fetchEventsBatch(input: {
|
|
126
|
+
containerNumbers: string[]
|
|
127
|
+
config: ResolvedTerminalConfig
|
|
128
|
+
}): Promise<{ events: TerminalFetchedEvent[] }> {
|
|
129
|
+
const { containerNumbers, config } = input
|
|
130
|
+
if (containerNumbers.length === 0) return { events: [] }
|
|
131
|
+
|
|
132
|
+
const token = await acquireToken(config)
|
|
133
|
+
const body = await this.getUnitMany(containerNumbers, config, token)
|
|
134
|
+
const rows = parseUnitDataTable(body)
|
|
135
|
+
|
|
136
|
+
const events: TerminalFetchedEvent[] = []
|
|
137
|
+
for (const row of rows) {
|
|
138
|
+
const event = mapRowToEvent(row, config)
|
|
139
|
+
if (event) events.push(event)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
terminalLogger.debug('fetchEventsBatch', {
|
|
143
|
+
terminalCode: config.terminalCode,
|
|
144
|
+
containers: containerNumbers.length,
|
|
145
|
+
rows: rows.length,
|
|
146
|
+
events: events.length,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
return { events }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async testConnection(config: ResolvedTerminalConfig): Promise<TerminalAdapterTestResult> {
|
|
153
|
+
const started = Date.now()
|
|
154
|
+
try {
|
|
155
|
+
await acquireToken(config)
|
|
156
|
+
return { success: true, message: 'Token acquired', latencyMs: Date.now() - started }
|
|
157
|
+
} catch (err) {
|
|
158
|
+
const code = err instanceof TerminalAuthError ? err.code : undefined
|
|
159
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
160
|
+
return { success: false, message: code ? `${message} (${code})` : message, latencyMs: Date.now() - started }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ResolvedTerminalConfig } from '../../../terminal-adapter'
|
|
2
|
+
import { n4Request } from '../http'
|
|
3
|
+
|
|
4
|
+
type CachedToken = { token: string; expiresAt: number }
|
|
5
|
+
|
|
6
|
+
// Process-level token cache, keyed by terminal + principal.
|
|
7
|
+
const tokenCache = new Map<string, CachedToken>()
|
|
8
|
+
|
|
9
|
+
export class TerminalAuthError extends Error {
|
|
10
|
+
constructor(
|
|
11
|
+
message: string,
|
|
12
|
+
readonly code?: string,
|
|
13
|
+
) {
|
|
14
|
+
super(message)
|
|
15
|
+
this.name = 'TerminalAuthError'
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function cacheKey(config: ResolvedTerminalConfig): string {
|
|
20
|
+
const principal =
|
|
21
|
+
config.authType === 'oauth2_password'
|
|
22
|
+
? String((config.authConfig as Record<string, unknown>)?.username ?? '')
|
|
23
|
+
: String(config.clientId ?? '')
|
|
24
|
+
return `${config.terminalCode}:${principal}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildBody(config: ResolvedTerminalConfig): URLSearchParams {
|
|
28
|
+
const auth = (config.authConfig ?? {}) as Record<string, unknown>
|
|
29
|
+
const body = new URLSearchParams()
|
|
30
|
+
if (config.clientId) body.set('client_id', config.clientId)
|
|
31
|
+
if (config.scope) body.set('scope', config.scope)
|
|
32
|
+
|
|
33
|
+
if (config.authType === 'oauth2_password') {
|
|
34
|
+
body.set('grant_type', 'password')
|
|
35
|
+
body.set('username', String(auth.username ?? ''))
|
|
36
|
+
body.set('password', String(auth.password ?? ''))
|
|
37
|
+
} else {
|
|
38
|
+
body.set('grant_type', 'client_credentials')
|
|
39
|
+
if (auth.clientSecret) body.set('client_secret', String(auth.clientSecret))
|
|
40
|
+
}
|
|
41
|
+
return body
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Extract the `AADB2C…` / OAuth error code from a token error body. */
|
|
45
|
+
function extractErrorCode(text: string): string | undefined {
|
|
46
|
+
const m = text.match(/AADB2C\d+|AADSTS\d+/)
|
|
47
|
+
if (m) return m[0]
|
|
48
|
+
try {
|
|
49
|
+
const j = JSON.parse(text) as { error?: string }
|
|
50
|
+
return j.error
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Acquire (and cache) an OAuth access token for the terminal. Supports
|
|
58
|
+
* Azure B2C ROPC (password grant) and client-credentials. Surfaces the exact
|
|
59
|
+
* `AADB2C…` code on failure (e.g. AADB2C90225 for a not-yet-changed password).
|
|
60
|
+
*/
|
|
61
|
+
export async function acquireToken(config: ResolvedTerminalConfig): Promise<string> {
|
|
62
|
+
if (!config.tokenUrl) throw new TerminalAuthError('Terminal config has no tokenUrl')
|
|
63
|
+
|
|
64
|
+
const key = cacheKey(config)
|
|
65
|
+
const cached = tokenCache.get(key)
|
|
66
|
+
const now = Date.now()
|
|
67
|
+
if (cached && cached.expiresAt > now + 30_000) return cached.token
|
|
68
|
+
|
|
69
|
+
const { status, ok, text } = await n4Request(
|
|
70
|
+
config.tokenUrl,
|
|
71
|
+
{
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
74
|
+
body: buildBody(config).toString(),
|
|
75
|
+
},
|
|
76
|
+
{ proxyUrl: config.proxyUrl, label: `${config.terminalCode} token` },
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if (!ok) {
|
|
80
|
+
throw new TerminalAuthError(
|
|
81
|
+
`Token request failed (${status})${extractErrorCode(text) ? `: ${extractErrorCode(text)}` : ''}`,
|
|
82
|
+
extractErrorCode(text),
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let parsed: { access_token?: string; expires_in?: string | number }
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(text)
|
|
89
|
+
} catch {
|
|
90
|
+
throw new TerminalAuthError('Token response was not JSON')
|
|
91
|
+
}
|
|
92
|
+
if (!parsed.access_token) {
|
|
93
|
+
throw new TerminalAuthError('No access_token in token response', extractErrorCode(text))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ttlSeconds = Number(parsed.expires_in ?? 3600)
|
|
97
|
+
tokenCache.set(key, { token: parsed.access_token, expiresAt: now + ttlSeconds * 1000 })
|
|
98
|
+
return parsed.access_token
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Clear the cached token for a terminal (used after auth failures). */
|
|
102
|
+
export function invalidateToken(config: ResolvedTerminalConfig): void {
|
|
103
|
+
tokenCache.delete(cacheKey(config))
|
|
104
|
+
}
|