initial commit

This commit is contained in:
chungyeong
2026-02-19 17:28:58 +09:00
commit 02970df6af
34 changed files with 5673 additions and 0 deletions

50
test/alertRules.test.js Normal file
View File

@@ -0,0 +1,50 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildAlertRules,
inferAlertOn,
normalizeAlertOn,
parseTargetPrice,
} = require("../src/alertRules");
test("buildAlertRules builds both mode with integer target", () => {
const result = buildAlertRules({
targetPrice: "1200000",
alertOn: "both",
});
assert.deepEqual(result, {
targetPrice: 1200000,
notifyOnPriceChange: true,
notifyOnFirstResult: false,
});
});
test("buildAlertRules validates threshold mode target", () => {
assert.throws(
() =>
buildAlertRules({
targetPrice: null,
alertOn: "threshold",
}),
(error) => error.message.includes("targetPrice가 필요합니다.")
);
});
test("normalizeAlertOn validates allowed modes", () => {
assert.equal(normalizeAlertOn(" change "), "change");
assert.throws(() => normalizeAlertOn("invalid"), (error) => error.statusCode === 400);
});
test("parseTargetPrice handles undefined and allowUndefined option", () => {
assert.equal(parseTargetPrice(undefined), null);
assert.equal(parseTargetPrice(undefined, { allowUndefined: true }), undefined);
});
test("inferAlertOn reflects rule combinations", () => {
assert.equal(inferAlertOn({ targetPrice: 1000, notifyOnPriceChange: true }), "both");
assert.equal(inferAlertOn({ targetPrice: 1000, notifyOnPriceChange: false }), "threshold");
assert.equal(inferAlertOn({ targetPrice: null, notifyOnPriceChange: true }), "change");
});

201
test/crawlerClient.test.js Normal file
View File

@@ -0,0 +1,201 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createCrawlerClient } = require("../src/crawlerClient");
function createJsonResponse(body, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
async json() {
return body;
},
async text() {
return JSON.stringify(body);
},
};
}
function createTextResponse(text, status = 500) {
return {
ok: false,
status,
async json() {
throw new Error("json not available");
},
async text() {
return text;
},
};
}
test("single endpoint mode still works", async () => {
const crawler = createCrawlerClient({
endpoint: "https://single-endpoint.example",
maxAttempts: 1,
fetch: async () =>
createJsonResponse({
currency: "KRW",
offers: [{ provider: "single", price: 123456 }],
}),
});
const offers = await crawler.getQuotes({
watchId: "watch-1",
searchParams: { segments: [{ from: "ICN", to: "MAD" }] },
});
assert.equal(offers.length, 1);
assert.equal(offers[0].provider, "single");
assert.equal(offers[0].price, 123456);
});
test("endpoint mode retries transient failures and then succeeds", async () => {
let calls = 0;
const crawler = createCrawlerClient({
endpoint: "https://retry-endpoint.example",
maxAttempts: 3,
retryBaseDelayMs: 1,
retryMaxDelayMs: 1,
fetch: async () => {
calls += 1;
if (calls < 3) {
return createTextResponse("upstream failed", 503);
}
return createJsonResponse({
currency: "KRW",
offers: [{ provider: "retry", price: 777000 }],
});
},
});
const offers = await crawler.getQuotes({
watchId: "watch-retry",
searchParams: { segments: [{ from: "ICN", to: "MAD" }] },
});
assert.equal(calls, 3);
assert.equal(offers.length, 1);
assert.equal(offers[0].provider, "retry");
});
test("priorityFallback tries next provider when primary fails", async () => {
const calls = [];
const crawler = createCrawlerClient({
endpoint: null,
providers: ["skyscanner", "naver"],
providerEndpoints: {
skyscanner: "https://skyscanner.example",
naver: "https://naver.example",
},
routingStrategy: "priorityFallback",
maxAttempts: 1,
fetch: async (url) => {
calls.push(url);
if (url.includes("skyscanner")) {
return createTextResponse("upstream failed", 503);
}
return createJsonResponse({
currency: "KRW",
offers: [{ price: 980000 }],
});
},
});
const offers = await crawler.getQuotes({
watchId: "watch-2",
searchParams: { segments: [{ from: "ICN", to: "MAD" }] },
});
assert.equal(calls.length, 2);
assert.equal(offers.length, 1);
assert.equal(offers[0].provider, "naver");
assert.equal(offers[0].price, 980000);
});
test("primaryOnly does not fallback to the next provider", async () => {
const calls = [];
const crawler = createCrawlerClient({
endpoint: null,
providers: ["skyscanner", "naver"],
providerEndpoints: {
skyscanner: "https://skyscanner.example",
naver: "https://naver.example",
},
routingStrategy: "primaryOnly",
maxAttempts: 1,
fetch: async (url) => {
calls.push(url);
if (url.includes("skyscanner")) {
return createTextResponse("timeout", 504);
}
return createJsonResponse({
currency: "KRW",
offers: [{ price: 970000 }],
});
},
});
await assert.rejects(async () => crawler.getQuotes({ watchId: "watch-3", searchParams: {} }), {
message: /Primary provider failed/,
});
assert.equal(calls.length, 1);
});
test("parallelRace returns first successful provider result", async () => {
const crawler = createCrawlerClient({
endpoint: null,
providers: ["skyscanner", "naver"],
providerEndpoints: {
skyscanner: "https://skyscanner.example",
naver: "https://naver.example",
},
routingStrategy: "parallelRace",
maxAttempts: 1,
fetch: async (url) =>
new Promise((resolve) => {
if (url.includes("naver")) {
setTimeout(() => {
resolve(
createJsonResponse({
currency: "KRW",
offers: [{ price: 930000 }],
})
);
}, 5);
return;
}
setTimeout(() => {
resolve(
createJsonResponse({
currency: "KRW",
offers: [{ price: 950000 }],
})
);
}, 40);
}),
});
const offers = await crawler.getQuotes({
watchId: "watch-4",
searchParams: { segments: [{ from: "ICN", to: "MAD" }] },
});
assert.equal(offers.length, 1);
assert.equal(offers[0].provider, "naver");
assert.equal(offers[0].price, 930000);
});
test("throws when multi-source providers are configured without endpoints", () => {
assert.throws(
() =>
createCrawlerClient({
endpoint: null,
providers: ["unconfigured-provider"],
providerEndpoints: {},
}),
/Missing endpoint for provider\(s\): unconfigured-provider/
);
});

