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
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { RelayModule } from './v1/relay/relay.module';
import { GatewayModule } from './v1/gateway/gateway.module';
import { DashboardModule } from './v1/dashboard/dashboard.module';
import { PaymentsModule } from './v1/payments/payments.module';
import { LineModule } from './v1/line/line.module';
import { CropwatchMcpModule } from './v1/mcp/mcp.module';

@Module({
Expand Down Expand Up @@ -56,6 +57,7 @@ import { CropwatchMcpModule } from './v1/mcp/mcp.module';
GatewayModule,
DashboardModule,
PaymentsModule,
LineModule,
CropwatchMcpModule,
],
controllers: [AppController],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,15 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot
},
},
},
"/v1/line/link": {
"delete": {},
},
"/v1/line/link-start": {
"post": {},
},
"/v1/line/webhook": {
"post": {},
},
"/v1/locations": {
"get": {
"parameters": [
Expand Down
41 changes: 41 additions & 0 deletions src/v1/common/v1-route-input-contract.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import { TrafficService } from '../traffic/traffic.service';
import { WaterController } from '../water/water.controller';
import { WaterService } from '../water/water.service';
import { LineController } from '../line/line.controller';
import { LineService } from '../line/line.service';

type MockedMethods = Record<string, jest.Mock>;
type ServiceRegistry = Record<string, MockedMethods>;
Expand Down Expand Up @@ -363,6 +365,12 @@
soil: createMockedMethods(['findOne']),
traffic: createMockedMethods(['findOne']),
water: createMockedMethods(['findOne']),
line: createMockedMethods([
'verifyWebhookSignature',
'handleEvents',
'createLinkNonce',
'unlink',
]),
};

const moduleBuilder = Test.createTestingModule({
Expand All @@ -374,6 +382,7 @@
SoilController,
TrafficController,
WaterController,
LineController,
],
providers: [
{ provide: AirService, useValue: serviceRegistry.air },
Expand All @@ -383,6 +392,7 @@
{ provide: SoilService, useValue: serviceRegistry.soil },
{ provide: TrafficService, useValue: serviceRegistry.traffic },
{ provide: WaterService, useValue: serviceRegistry.water },
{ provide: LineService, useValue: serviceRegistry.line },
],
});

Expand Down Expand Up @@ -866,9 +876,40 @@
name: 'GET /v1/water/:dev_eui preserves start, end, and timezone query inputs',
url: '/v1/water/DEV-001?start=2026-01-01T00:00:00.000Z&end=2026-01-02T00:00:00.000Z&timezone=America%2FDenver',
},
{
auth: true,
expectedCall: {
args: [MOCK_USER.sub],
method: 'createLinkNonce',
service: 'line',
},
expectedStatus: 201,
method: 'post',
name: 'POST /v1/line/link-start mints a nonce for the current user',
url: '/v1/line/link-start',
},
{
auth: true,
expectedCall: {
args: [MOCK_USER.sub],
method: 'unlink',
service: 'line',
},
expectedStatus: 204,
method: 'delete',
name: 'DELETE /v1/line/link unlinks the current user',
url: '/v1/line/link',
},
];

