Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,8 @@ jobs:
run: npm ci
- name: Build SDK
run: npm run build
- name: Type-check SDK
run: npm run type-check
- name: Generate SDK docs
if: matrix.node-version == 24
run: npm run docs
Expand DownExpand Up@@ -59,6 +61,10 @@ jobs:
run: npm run build --workspace @terminal49/sdk
- name: Build MCP
run: npm run build --workspace @terminal49/mcp
- name: Type-check SDK + MCP
run: |
npm run type-check --workspace @terminal49/sdk
npm run type-check --workspace @terminal49/mcp
- name: Test MCP
run: npm run test --workspace @terminal49/mcp -- --run --coverage
- name: Lint MCP (oxlint + oxfmt)
Expand Down
24 changes: 13 additions & 11 deletions docs/sdk/reference/client/managers/classes/ShipmentManager.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,7 @@ description: "ShipmentManager class in the Terminal49 TypeScript SDK, used to li

| Parameter | Type |
| ------ | ------ |
| `filters` | \{ `carrier?`: `string`; `include?`: [`IncludeParam`](/sdk/reference/types/options/type-aliases/IncludeParam)\<[`ShipmentInclude`](/sdk/reference/types/options/type-aliases/ShipmentInclude)\>; `includeContainers?`: `boolean`; `port?`: `string`; `status?`: `string`; `updatedAfter?`: `string`; \} \| `undefined` |
| `filters` | \{ `carrier?`: `string`; `include?`: [`IncludeParam`](/sdk/reference/types/options/type-aliases/IncludeParam)\<[`ShipmentInclude`](/sdk/reference/types/options/type-aliases/ShipmentInclude)\>; `includeContainers?`: `boolean`; `number?`: `string`; `port?`: `string`; `status?`: `string`; `trackingStopped?`: `boolean`; `updatedAfter?`: `string`; \} \| `undefined` |
| `options?` | `Omit`\<[`ListOptions`](/sdk/reference/types/options/interfaces/ListOptions), `"page"`\> |

#### Returns
Expand All@@ -73,16 +73,18 @@ description: "ShipmentManager class in the Terminal49 TypeScript SDK, used to li

#### Parameters

| Parameter | Type |
| ------ | ------ |
| `filters` | \{ `carrier?`: `string`; `include?`: [`IncludeParam`](/sdk/reference/types/options/type-aliases/IncludeParam)\<[`ShipmentInclude`](/sdk/reference/types/options/type-aliases/ShipmentInclude)\>; `includeContainers?`: `boolean`; `port?`: `string`; `status?`: `string`; `updatedAfter?`: `string`; \} |
| `filters.carrier?` | `string` |
| `filters.include?` | [`IncludeParam`](/sdk/reference/types/options/type-aliases/IncludeParam)\<[`ShipmentInclude`](/sdk/reference/types/options/type-aliases/ShipmentInclude)\> |
| `filters.includeContainers?` | `boolean` |
| `filters.port?` | `string` |
| `filters.status?` | `string` |
| `filters.updatedAfter?` | `string` |
| `options?` | [`ListOptions`](/sdk/reference/types/options/interfaces/ListOptions) |
| Parameter | Type | Description |
| ------ | ------ | ------ |
| `filters` | \{ `carrier?`: `string`; `include?`: [`IncludeParam`](/sdk/reference/types/options/type-aliases/IncludeParam)\<[`ShipmentInclude`](/sdk/reference/types/options/type-aliases/ShipmentInclude)\>; `includeContainers?`: `boolean`; `number?`: `string`; `port?`: `string`; `status?`: `string`; `trackingStopped?`: `boolean`; `updatedAfter?`: `string`; \} | - |
| `filters.carrier?` | `string` | - |
| `filters.include?` | [`IncludeParam`](/sdk/reference/types/options/type-aliases/IncludeParam)\<[`ShipmentInclude`](/sdk/reference/types/options/type-aliases/ShipmentInclude)\> | - |
| `filters.includeContainers?` | `boolean` | - |
| `filters.number?` | `string` | Search shipments by the original tracking `request_number`. |
| `filters.port?` | `string` | - |
| `filters.status?` | `string` | - |
| `filters.trackingStopped?` | `boolean` | Filter shipments by whether they are still tracking. Maps to the supported `filter[tracking_stopped]`. |
| `filters.updatedAfter?` | `string` | - |
| `options?` | [`ListOptions`](/sdk/reference/types/options/interfaces/ListOptions) | - |