View File

@@ -0,0 +1,65 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { InMemoryDashboardStore } = require("../src/dashboardStore");
test("in-memory store persists watch, poll result, events and controls", async () => {
const store = new InMemoryDashboardStore();
await store.init();
await store.saveWatch({
id: "watch-1",
rawInput: "인천->마드리드",
searchParams: { segments: [{ from: "ICN", to: "MAD" }] },
alertRules: { targetPrice: 1300000, notifyOnPriceChange: true },
pollingEnabled: true,
alertsEnabled: true,
createdAt: "2026-02-19T00:00:00.000Z",
updatedAt: "2026-02-19T00:00:00.000Z",
lastSnapshot: null,
lastError: null,
});
await store.savePollResult("watch-1", {
snapshot: {
polledAt: "2026-02-19T00:01:00.000Z",
bestPrice: 1295000,
currency: "KRW",
bestOffer: { provider: "mock" },
offers: [{ provider: "mock", price: 1295000, currency: "KRW" }],
},
});
await store.saveEvent({
watchId: "watch-1",
eventType: "target_price",
observedAt: "2026-02-19T00:01:00.000Z",
payload: {
currentBestPrice: 1295000,
previousBestPrice: 1310000,
currency: "KRW",
},
});
const watches = await store.listWatches();
const events = await store.listEvents(5);
assert.equal(watches.length, 1);
assert.equal(watches[0].lastSnapshot.bestPrice, 1295000);
assert.equal(events.length, 1);
assert.equal(events[0].eventType, "target_price");
const controlsBefore = await store.getGlobalControls();
assert.equal(controlsBefore.crawlingEnabled, true);
assert.equal(controlsBefore.alertsEnabled, true);
await store.setGlobalControls({
crawlingEnabled: false,
alertsEnabled: false,
});
const controlsAfter = await store.getGlobalControls();
assert.equal(controlsAfter.crawlingEnabled, false);
assert.equal(controlsAfter.alertsEnabled, false);
});

48
test/envLoader.test.js Normal file
View File