const rejectionCases: RejectionCase[] = [
{
expectedMessage: 'Missing webhook body',
expectedStatus: 400,
method: 'post',
name: 'POST /v1/line/webhook rejects requests without a raw body',
url: '/v1/line/webhook',
},
{
auth: true,
body: {
Expand Down Expand Up @@ -996,7 +1037,7 @@
];

it.each(successCases)('$name', async (testCase) => {
let req = request(app.getHttpServer())[testCase.method](testCase.url);

Check warning on line 1040 in src/v1/common/v1-route-input-contract.spec.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe argument of type `any` assigned to a parameter of type `App`

Check warning on line 1040 in src/v1/common/v1-route-input-contract.spec.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe argument of type `any` assigned to a parameter of type `App`

if (testCase.auth) {
req = req.set('Authorization', AUTH_HEADER);
Expand Down Expand Up @@ -1034,7 +1075,7 @@
});

it.each(rejectionCases)('$name', async (testCase) => {
let req = request(app.getHttpServer())[testCase.method](testCase.url);

Check warning on line 1078 in src/v1/common/v1-route-input-contract.spec.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe argument of type `any` assigned to a parameter of type `App`

Check warning on line 1078 in src/v1/common/v1-route-input-contract.spec.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe argument of type `any` assigned to a parameter of type `App`

if (testCase.auth) {
req = req.set('Authorization', AUTH_HEADER);
Expand Down
131 changes: 131 additions & 0 deletions src/v1/line/line-api.client.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { ConfigService } from '@nestjs/config';
import { LineApiClient, LineApiError } from './line-api.client';

function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}

function createClient(configValues?: Record<string, string>) {
return new LineApiClient({
get: jest.fn(
(key: string) =>
(configValues ?? {
LINE_CHANNEL_ID: 'channel-1',
LINE_CHANNEL_SECRET: 'secret-1',
})[key],
),
} as unknown as ConfigService);
}

function urlOf(input: RequestInfo | URL): string {
if (typeof input === 'string') return input;
return input instanceof URL ? input.href : input.url;
}

describe('LineApiClient', () => {
let fetchMock: jest.SpiedFunction<typeof fetch>;

beforeEach(() => {
fetchMock = jest.spyOn(globalThis, 'fetch');
});

afterEach(() => {
fetchMock.mockRestore();
});

it('issues one stateless token and reuses it across calls', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse(200, { access_token: 'tok-1', expires_in: 900 }),
)
.mockImplementation(() => Promise.resolve(jsonResponse(200, {})));

const client = createClient();
await client.pushMessage('U1', [{ type: 'text', text: 'a' }]);
await client.pushMessage('U1', [{ type: 'text', text: 'b' }]);

const tokenCalls = fetchMock.mock.calls.filter(([url]) =>
urlOf(url).includes('/oauth2/v3/token'),
);
expect(tokenCalls).toHaveLength(1);

const pushCalls = fetchMock.mock.calls.filter(([url]) =>
urlOf(url).includes('/v2/bot/message/push'),
);
expect(pushCalls).toHaveLength(2);
const pushInit = pushCalls[0][1];
expect(pushInit.headers).toMatchObject({
authorization: 'Bearer tok-1',
});
});

it('refreshes the token once it is within the expiry margin', async () => {
fetchMock
.mockResolvedValueOnce(
// expires_in 30s minus the 60s margin → immediately stale.
jsonResponse(200, { access_token: 'tok-old', expires_in: 30 }),
)
.mockResolvedValueOnce(jsonResponse(200, {}))
.mockResolvedValueOnce(
jsonResponse(200, { access_token: 'tok-new', expires_in: 900 }),
)
.mockImplementation(() => Promise.resolve(jsonResponse(200, {})));

const client = createClient();
await client.pushMessage('U1', [{ type: 'text', text: 'a' }]);
await client.pushMessage('U1', [{ type: 'text', text: 'b' }]);

const tokenCalls = fetchMock.mock.calls.filter(([url]) =>
urlOf(url).includes('/oauth2/v3/token'),
);
expect(tokenCalls).toHaveLength(2);
});

it('maps LINE error bodies to LineApiError with status and detail', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse(200, { access_token: 'tok-1', expires_in: 900 }),
)
.mockResolvedValueOnce(
jsonResponse(403, {
message: 'Forbidden',
details: [{ message: 'user blocked the bot' }],
}),
);

const client = createClient();
await expect(
client.pushMessage('U1', [{ type: 'text', text: 'a' }]),
).rejects.toMatchObject({
name: 'LineApiError',
status: 403,
detail: 'Forbidden; user blocked the bot',
});
});

it('throws a configuration error when channel credentials are absent', async () => {
const client = createClient({});
await expect(
client.pushMessage('U1', [{ type: 'text', text: 'a' }]),
).rejects.toBeInstanceOf(LineApiError);
expect(fetchMock).not.toHaveBeenCalled();
});

it('issueLinkToken returns the token from the response body', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse(200, { access_token: 'tok-1', expires_in: 900 }),
)
.mockResolvedValueOnce(jsonResponse(200, { linkToken: 'link-abc' }));

const client = createClient();
await expect(client.issueLinkToken('U1')).resolves.toBe('link-abc');
const linkCall = fetchMock.mock.calls.find(([url]) =>
urlOf(url).includes('/v2/bot/user/U1/linkToken'),
);
expect(linkCall).toBeDefined();
});
});
Loading
Loading