#### Returns

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,4 +5,4 @@ description: "ContainerInclude type alias in the Terminal49 TypeScript SDK listi

# Type Alias: ContainerInclude

> **ContainerInclude** = `"shipment"` \| `"pod_terminal"` \| `"destination_terminal"` \| `"transport_events"`
> **ContainerInclude** = `"shipment"` \| `"pod_terminal"` \| `"pickup_facility"` \| `"transport_events"`
193 changes: 193 additions & 0 deletions sdks/typescript-sdk/src/client.filters.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
import { describe, expect, it } from 'vitest';
import { Terminal49Client } from './client.js';
import {
buildContainerListQuery,
buildShipmentListQuery,
clampPageSize,
MAX_PAGE_SIZE,
} from './client/query.js';

const baseUrl = 'https://api.test/v2';

/**
* Permissive fetch that records each call and always returns an empty
* JSON:API document, regardless of query string. Lets us assert on the
* emitted query keys without registering exact URLs.
*/
function createRecordingFetch() {
const calls: URL[] = [];
const fetchImpl = (async (input: Request | URL | string) => {
const urlString =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
calls.push(new URL(urlString));
return new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as unknown as typeof fetch;
return { fetchImpl, calls };
}

function makeClient(fetchImpl: typeof fetch) {
return new Terminal49Client({
apiToken: 'token-123',
apiBaseUrl: baseUrl,
fetchImpl,
});
}

describe('pure list-query builders', () => {
it('drops every unsupported container filter and reports them', () => {
const { query, unsupportedFilters } = buildContainerListQuery({
status: 'in_transit',
port: 'USLAX',
carrier: 'MAEU',
updatedAfter: '2024-01-01',
});

expect(query['filter[status]' as keyof typeof query]).toBeUndefined();
expect(query['filter[pod_locode]' as keyof typeof query]).toBeUndefined();
expect(query['filter[line_scac]' as keyof typeof query]).toBeUndefined();
expect(query['filter[updated_at]' as keyof typeof query]).toBeUndefined();
expect(unsupportedFilters.sort()).toEqual(
['carrier', 'port', 'status', 'updatedAfter'].sort(),
);
});

it('keeps supported container keys (include) and reports nothing unsupported', () => {
const { query, unsupportedFilters } = buildContainerListQuery({
include: 'shipment,pod_terminal',
});
expect(query.include).toBe('shipment,pod_terminal');
expect(unsupportedFilters).toEqual([]);
});

it('drops unsupported shipment filters but keeps supported ones', () => {
const { query, unsupportedFilters } = buildShipmentListQuery({
status: 'in_transit',
port: 'USLAX',
carrier: 'MAEU',
updatedAfter: '2024-01-01',
trackingStopped: true,
number: 'TRK-1',
include: 'containers',
});

// unsupported keys are never emitted
expect(query['filter[status]' as keyof typeof query]).toBeUndefined();
expect(query['filter[pod_locode]' as keyof typeof query]).toBeUndefined();
expect(query['filter[line_scac]' as keyof typeof query]).toBeUndefined();
expect(query['filter[updated_at]' as keyof typeof query]).toBeUndefined();

// supported keys ARE emitted
expect(query['filter[tracking_stopped]']).toBe(true);
expect(query.number).toBe('TRK-1');
expect(query.include).toBe('containers');

expect(unsupportedFilters.sort()).toEqual(
['carrier', 'port', 'status', 'updatedAfter'].sort(),
);
});

it('clamps page size to the API maximum and floors at 1', () => {
expect(clampPageSize(9999)).toBe(MAX_PAGE_SIZE);
expect(clampPageSize(MAX_PAGE_SIZE + 1)).toBe(MAX_PAGE_SIZE);
expect(clampPageSize(0)).toBe(1);
expect(clampPageSize(-5)).toBe(1);
expect(clampPageSize(25)).toBe(25);
expect(clampPageSize(undefined)).toBeUndefined();
});
});

describe('ContainerManager.list filter correctness', () => {
it('does NOT emit filter[*] keys for unsupported status/port/carrier/updatedAfter', async () => {
const { fetchImpl, calls } = createRecordingFetch();
const client = makeClient(fetchImpl);

await client.listContainers({
status: 'in_transit',
port: 'USLAX',
carrier: 'MAEU',
updatedAfter: '2024-01-01',
});

const params = calls[0].searchParams;
expect(params.get('filter[status]')).toBeNull();
expect(params.get('filter[pod_locode]')).toBeNull();
expect(params.get('filter[line_scac]')).toBeNull();
expect(params.get('filter[updated_at]')).toBeNull();
// include (a supported key) is still emitted
expect(params.get('include')).toContain('shipment');
});

it('reports the dropped filters via unsupportedFilters on the mapped result', async () => {
const { fetchImpl } = createRecordingFetch();
const client = makeClient(fetchImpl);

const result = (await client.listContainers(
{ status: 'in_transit', port: 'USLAX' },
{ format: 'mapped' },
)) as { unsupportedFilters?: string[] };

expect(result.unsupportedFilters?.sort()).toEqual(['port', 'status']);
});

it('clamps an over-large page size before sending', async () => {
const { fetchImpl, calls } = createRecordingFetch();
const client = makeClient(fetchImpl);

await client.listContainers({}, { pageSize: 9999 });

expect(calls[0].searchParams.get('page[size]')).toBe(String(MAX_PAGE_SIZE));
});
});

describe('ShipmentManager.list filter correctness', () => {
it('does NOT emit filter[*] keys for unsupported status/port/carrier/updatedAfter', async () => {
const { fetchImpl, calls } = createRecordingFetch();
const client = makeClient(fetchImpl);

await client.listShipments({
status: 'in_transit',
port: 'USLAX',
carrier: 'MAEU',
updatedAfter: '2024-01-01',
});

const params = calls[0].searchParams;
expect(params.get('filter[status]')).toBeNull();
expect(params.get('filter[pod_locode]')).toBeNull();
expect(params.get('filter[line_scac]')).toBeNull();
expect(params.get('filter[updated_at]')).toBeNull();
});

it('emits the supported filter[tracking_stopped] key when requested', async () => {
const { fetchImpl, calls } = createRecordingFetch();
const client = makeClient(fetchImpl);

// trackingStopped is a manager-level filter (the wrapper keeps its
// historical signature), so exercise the manager directly.
await client.shipments.list({ trackingStopped: true });

expect(calls[0].searchParams.get('filter[tracking_stopped]')).toBe('true');
});

it('reports the dropped filters via unsupportedFilters on the mapped result', async () => {
const { fetchImpl } = createRecordingFetch();
const client = makeClient(fetchImpl);

const result = (await client.listShipments(
{ carrier: 'MAEU', updatedAfter: '2024-01-01' },
{ format: 'mapped' },
)) as { unsupportedFilters?: string[] };

expect(result.unsupportedFilters?.sort()).toEqual([
'carrier',
'updatedAfter',
]);
});
});
62 changes: 32 additions & 30 deletions sdks/typescript-sdk/src/client.request.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,16 +78,12 @@ describe('Terminal49Client request building', () => {
expect(result.mapped?.[0]?.scac).toBe('MAEU');
});

it('builds listShipments filters and pagination', async () => {
it('builds listShipments include + pagination and omits unsupported filters', async () => {
const search = buildSearchParams([
[
'include',
'containers,pod_terminal,port_of_lading,port_of_discharge,destination,destination_terminal',
],
['filter[status]', 'in_transit'],
['filter[pod_locode]', 'USLAX'],
['filter[line_scac]', 'MAEU'],
['filter[updated_at]', '2024-01-01'],
['page[number]', '2'],
['page[size]', '50'],
]);
Expand All@@ -102,23 +98,32 @@ describe('Terminal49Client request building', () => {
fetchImpl,
});

await client.listShipments(
const result = await client.listShipments(
{
status: 'in_transit',
port: 'USLAX',
carrier: 'MAEU',
updatedAfter: '2024-01-01',
},
{ page: 2, pageSize: 50 },
{ page: 2, pageSize: 50, format: 'mapped' },
);

const params = calls[0].url.searchParams;
expect(params.get('filter[status]')).toBe('in_transit');
expect(params.get('filter[pod_locode]')).toBe('USLAX');
expect(params.get('filter[line_scac]')).toBe('MAEU');
expect(params.get('filter[updated_at]')).toBe('2024-01-01');
// The v2 API does not support these filter[*] keys, so the SDK omits them
// instead of sending no-op params.
expect(params.get('filter[status]')).toBeNull();
expect(params.get('filter[pod_locode]')).toBeNull();
expect(params.get('filter[line_scac]')).toBeNull();
expect(params.get('filter[updated_at]')).toBeNull();
expect(params.get('page[number]')).toBe('2');
expect(params.get('page[size]')).toBe('50');
// ...and reports them back so callers know they were dropped.
expect(result.unsupportedFilters).toEqual([
'status',
'port',
'carrier',
'updatedAfter',
]);
Comment thread
dodeja marked this conversation as resolved.
});

it('removes containers from include when includeContainers=false', async () => {
Expand DownExpand Up@@ -146,10 +151,7 @@ describe('Terminal49Client request building', () => {
});

it('accepts comma-separated listShipments include strings', async () => {
const search = buildSearchParams([
['include', 'containers,pod_terminal'],
['filter[status]', 'in_transit'],
]);
const search = buildSearchParams([['include', 'containers,pod_terminal']]);

const { fetchImpl, calls } = createMockFetch({
[`/shipments?${search}`]: () => jsonResponse({ data: [] }),
Expand All@@ -166,15 +168,15 @@ describe('Terminal49Client request building', () => {
include: 'containers,pod_terminal',
});

expect(calls[0].url.searchParams.get('include')).toBe(
'containers,pod_terminal',
);
const params = calls[0].url.searchParams;
expect(params.get('include')).toBe('containers,pod_terminal');
// Unsupported filter is dropped rather than forwarded as a no-op.
expect(params.get('filter[status]')).toBeNull();
});

it('builds listContainers filters and pagination with custom include', async () => {
it('builds listContainers include + pagination and omits unsupported filters', async () => {
const search = buildSearchParams([
['include', 'shipment,pod_terminal,transport_events'],
['filter[status]', 'in_transit'],
['page[number]', '3'],
['page[size]', '10'],
]);
Expand All@@ -189,28 +191,27 @@ describe('Terminal49Client request building', () => {
fetchImpl,
});

await client.listContainers(
const result = await client.listContainers(
{
status: 'in_transit',
include: ['shipment', 'pod_terminal', 'transport_events'],
},
{ page: 3, pageSize: 10 },
{ page: 3, pageSize: 10, format: 'mapped' },
);

const params = calls[0].url.searchParams;
expect(params.get('include')).toBe(
'shipment,pod_terminal,transport_events',
);
expect(params.get('filter[status]')).toBe('in_transit');
// `filter[status]` is unsupported on /containers, so it is omitted.
expect(params.get('filter[status]')).toBeNull();
expect(params.get('page[number]')).toBe('3');
expect(params.get('page[size]')).toBe('10');
expect(result.unsupportedFilters).toEqual(['status']);
});

it('accepts comma-separated listContainers include strings', async () => {
const search = buildSearchParams([
['include', 'shipment,pod_terminal'],
['filter[status]', 'available'],
]);
const search = buildSearchParams([['include', 'shipment,pod_terminal']]);

const { fetchImpl, calls } = createMockFetch({
[`/containers?${search}`]: () => jsonResponse({ data: [] }),
Expand All@@ -227,9 +228,10 @@ describe('Terminal49Client request building', () => {
include: 'shipment,pod_terminal',
});

expect(calls[0].url.searchParams.get('include')).toBe(
'shipment,pod_terminal',
);
const params = calls[0].url.searchParams;
expect(params.get('include')).toBe('shipment,pod_terminal');
// Unsupported filter is dropped rather than forwarded as a no-op.
expect(params.get('filter[status]')).toBeNull();
});

it('hits container raw events and refresh endpoints', async () => {
Expand Down
Loading
Loading