@@ -0,0 +1,48 @@
"use strict";
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const assert = require("node:assert/strict");
const { loadDotEnv } = require("../src/envLoader");
test("loadDotEnv parses quoted values and keeps existing env", () => {
const suffix = `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const plainKey = `AIRPLANE_TEST_PLAIN_${suffix}`;
const quotedKey = `AIRPLANE_TEST_QUOTED_${suffix}`;
const singleQuotedKey = `AIRPLANE_TEST_SINGLE_${suffix}`;
const existingKey = `AIRPLANE_TEST_EXISTING_${suffix}`;
const invalidKey = "INVALID-NAME";
const envPath = path.join(os.tmpdir(), `airplane_${suffix}.env`);
const envContent = [
`# comment`,
`${plainKey}=hello`,
`${quotedKey}=\"line1\\nline2\"`,
`${singleQuotedKey}='with space'`,
`${existingKey}=from_file`,
`${invalidKey}=ignored`,
``,
].join("\n");
process.env[existingKey] = "from_process";
fs.writeFileSync(envPath, envContent, "utf8");
try {
loadDotEnv(envPath);
assert.equal(process.env[plainKey], "hello");
assert.equal(process.env[quotedKey], "line1\nline2");
assert.equal(process.env[singleQuotedKey], "with space");
assert.equal(process.env[existingKey], "from_process");
} finally {
delete process.env[plainKey];
delete process.env[quotedKey];
delete process.env[singleQuotedKey];
delete process.env[existingKey];
if (fs.existsSync(envPath)) {
fs.unlinkSync(envPath);
}
}
});

View File

@@ -0,0 +1,69 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { extractFlightSearchRequest } = require("../src/llmParameterExtractor");
test("uses LLM output when client is provided", async () => {
const llmClient = async () => ({
departureDateWindow: {
from: "2026-11-21",
to: "2026-12-10",
},
stayDurationDays: {
minDays: 12,
maxDays: 14,
},
segments: [
{ from: "ICN", to: "MAD" },
{ from: "MAD", to: "ICN" },
],
passengers: {
total: 2,
byCabin: {
economy: 0,
premium_economy: 0,
business: 2,
first: 0,
},
},
constraints: {
sameFlightForAllPassengers: true,
itineraryCount: 1,
maxStops: 0,
maxJourneyHours: {
hours: 20,
operator: "<=",
},
},
tripType: "round_trip",
warnings: [],
missingFields: [],
});
const result = await extractFlightSearchRequest("임의 입력", {
now: new Date("2026-02-19T00:00:00Z"),
llmClient,
});
assert.equal(result.source, "llm");
assert.equal(result.params.tripType, "round_trip");
assert.equal(result.params.departureDateWindow.from, "2026-11-21");
assert.equal(result.params.constraints.maxJourneyHours.hours, 20);
assert.deepEqual(result.params.missingFields, []);
});
test("falls back to rule parser when LLM client fails", async () => {
const result = await extractFlightSearchRequest("인천->마드리드 20시간 이하", {
llmClient: async () => {
throw new Error("intentional failure");
},
});
assert.equal(result.source, "rule_parser");
assert.equal(result.params.constraints.maxJourneyHours.hours, 20);
assert.equal(
result.params.warnings.some((warning) => warning.includes("LLM extraction fallback triggered")),
true
);
});

View File

