chore: 현재 작업 중간 커밋

This commit is contained in:
chungyeong
2026-03-05 11:00:45 +09:00
parent 02970df6af
commit be88b4fcec
43 changed files with 6837 additions and 466 deletions

52
test/apiAuth.test.js Normal file
View File

@@ -0,0 +1,52 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { isAuthorizedRequest, resolveApiAuth } = require("../src/apiAuth");
test("resolveApiAuth requires token by default in production", () => {
assert.throws(() => resolveApiAuth({ nodeEnv: "production" }), /DASHBOARD_API_TOKEN/);
});
test("resolveApiAuth is disabled by default in non-production", () => {
const authConfig = resolveApiAuth({ nodeEnv: "development" });
assert.equal(authConfig.enabled, false);
assert.equal(authConfig.token, "");
});
test("isAuthorizedRequest supports bearer and x-api-key headers", () => {
const authConfig = resolveApiAuth({
nodeEnv: "production",
apiToken: "top-secret-token",
});
assert.equal(
isAuthorizedRequest(
{
authorization: "Bearer top-secret-token",
},
authConfig
),
true
);
assert.equal(
isAuthorizedRequest(
{
"x-api-key": "top-secret-token",
},
authConfig
),
true
);
assert.equal(
isAuthorizedRequest(
{
authorization: "Bearer wrong-token",
},
authConfig
),
false
);
});

404
test/crawlerUrls.test.js Normal file
View File

