initial commit
This commit is contained in:
50
test/alertRules.test.js
Normal file
50
test/alertRules.test.js
Normal 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
201
test/crawlerClient.test.js
Normal 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/
|
||||
);
|
||||
});
|
||||
65
test/dashboardStore.test.js
Normal file
65
test/dashboardStore.test.js
Normal 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
48
test/envLoader.test.js
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
69
test/llmParameterExtractor.test.js
Normal file
69
test/llmParameterExtractor.test.js
Normal 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
|
||||
);
|
||||
});
|
||||
52
test/naturalLanguageFlightParser.test.js
Normal file
52
test/naturalLanguageFlightParser.test.js
Normal 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
97
test/notifier.test.js
Normal 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
134
test/priceWatcher.test.js
Normal 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");
|
||||
});
|
||||
105
test/priceWatcherControls.test.js
Normal file
105
test/priceWatcherControls.test.js
Normal 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);
|
||||
});
|
||||
137
test/skyscannerSampleServer.test.js
Normal file
137
test/skyscannerSampleServer.test.js
Normal 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/);
|
||||
});
|
||||
Reference in New Issue
Block a user