@@ -0,0 +1,52 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { parseFlightSearchRequest } = require("../src/naturalLanguageFlightParser");
test("parse sample Korean request into structured params", () => {
const input =
"11월 말부터 12월 초까지 출발하는 일정 여행 기간은 대략 12~14일, 비즈니스 2개, 프리미엄 이코노미 1개, 동일 항공편 인천 -> 마드리드 인, 바르셀로나 -> 인천 아웃 총 1회 여정 시간은 20시간 미만";
const parsed = parseFlightSearchRequest(input, {
now: new Date("2026-02-19T00:00:00Z"),
});
assert.equal(parsed.departureDateWindow.from, "2026-11-21");
assert.equal(parsed.departureDateWindow.to, "2026-12-10");
assert.deepEqual(parsed.stayDurationDays, { minDays: 12, maxDays: 14 });
assert.equal(parsed.passengers.total, 3);
assert.deepEqual(parsed.passengers.byCabin, {
economy: 0,
premium_economy: 1,
business: 2,
first: 0,
});
assert.deepEqual(parsed.segments, [
{ from: "ICN", to: "MAD" },
{ from: "BCN", to: "ICN" },
]);
assert.equal(parsed.tripType, "open_jaw");
assert.equal(parsed.constraints.sameFlightForAllPassengers, true);
assert.equal(parsed.constraints.itineraryCount, 1);
assert.equal(parsed.constraints.maxStops, null);
assert.deepEqual(parsed.constraints.maxJourneyHours, { hours: 20, operator: "<" });
assert.equal(parsed.warnings.length, 1);
assert.deepEqual(parsed.missingFields, []);
});
test("infer next year for past month range", () => {
const parsed = parseFlightSearchRequest("11월 말부터 12월 초까지 출발", {
now: new Date("2026-12-20T00:00:00Z"),
});
assert.equal(parsed.departureDateWindow.from, "2027-11-21");
assert.equal(parsed.departureDateWindow.to, "2027-12-10");
});
test("parse max stops when explicit layover phrase is present", () => {
const parsed = parseFlightSearchRequest("인천->마드리드 1회 경유 20시간 이하");
assert.equal(parsed.constraints.maxStops, 1);
assert.deepEqual(parsed.constraints.maxJourneyHours, { hours: 20, operator: "<=" });
});

97
test/notifier.test.js Normal file
View File

@@ -0,0 +1,97 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { TelegramNotifier, createNotifier } = require("../src/notifier");
const sampleEvent = {
watchId: "watch-1",
rawInput: "인천->마드리드",
eventType: "target_price",
threshold: 1300000,
previousBestPrice: 1320000,
currentBestPrice: 1295000,
currency: "KRW",
bestOffer: {
provider: "mock-ota-b",
},
observedAt: "2026-02-19T00:00:00.000Z",
};
test("createNotifier selects telegram channel and sends formatted message", async () => {
const requests = [];
const notifier = createNotifier({
channel: "telegram",
telegramBotToken: "123:abc",
telegramChatId: "999",
fetch: async (url, init) => {
requests.push({ url, init });
return {
ok: true,
status: 200,
async text() {
return JSON.stringify({ ok: true });
},
};
},
});
await notifier.notify(sampleEvent);
assert.equal(requests.length, 1);
assert.equal(requests[0].url, "https://api.telegram.org/bot123:abc/sendMessage");
const body = JSON.parse(requests[0].init.body);
assert.equal(body.chat_id, "999");
assert.equal(body.disable_web_page_preview, true);
assert.equal(typeof body.text, "string");
assert.match(body.text, /Current best: 1,295,000 KRW/);
assert.match(body.text, /Target threshold: 1,300,000 KRW/);
});
test("createNotifier supports webhook channel", async () => {
const requests = [];
const notifier = createNotifier({
channel: "webhook",
webhookUrl: "https://example.com/hook",
fetch: async (url, init) => {
requests.push({ url, init });
return {
ok: true,
status: 200,
async text() {
return "";
},
};
},
});
await notifier.notify(sampleEvent);
assert.equal(requests.length, 1);
assert.equal(requests[0].url, "https://example.com/hook");
assert.deepEqual(JSON.parse(requests[0].init.body), sampleEvent);
});
test("createNotifier rejects unsupported channel", () => {
assert.throws(() => createNotifier({ channel: "email" }), /Unsupported NOTIFY_CHANNEL/);
});
test("telegram notifier surfaces API errors", async () => {
const notifier = new TelegramNotifier({
botToken: "token",
chatId: "chat",
fetch: async () => ({
ok: false,
status: 400,
async text() {
return JSON.stringify({ description: "Bad Request: chat not found" });
},
}),
});
await assert.rejects(
async () => notifier.notify(sampleEvent),
/Telegram notification failed \(400\): Bad Request: chat not found/
);
});

134
test/priceWatcher.test.js Normal file
View File