@@ -0,0 +1,404 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
// const { buildNaverUrl } = require("../src/crawlers/naver");
const { buildSkyscannerUrl } = require("../src/crawlers/skyscanner");
const { buildGoogleUrl } = require("../src/crawlers/google");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeParams(overrides = {}) {
return {
tripType: "one_way",
segments: [{ from: "ICN", to: "NRT" }],
passengers: { total: 1, byCabin: {} },
departureDateWindow: { from: "2026-03-15" },
...overrides,
};
}
// ---------------------------------------------------------------------------
// Naver URL tests (provider 일시 제외)
// ---------------------------------------------------------------------------
// test("naver: one-way URL format", () => {
// const url = buildNaverUrl(makeParams());
// assert.match(url, /flight\.naver\.com\/flights\/international\/ICN-NRT-20260315/);
// assert.match(url, /adult=1/);
// assert.match(url, /fareType=Y/);
// assert.ok(!url.includes("/NRT-ICN-"));
// });
// test("naver: round-trip URL has separate outbound and return segments", () => {
// const url = buildNaverUrl(makeParams({ tripType: "round_trip", stayDurationDays: { minDays: 7 } }));
// assert.match(url, /ICN-NRT-20260315/);
// assert.match(url, /\/NRT-ICN-20260322/);
// assert.match(url, /fareType=Y/);
// });
// test("naver: round-trip defaults to 7-day return when stayDurationDays is missing", () => {
// const url = buildNaverUrl(makeParams({ tripType: "round_trip" }));
// assert.match(url, /\/NRT-ICN-20260322/);
// });
// test("naver: multi-city uses /flights/multi and colon separators", () => {
// const url = buildNaverUrl(makeParams({
// tripType: "multi_city",
// segments: [{ from: "ICN", to: "NRT" }, { from: "NRT", to: "ICN" }],
// }));
// assert.match(url, /\/flights\/multi\?/);
// assert.match(url, /ICN:NRT:20260315/);
// assert.match(url, /NRT:ICN:20260318/);
// });
// test("naver: business class maps to fareType=C", () => {
// const url = buildNaverUrl(makeParams({ passengers: { total: 2, byCabin: { business: 2 } } }));
// assert.match(url, /fareType=C/);
// assert.match(url, /adult=2/);
// });
// test("naver: first class maps to fareType=F", () => {
// const url = buildNaverUrl(makeParams({ passengers: { total: 1, byCabin: { first: 1 } } }));
// assert.match(url, /fareType=F/);
// });
// test("naver: empty segments returns base URL", () => {
// const url = buildNaverUrl({ segments: [] });
// assert.equal(url, "https://flight.naver.com");
// });
// ---------------------------------------------------------------------------
// Skyscanner URL tests (/transport/d/ format with YYYY-MM-DD)
// ---------------------------------------------------------------------------
test("skyscanner: one-way uses /transport/d/ with YYYY-MM-DD", () => {
const url = buildSkyscannerUrl(makeParams());
assert.match(url, /\/transport\/d\/icn\/2026-03-15\/nrt\//);
assert.match(url, /adultsv2=1/);
assert.match(url, /cabinclass=economy/);
});
test("skyscanner: round-trip path has outbound and return legs", () => {
const url = buildSkyscannerUrl(
makeParams({
tripType: "round_trip",
stayDurationDays: { minDays: 5 },
})
);
// /icn/2026-03-15/nrt/nrt/2026-03-20/icn/
assert.match(url, /\/icn\/2026-03-15\/nrt\/nrt\/2026-03-20\/icn\//);
});
test("skyscanner: round-trip defaults to 7-day return", () => {
const url = buildSkyscannerUrl(
makeParams({ tripType: "round_trip" })
);
assert.match(url, /\/nrt\/2026-03-22\/icn\//);
});
test("skyscanner: multi-city path-based with multiple legs", () => {
const url = buildSkyscannerUrl(
makeParams({
tripType: "multi_city",
segments: [
{ from: "ICN", to: "MAD" },
{ from: "BCN", to: "ICN" },
],
})
);
// /transport/d/icn/2026-03-15/mad/bcn/2026-03-22/icn/ (default 7-day stay)
assert.match(url, /\/transport\/d\/icn\/2026-03-15\/mad\/bcn\/2026-03-22\/icn\//);
});
test("skyscanner: business cabin class", () => {
const url = buildSkyscannerUrl(
makeParams({ passengers: { total: 1, byCabin: { business: 1 } } })
);
assert.match(url, /cabinclass=business/);
});
test("skyscanner: duration param (total journey minutes)", () => {
const url = buildSkyscannerUrl(
makeParams({ constraints: { maxJourneyHours: { hours: 33.5 } } })
);
assert.match(url, /duration=2010/); // 33.5 * 60
});
test("skyscanner: maxStops=0 → stops=!oneStop,!twoPlusStops (direct only)", () => {
const url = buildSkyscannerUrl(
makeParams({ constraints: { maxStops: 0 } })
);
const stops = new URL(url).searchParams.get("stops");
assert.equal(stops, "!oneStop,!twoPlusStops");
});
test("skyscanner: maxStops=1 → stops=!twoPlusStops (direct + 1 stop)", () => {
const url = buildSkyscannerUrl(
makeParams({ constraints: { maxStops: 1 } })
);
const stops = new URL(url).searchParams.get("stops");
assert.equal(stops, "!twoPlusStops");
});
test("skyscanner: no maxStops → no stops param", () => {
const url = buildSkyscannerUrl(makeParams());
const stops = new URL(url).searchParams.get("stops");
assert.equal(stops, null);
});
test("skyscanner: matches real URL structure (multi-city + business + stops + duration)", () => {
const url = buildSkyscannerUrl({
tripType: "multi_city",
segments: [
{ from: "ICN", to: "MAD" },
{ from: "BCN", to: "ICN" },
],
passengers: { total: 2, byCabin: { business: 2 } },
departureDateWindow: { from: "2026-11-26" },
stayDurationDays: { minDays: 19 },
constraints: { maxStops: 1, maxJourneyHours: { hours: 33.5 } },
});
// Path: return leg uses stayDurationDays.minDays=19 → 2026-11-26 + 19 = 2026-12-15
assert.match(url, /\/transport\/d\/icn\/2026-11-26\/mad\/bcn\/2026-12-15\/icn\//);
// Params
assert.match(url, /adultsv2=2/);
assert.match(url, /cabinclass=business/);
assert.match(url, /duration=2010/);
const stops = new URL(url).searchParams.get("stops");
assert.equal(stops, "!twoPlusStops");
});
test("skyscanner: empty segments returns base URL", () => {
const url = buildSkyscannerUrl({ segments: [] });
assert.equal(url, "https://www.skyscanner.co.kr");
});
// ---------------------------------------------------------------------------
// Google URL tests (protobuf tfs format)
// ---------------------------------------------------------------------------
/**
* Helper: decode URL-safe base64 tfs param and parse protobuf fields.
* Returns a flat-ish structure for easy assertions.
*/
function decodeTfs(url) {
const tfs = new URL(url).searchParams.get("tfs");
if (!tfs) return null;
const std = tfs.replace(/-/g, "+").replace(/_/g, "/");
const buf = Buffer.from(std, "base64");
return parseProtobuf(buf);
}
function readVarint(buf, pos) {
let value = 0n;
let shift = 0n;
while (pos < buf.length) {
const byte = buf[pos++];
value |= BigInt(byte & 0x7f) << shift;
shift += 7n;
if ((byte & 0x80) === 0) break;
}
return { value: Number(value), next: pos };
}
function parseProtobuf(buf) {
const results = [];
let pos = 0;
while (pos < buf.length) {
const tag = readVarint(buf, pos);
pos = tag.next;
const fieldNum = tag.value >> 3;
const wireType = tag.value & 0x7;
if (wireType === 0) {
const val = readVarint(buf, pos);
pos = val.next;
results.push({ f: fieldNum, t: "varint", v: val.value });
} else if (wireType === 2) {
const len = readVarint(buf, pos);
pos = len.next;
const data = buf.slice(pos, pos + len.value);
pos += len.value;
const str = data.toString("utf8");
if (/^[\x20-\x7E]+$/.test(str)) {
results.push({ f: fieldNum, t: "str", v: str });
} else {
try {
const nested = parseProtobuf(data);
results.push({ f: fieldNum, t: "msg", v: nested });
} catch {
results.push({ f: fieldNum, t: "bytes", v: data });
}
}
} else {
break;
}
}
return results;
}
/** Extract all field_3 (segment) messages from decoded tfs */
function getSegments(decoded) {
return decoded.filter((f) => f.f === 3 && f.t === "msg").map((f) => f.v);
}
/** Find a field value in a decoded protobuf array */
function findField(decoded, fieldNum) {
const f = decoded.find((x) => x.f === fieldNum);
return f ? f.v : undefined;
}
test("google: uses protobuf tfs format (not ?q= query)", () => {
const url = buildGoogleUrl(makeParams());
assert.match(url, /\/flights\/search\?tfs=/);
assert.match(url, /&tfu=/);
assert.ok(!url.includes("?q="));
});
test("google: one-way has 1 segment with correct airports and date", () => {
const url = buildGoogleUrl(makeParams());
const decoded = decodeTfs(url);
const segs = getSegments(decoded);
assert.equal(segs.length, 1);
// Date
assert.equal(findField(segs[0], 2), "2026-03-15");
// Origin: field 13 → nested field 2 = "ICN"
const origin = findField(segs[0], 13);
assert.equal(findField(origin, 2), "ICN");
// Dest: field 14 → nested field 2 = "NRT"
const dest = findField(segs[0], 14);
assert.equal(findField(dest, 2), "NRT");
});
test("google: round-trip has 2 segments with reversed airports", () => {
const url = buildGoogleUrl(
makeParams({ tripType: "round_trip", stayDurationDays: { minDays: 7 } })
);
const decoded = decodeTfs(url);
const segs = getSegments(decoded);
assert.equal(segs.length, 2);
// Outbound: ICN → NRT on 2026-03-15
assert.equal(findField(segs[0], 2), "2026-03-15");
assert.equal(findField(findField(segs[0], 13), 2), "ICN");
assert.equal(findField(findField(segs[0], 14), 2), "NRT");
// Return: NRT → ICN on 2026-03-22
assert.equal(findField(segs[1], 2), "2026-03-22");
assert.equal(findField(findField(segs[1], 13), 2), "NRT");
assert.equal(findField(findField(segs[1], 14), 2), "ICN");
});
test("google: round-trip defaults to 7-day return when stayDurationDays is missing", () => {
const url = buildGoogleUrl(makeParams({ tripType: "round_trip" }));
const segs = getSegments(decodeTfs(url));
assert.equal(segs.length, 2);
assert.equal(findField(segs[1], 2), "2026-03-22");
});
test("google: maxJourneyHours encodes as field_12 in minutes", () => {
const url = buildGoogleUrl(
makeParams({ constraints: { maxJourneyHours: { hours: 9 } } })
);
const segs = getSegments(decodeTfs(url));
assert.equal(findField(segs[0], 12), 540); // 9h * 60
});
test("google: maxStops encodes as field_5", () => {
const url = buildGoogleUrl(
makeParams({ constraints: { maxStops: 1 } })
);
const segs = getSegments(decodeTfs(url));
assert.equal(findField(segs[0], 5), 1);
});
test("google: maxStops=0 means direct flights only", () => {
const url = buildGoogleUrl(
makeParams({ constraints: { maxStops: 0 } })
);
const segs = getSegments(decodeTfs(url));
assert.equal(findField(segs[0], 5), 0);
});
test("google: no maxStops omits field_5", () => {
const url = buildGoogleUrl(makeParams());
const segs = getSegments(decodeTfs(url));
assert.equal(findField(segs[0], 5), undefined);
});
test("google: byte-exact match with known duration-filter URL", () => {
const url = buildGoogleUrl({
tripType: "round_trip",
segments: [{ from: "ICN", to: "NRT" }],
passengers: { total: 1, byCabin: {} },
departureDateWindow: { from: "2026-03-15" },
stayDurationDays: { minDays: 7 },
constraints: { maxJourneyHours: { hours: 9 } },
});
const got = new URL(url).searchParams.get("tfs");
const expected =
"CBwQAhohEgoyMDI2LTAzLTE1YJwEagcIARIDSUNOcgcIARIDTlJUGiESCjIwMjYtMDMtMjJgnARqBwgBEgNOUlRyBwgBEgNJQ05AAUgBcAGCAQsI____________AZgBAQ";
assert.equal(got, expected);
});
test("google: byte-exact match with known 1-stop URL", () => {
const url = buildGoogleUrl({
tripType: "round_trip",
segments: [{ from: "ICN", to: "NRT" }],
passengers: { total: 1, byCabin: {} },
departureDateWindow: { from: "2026-03-15" },
stayDurationDays: { minDays: 7 },
constraints: { maxStops: 1, maxJourneyHours: { hours: 9 } },
});
const got = new URL(url).searchParams.get("tfs");
const expected =
"CBwQAhojEgoyMDI2LTAzLTE1KAFgnARqBwgBEgNJQ05yBwgBEgNOUlQaIxIKMjAyNi0wMy0yMigBYJwEagcIARIDTlJUcgcIARIDSUNOQAFIAXABggELCP___________wGYAQE";
assert.equal(got, expected);
});
test("google: field 19 encodes trip type (1=RT, 2=OW, 3=MC)", () => {
const ow = buildGoogleUrl(makeParams({ tripType: "one_way" }));
assert.equal(findField(decodeTfs(ow), 19), 2);
const rt = buildGoogleUrl(makeParams({ tripType: "round_trip" }));
assert.equal(findField(decodeTfs(rt), 19), 1);
const mc = buildGoogleUrl(makeParams({
tripType: "multi_city",
segments: [{ from: "ICN", to: "NRT" }, { from: "NRT", to: "LAX" }],
}));
assert.equal(findField(decodeTfs(mc), 19), 3);
});
test("google: multi-city has correct number of segments", () => {
const url = buildGoogleUrl(
makeParams({
tripType: "multi_city",
segments: [
{ from: "ICN", to: "NRT" },
{ from: "NRT", to: "LAX" },
],
})
);
const decoded = decodeTfs(url);
assert.equal(findField(decoded, 2), 2);
const segs = getSegments(decoded);
assert.equal(segs.length, 2);
assert.equal(findField(findField(segs[1], 13), 2), "NRT");
assert.equal(findField(findField(segs[1], 14), 2), "LAX");
assert.equal(findField(segs[1], 2), "2026-03-22"); // default 7-day stay
});
test("google: business class encodes as cabinClass=3 (field_9)", () => {
const url = buildGoogleUrl(
makeParams({ passengers: { total: 2, byCabin: { business: 2 } } })
);
const decoded = decodeTfs(url);
assert.equal(findField(decoded, 8), 2); // adults
assert.equal(findField(decoded, 9), 3); // business = 3
});
test("google: empty segments returns base URL", () => {
const url = buildGoogleUrl({ segments: [] });
assert.equal(url, "https://www.google.com/travel/flights");
});

View File

@@ -0,0 +1,133 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createDashboardRuntime } = require("../src/dashboardRuntime");
function createStore({ controls }) {
const events = [];
const watches = [
{
id: "watch-1",
rawInput: "인천->도쿄",
searchParams: { segments: [{ from: "ICN", to: "NRT" }], departureDateWindow: { from: "2026-06-01" } },
alertRules: {
targetPrice: null,
notifyOnPriceChange: true,
notifyOnFirstResult: true,
},
pollingEnabled: true,
alertsEnabled: true,
createdAt: "2026-02-19T00:00:00.000Z",
updatedAt: "2026-02-19T00:00:00.000Z",
lastSnapshot: null,
lastError: null,
},
];
return {
events,
async init() {},
async close() {},
async listWatches() {
return watches;
},
async getWatch() {
return null;
},
async saveWatch(watch) {
return watch;
},
async deleteWatch() {
return true;
},
async savePollResult() {},
async saveEvent(event) {
events.push(event);
return event;
},
async listEvents() {
return events;
},
async getGlobalControls() {
return controls;
},
async setGlobalControls(patch = {}) {
controls = { ...controls, ...patch };
return controls;
},
};
}
test("runtime stores failed notification state when notifier throws", async () => {
const store = createStore({
controls: { crawlingEnabled: true, alertsEnabled: true },
});
const crawler = {
async getQuotes() {
return [{ provider: "mock", price: 120000, currency: "KRW" }];
},
};
const notifier = {
async notify() {
throw new Error("network down");
},
};
const runtime = await createDashboardRuntime({
store,
crawler,
notifier,
logger: { error: () => {} },
pollIntervalSec: 3600,
});
try {
assert.equal(store.events.length, 1);
const payload = store.events[0].payload;
assert.equal(payload.notificationState, "failed");
assert.equal(payload.notificationSent, false);
assert.equal(payload.notificationSuppressed, undefined);
assert.equal(payload.notificationError.phase, "notify");
assert.match(payload.notificationError.message, /Notifier failed/);
} finally {
await runtime.close();
}
});
test("runtime stores suppressed notification state when alerts are disabled", async () => {
let notifyCalls = 0;
const store = createStore({
controls: { crawlingEnabled: true, alertsEnabled: false },
});
const crawler = {
async getQuotes() {
return [{ provider: "mock", price: 120000, currency: "KRW" }];
},
};
const notifier = {
async notify() {
notifyCalls += 1;
},
};
const runtime = await createDashboardRuntime({
store,
crawler,
notifier,
logger: { error: () => {} },
pollIntervalSec: 3600,
});
try {
assert.equal(store.events.length, 1);
const payload = store.events[0].payload;
assert.equal(payload.notificationState, "suppressed");
assert.equal(payload.notificationSent, false);
assert.equal(payload.notificationSuppressed, true);
assert.equal(payload.notificationError, null);
assert.equal(notifyCalls, 0);
} finally {
await runtime.close();
}
});

View File

@@ -2,7 +2,38 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { InMemoryDashboardStore } = require("../src/dashboardStore");
const {
InMemoryDashboardStore,
MySqlDashboardStore,
createDashboardStore,
} = require("../src/dashboardStore");
async function withPatchedEnv(patch, fn) {
const backup = {};
for (const [key] of Object.entries(patch)) {
backup[key] = process.env[key];
}
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
try {
return await fn();
} finally {
for (const [key, value] of Object.entries(backup)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
test("in-memory store persists watch, poll result, events and controls", async () => {
const store = new InMemoryDashboardStore();
@@ -63,3 +94,130 @@ test("in-memory store persists watch, poll result, events and controls", async (
assert.equal(controlsAfter.crawlingEnabled, false);
assert.equal(controlsAfter.alertsEnabled, false);
});
test("mysql mode fails closed without fallback when configuration is missing", async () => {
await withPatchedEnv(
{
DASHBOARD_DB: undefined,
MYSQL_URL: undefined,
MYSQL_HOST: undefined,
MYSQL_USER: undefined,
MYSQL_DATABASE: undefined,
DASHBOARD_ALLOW_MEMORY_FALLBACK: undefined,
},
async () => {
await assert.rejects(
() =>
createDashboardStore({
mode: "mysql",
allowMemoryFallback: false,
}),
/MySQL 연결 정보가 필요합니다/
);
}
);
});
test("mysql mode can fallback to memory only when explicitly allowed", async () => {
await withPatchedEnv(
{
DASHBOARD_DB: undefined,
MYSQL_URL: undefined,
MYSQL_HOST: undefined,
MYSQL_USER: undefined,
MYSQL_DATABASE: undefined,
DASHBOARD_ALLOW_MEMORY_FALLBACK: undefined,
},
async () => {
const setup = await createDashboardStore({
mode: "mysql",
allowMemoryFallback: true,
});
assert.equal(setup.engine, "memory");
assert.match(setup.warning, /메모리 저장소/);
await setup.store.close();
}
);
});
test("mysql store init fails when required tables are missing", async () => {
const calls = [];
const store = new MySqlDashboardStore(
{
query: async (sql) => {
calls.push(sql);
return [[{ TABLE_NAME: "projects" }], []];
},
end: async () => {},
}
);
await assert.rejects(
() => store.init(),
/MySQL playground 스키마가 준비되지 않았습니다\..*project_documents.*project_events.*project_settings/
);
assert.equal(calls.length, 1);
});
test("mysql store init succeeds when all required tables exist", async () => {
const calls = [];
const store = new MySqlDashboardStore(
{
query: async (sql, params) => {
calls.push(sql);
if (String(sql).includes("FROM information_schema.TABLES")) {
return [
[
{ TABLE_NAME: "projects" },
{ TABLE_NAME: "project_documents" },
{ TABLE_NAME: "project_events" },
{ TABLE_NAME: "project_settings" },
],
[],
];
}
if (String(sql).includes("INSERT INTO projects")) {
assert.equal(params[0], "air-watcher");
return [{ affectedRows: 1 }, []];
}
throw new Error(`unexpected query: ${sql}`);
},
end: async () => {},
}
);
await assert.doesNotReject(() => store.init());
assert.equal(store.schemaMode, "playground");
assert.equal(calls.length, 2);
});
test("mysql store supports custom projectKey", async () => {
let insertedProjectKey = null;
const store = new MySqlDashboardStore({
query: async (sql, params) => {
if (String(sql).includes("FROM information_schema.TABLES")) {
return [
[
{ TABLE_NAME: "projects" },
{ TABLE_NAME: "project_documents" },
{ TABLE_NAME: "project_events" },
{ TABLE_NAME: "project_settings" },
],
[],
];
}
if (String(sql).includes("INSERT INTO projects")) {
insertedProjectKey = params[0];
return [{ affectedRows: 1 }, []];
}
throw new Error(`unexpected query: ${sql}`);
},
end: async () => {},
}, {
projectKey: "mini-app-a",
});
await store.init();
assert.equal(insertedProjectKey, "mini-app-a");
});

View File

@@ -0,0 +1,26 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createHttpError, toPublicErrorResponse } = require("../src/dashboardUtils");
test("toPublicErrorResponse keeps 4xx error details", () => {
const failure = toPublicErrorResponse(createHttpError(400, "잘못된 요청"));
assert.equal(failure.statusCode, 400);
assert.equal(failure.body.error, "잘못된 요청");
});
test("toPublicErrorResponse masks 5xx errors", () => {
const loggerCalls = [];
const failure = toPublicErrorResponse(new Error("db password exposed"), {
logger: {
error(message) {
loggerCalls.push(message);
},
},
});
assert.equal(failure.statusCode, 500);
assert.equal(failure.body.error, "Internal Server Error");
assert.equal(loggerCalls.length, 1);
});

View File

@@ -2,7 +2,10 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { extractFlightSearchRequest } = require("../src/llmParameterExtractor");
const {
createOpenAIClient,
extractFlightSearchRequest,
} = require("../src/llmParameterExtractor");
test("uses LLM output when client is provided", async () => {
const llmClient = async () => ({
@@ -67,3 +70,102 @@ test("falls back to rule parser when LLM client fails", async () => {
true
);
});
test("corrects LLM round_trip to multi_city when segments have different cities", async () => {
// LLM incorrectly says round_trip for ICN→MAD / BCN→ICN (MAD ≠ BCN)
const llmClient = async () => ({
departureDateWindow: { from: "2026-11-25", to: "2026-11-25" },
stayDurationDays: { minDays: 12, maxDays: 12 },
segments: [
{ from: "ICN", to: "MAD" },
{ from: "BCN", to: "ICN" },
],
passengers: { total: 3, byCabin: { economy: 1, premium_economy: 0, business: 2, first: 0 } },
constraints: { sameFlightForAllPassengers: true, itineraryCount: null, maxStops: 1, maxJourneyHours: { hours: 42, operator: "<" } },
tripType: "round_trip",
warnings: [],
missingFields: [],
});
const result = await extractFlightSearchRequest("인천-마드리드, 바르셀로나-인천", {
now: new Date("2026-02-20T00:00:00Z"),
llmClient,
});
assert.equal(result.source, "llm");
// Must be corrected to multi_city, not round_trip
assert.equal(result.params.tripType, "multi_city");
assert.equal(result.params.segments.length, 2);
assert.equal(result.params.segments[0].from, "ICN");
assert.equal(result.params.segments[1].from, "BCN");
});
test("keeps round_trip when segments are genuinely reversed (A→B / B→A)", async () => {
const llmClient = async () => ({
departureDateWindow: { from: "2026-06-01", to: "2026-06-01" },
stayDurationDays: { minDays: 7, maxDays: 7 },
segments: [
{ from: "ICN", to: "NRT" },
{ from: "NRT", to: "ICN" },
],
passengers: { total: 1, byCabin: { economy: 1, premium_economy: 0, business: 0, first: 0 } },
constraints: { sameFlightForAllPassengers: true, itineraryCount: null, maxStops: null, maxJourneyHours: null },
tripType: "round_trip",
warnings: [],
missingFields: [],
});
const result = await extractFlightSearchRequest("인천-도쿄 왕복", {
now: new Date("2026-02-20T00:00:00Z"),
llmClient,
});
assert.equal(result.params.tripType, "round_trip");
});
test("corrects open_jaw to multi_city", async () => {
const llmClient = async () => ({
departureDateWindow: { from: "2026-06-01", to: "2026-06-01" },
stayDurationDays: { minDays: 10, maxDays: 10 },
segments: [
{ from: "ICN", to: "MAD" },
{ from: "BCN", to: "ICN" },
],
passengers: { total: 1, byCabin: { economy: 1, premium_economy: 0, business: 0, first: 0 } },
constraints: { sameFlightForAllPassengers: true, itineraryCount: null, maxStops: null, maxJourneyHours: null },
tripType: "open_jaw",
warnings: [],
missingFields: [],
});
const result = await extractFlightSearchRequest("인천-마드리드, 바르셀로나-인천", {
now: new Date("2026-02-20T00:00:00Z"),
llmClient,
});
assert.equal(result.params.tripType, "multi_city");
});
test("createOpenAIClient aborts timed out requests", async () => {
const llmClient = createOpenAIClient({
apiKey: "dummy-key",
timeoutMs: 10,
fetch: async (_url, options) =>
new Promise((_resolve, reject) => {
options.signal.addEventListener("abort", () => {
const abortError = new Error("aborted");
abortError.name = "AbortError";
reject(abortError);
});
}),
});
await assert.rejects(
() =>
llmClient({
input: "임의 입력",
now: new Date("2026-02-19T00:00:00Z"),
}),
/timed out/
);
});

View File

@@ -0,0 +1,28 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
MIN_CRAWL_INTERVAL_MS,
MIN_CRAWL_INTERVAL_SEC,
normalizeCrawlIntervalMs,
normalizeCrawlIntervalSec,
} = require("../src/pollingConfig");
test("normalizeCrawlIntervalSec enforces minimum 1 hour", () => {
assert.equal(normalizeCrawlIntervalSec(undefined), MIN_CRAWL_INTERVAL_SEC);
assert.equal(normalizeCrawlIntervalSec(3600), 3600);
assert.equal(normalizeCrawlIntervalSec(7200), 7200);
assert.throws(() => normalizeCrawlIntervalSec(60), /3600 이상이어야 합니다/);
assert.throws(() => normalizeCrawlIntervalSec(3599), /3600 이상이어야 합니다/);
assert.throws(() => normalizeCrawlIntervalSec("abc"), /정수여야 합니다/);
});
test("normalizeCrawlIntervalMs enforces minimum 1 hour", () => {
assert.equal(normalizeCrawlIntervalMs(undefined), MIN_CRAWL_INTERVAL_MS);
assert.equal(normalizeCrawlIntervalMs(3600000), 3600000);
assert.equal(normalizeCrawlIntervalMs(7200000), 7200000);
assert.throws(() => normalizeCrawlIntervalMs(60000), /3600000 이상이어야 합니다/);
assert.throws(() => normalizeCrawlIntervalMs(3599999), /3600000 이상이어야 합니다/);
assert.throws(() => normalizeCrawlIntervalMs("bad"), /정수여야 합니다/);
});

View File

@@ -45,6 +45,7 @@ test("emits threshold alerts when crossing and improving below threshold", async
rawInput: "인천-마드리드 추적",
searchParams: {
segments: [{ from: "ICN", to: "MAD" }],
departureDateWindow: { from: "2026-06-01" },
},
alertRules: {
targetPrice: 900,
@@ -79,6 +80,7 @@ test("emits price change alerts when price changes", async () => {
rawInput: "인천-마드리드 추적",
searchParams: {
segments: [{ from: "ICN", to: "MAD" }],
departureDateWindow: { from: "2026-06-01" },
},
alertRules: {
notifyOnPriceChange: true,
@@ -114,6 +116,7 @@ test("keeps crawl snapshot even when notifier fails", async () => {
rawInput: "인천-마드리드 추적",
searchParams: {
segments: [{ from: "ICN", to: "MAD" }],
departureDateWindow: { from: "2026-06-01" },
},
alertRules: {
targetPrice: 980000,
@@ -132,3 +135,53 @@ test("keeps crawl snapshot even when notifier fails", async () => {
assert.equal(watch.lastSnapshot.bestPrice, 950000);
assert.equal(watch.lastError.phase, "notify");
});
test("pollAll skips when another poll cycle is already in progress", async () => {
let release = null;
let started = null;
const startedPromise = new Promise((resolve) => {
started = resolve;
});
const watcher = new PriceWatcher({
crawler: {
async getQuotes() {
started();
await new Promise((resolve) => {
release = resolve;
});
return [
{
provider: "slow-crawler",
price: 999000,
currency: "KRW",
},
];
},
},
notifier: {
async notify() {},
},
logger: createSilentLogger(),
});
watcher.addWatch({
rawInput: "인천-마드리드 추적",
searchParams: { segments: [{ from: "ICN", to: "MAD" }], departureDateWindow: { from: "2026-06-01" } },
alertRules: {
notifyOnPriceChange: true,
targetPrice: null,
},
});
const firstCycle = watcher.pollAll();
await startedPromise;
const skipped = await watcher.pollAll();
assert.equal(skipped.length, 1);
assert.equal(skipped[0].skipped.reason, "poll_cycle_in_progress");
release();
await firstCycle;
});

View File

@@ -28,7 +28,7 @@ test("global crawling toggle skips polling", async () => {
const watchId = watcher.addWatch({
rawInput: "테스트",
searchParams: { segments: [{ from: "ICN", to: "MAD" }] },
searchParams: { segments: [{ from: "ICN", to: "MAD" }], departureDateWindow: { from: "2026-06-01" } },
alertRules: { targetPrice: 900, notifyOnPriceChange: true },
});
@@ -57,7 +57,7 @@ test("watch-level polling toggle skips polling", async () => {
const watchId = watcher.addWatch({
rawInput: "테스트",
searchParams: { segments: [{ from: "ICN", to: "MAD" }] },
searchParams: { segments: [{ from: "ICN", to: "MAD" }], departureDateWindow: { from: "2026-06-01" } },
alertRules: { targetPrice: null, notifyOnPriceChange: true },
pollingEnabled: false,
});
@@ -91,7 +91,7 @@ test("alerts can be suppressed while still computing alert events", async () =>
const watchId = watcher.addWatch({
rawInput: "테스트",
searchParams: { segments: [{ from: "ICN", to: "MAD" }] },
searchParams: { segments: [{ from: "ICN", to: "MAD" }], departureDateWindow: { from: "2026-06-01" } },
alertRules: { targetPrice: 950, notifyOnPriceChange: true },
alertsEnabled: false,
});
@@ -103,3 +103,22 @@ test("alerts can be suppressed while still computing alert events", async () =>
assert.equal(second.alert.eventType, "target_price");
assert.equal(second.alert.notificationSuppressed, true);
});
test("poll interval under 1 hour throws immediately", () => {
assert.throws(
() =>
new PriceWatcher({
crawler: {
async getQuotes() {
return [{ provider: "x", price: 1000, currency: "KRW" }];
},
},
notifier: {
async notify() {},
},
pollIntervalMs: 1000,
logger: createSilentLogger(),
}),
/3600000 이상이어야 합니다/
);
});