@@ -0,0 +1,134 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { PriceWatcher } = require("../src/priceWatcher");
function createSequenceCrawler(prices) {
let index = 0;
return {
async getQuotes() {
const safeIndex = Math.min(index, prices.length - 1);
const price = prices[safeIndex];
index += 1;
return [
{
provider: "sequence-crawler",
price,
currency: "KRW",
},
];
},
};
}
function createSilentLogger() {
return {
log() {},
error() {},
};
}
test("emits threshold alerts when crossing and improving below threshold", async () => {
const notifications = [];
const watcher = new PriceWatcher({
crawler: createSequenceCrawler([1000, 950, 890, 870]),
notifier: {
async notify(event) {
notifications.push(event);
},
},
logger: createSilentLogger(),
});
const watchId = watcher.addWatch({
rawInput: "인천-마드리드 추적",
searchParams: {
segments: [{ from: "ICN", to: "MAD" }],
},
alertRules: {
targetPrice: 900,
notifyOnPriceChange: false,
},
});
await watcher.pollWatch(watchId);
await watcher.pollWatch(watchId);
await watcher.pollWatch(watchId);
await watcher.pollWatch(watchId);
assert.equal(notifications.length, 2);
assert.equal(notifications[0].eventType, "target_price");
assert.equal(notifications[0].currentBestPrice, 890);
assert.equal(notifications[1].currentBestPrice, 870);
});
test("emits price change alerts when price changes", async () => {
const notifications = [];
const watcher = new PriceWatcher({
crawler: createSequenceCrawler([1000, 980, 980, 950]),
notifier: {
async notify(event) {
notifications.push(event);
},
},
logger: createSilentLogger(),
});
const watchId = watcher.addWatch({
rawInput: "인천-마드리드 추적",
searchParams: {
segments: [{ from: "ICN", to: "MAD" }],
},
alertRules: {
notifyOnPriceChange: true,
targetPrice: null,
},
});
await watcher.pollWatch(watchId);
await watcher.pollWatch(watchId);
await watcher.pollWatch(watchId);
await watcher.pollWatch(watchId);
assert.equal(notifications.length, 2);
assert.equal(notifications[0].eventType, "price_changed");
assert.equal(notifications[0].previousBestPrice, 1000);
assert.equal(notifications[0].currentBestPrice, 980);
assert.equal(notifications[1].previousBestPrice, 980);
assert.equal(notifications[1].currentBestPrice, 950);
});
test("keeps crawl snapshot even when notifier fails", async () => {
const watcher = new PriceWatcher({
crawler: createSequenceCrawler([950000]),
notifier: {
async notify() {
throw new Error("telegram timeout");
},
},
logger: createSilentLogger(),
});
const watchId = watcher.addWatch({
rawInput: "인천-마드리드 추적",
searchParams: {
segments: [{ from: "ICN", to: "MAD" }],
},
alertRules: {
targetPrice: 980000,
notifyOnPriceChange: false,
},
});
const result = await watcher.pollWatch(watchId);
const watch = watcher.getWatch(watchId);
assert.equal(result.notificationSent, false);
assert.equal(result.alert.eventType, "target_price");
assert.equal(result.snapshot.bestPrice, 950000);
assert.equal(result.error.phase, "notify");
assert.equal(watch.lastSnapshot.bestPrice, 950000);
assert.equal(watch.lastError.phase, "notify");
});

View File

@@ -0,0 +1,105 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { PriceWatcher } = require("../src/priceWatcher");
function createSilentLogger() {
return {
log() {},
error() {},
};
}
test("global crawling toggle skips polling", async () => {
let crawlerCalls = 0;
const watcher = new PriceWatcher({
crawler: {
async getQuotes() {
crawlerCalls += 1;
return [{ provider: "x", price: 1000, currency: "KRW" }];
},
},
notifier: {
async notify() {},
},
logger: createSilentLogger(),
});
const watchId = watcher.addWatch({
rawInput: "테스트",
searchParams: { segments: [{ from: "ICN", to: "MAD" }] },
alertRules: { targetPrice: 900, notifyOnPriceChange: true },
});
watcher.setGlobalControls({ crawlingEnabled: false });
const result = await watcher.pollWatch(watchId);
assert.equal(crawlerCalls, 0);
assert.equal(result.skipped.reason, "global_crawling_disabled");
});
test("watch-level polling toggle skips polling", async () => {
let crawlerCalls = 0;
const watcher = new PriceWatcher({
crawler: {
async getQuotes() {
crawlerCalls += 1;
return [{ provider: "x", price: 1000, currency: "KRW" }];
},
},
notifier: {
async notify() {},
},
logger: createSilentLogger(),
});
const watchId = watcher.addWatch({
rawInput: "테스트",
searchParams: { segments: [{ from: "ICN", to: "MAD" }] },
alertRules: { targetPrice: null, notifyOnPriceChange: true },
pollingEnabled: false,
});
const result = await watcher.pollWatch(watchId);
assert.equal(crawlerCalls, 0);
assert.equal(result.skipped.reason, "watch_polling_disabled");
});
test("alerts can be suppressed while still computing alert events", async () => {
const notifications = [];
let call = 0;
const watcher = new PriceWatcher({
crawler: {
async getQuotes() {
call += 1;
if (call === 1) {
return [{ provider: "x", price: 1000, currency: "KRW" }];
}
return [{ provider: "x", price: 900, currency: "KRW" }];
},
},
notifier: {
async notify(event) {
notifications.push(event);
},
},
logger: createSilentLogger(),
});
const watchId = watcher.addWatch({
rawInput: "테스트",
searchParams: { segments: [{ from: "ICN", to: "MAD" }] },
alertRules: { targetPrice: 950, notifyOnPriceChange: true },
alertsEnabled: false,
});
await watcher.pollWatch(watchId);
const second = await watcher.pollWatch(watchId);
assert.equal(notifications.length, 0);
assert.equal(second.alert.eventType, "target_price");
assert.equal(second.alert.notificationSuppressed, true);
});

View File

@@ -0,0 +1,137 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { Readable, Writable } = require("node:stream");
const {
createSkyscannerSampleHandler,
buildSampleOffers,
} = require("../src/skyscannerSampleServer");
function createMockRequest({ method = "POST", url = "/skyscanner", body = "" } = {}) {
const req = new Readable({
read() {
this.push(body);
this.push(null);
},
});
req.method = method;
req.url = url;
return req;
}
function createMockResponse() {
const chunks = [];
let statusCode = 0;
let headers = {};
let resolveDone;
const done = new Promise((resolve) => {
resolveDone = resolve;
});
const res = new Writable({
write(chunk, _encoding, callback) {
chunks.push(Buffer.from(chunk));
callback();
},
});
res.writeHead = (status, responseHeaders) => {
statusCode = status;
headers = responseHeaders || {};
};
res.end = (chunk) => {
if (chunk) chunks.push(Buffer.from(chunk));
resolveDone();
};
return {
res,
done,
getStatusCode() {
return statusCode;
},
getHeaders() {
return headers;
},
getJsonBody() {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
},
};
}
test("buildSampleOffers is deterministic for the same search params", () => {
const searchParams = {
segments: [{ from: "ICN", to: "MAD" }],
passengers: { total: 2 },
};
const first = buildSampleOffers(searchParams);
const second = buildSampleOffers(searchParams);
assert.deepEqual(first, second);
assert.equal(first[0].provider, "skyscanner");
assert.equal(first[0].currency, "KRW");
});
test("sample handler returns skyscanner offers on POST /skyscanner", async () => {
const handler = createSkyscannerSampleHandler();
const req = createMockRequest({
body: JSON.stringify({
watchId: "watch-1",
searchParams: {
segments: [{ from: "ICN", to: "MAD" }],
},
}),
});
const response = createMockResponse();
await handler(req, response.res);
await response.done;
assert.equal(response.getStatusCode(), 200);
assert.equal(response.getHeaders()["content-type"], "application/json; charset=utf-8");
const payload = response.getJsonBody();
assert.equal(payload.currency, "KRW");
assert.ok(Array.isArray(payload.offers));
assert.equal(payload.offers.length, 3);
assert.equal(payload.offers[0].provider, "skyscanner");
});
test("sample handler accepts wrapped request payload shape", async () => {
const handler = createSkyscannerSampleHandler();
const req = createMockRequest({
body: JSON.stringify({
request: {
watchId: "watch-2",
searchParams: {
segments: [{ from: "ICN", to: "BCN" }],
},
},
}),
});
const response = createMockResponse();
await handler(req, response.res);
await response.done;
assert.equal(response.getStatusCode(), 200);
const payload = response.getJsonBody();
assert.equal(payload.offers[0].provider, "skyscanner");
});
test("sample handler returns 404 for unsupported route", async () => {
const handler = createSkyscannerSampleHandler();
const req = createMockRequest({
url: "/not-skyscanner",
body: JSON.stringify({}),
});
const response = createMockResponse();
await handler(req, response.res);
await response.done;
assert.equal(response.getStatusCode(), 404);
assert.match(response.getJsonBody().expected, /POST \/skyscanner/);
});