chore: 현재 작업 중간 커밋
This commit is contained in:
52
test/apiAuth.test.js
Normal file
52
test/apiAuth.test.js
Normal 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
404
test/crawlerUrls.test.js
Normal 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");
|
||||
});
|
||||
133
test/dashboardRuntime.test.js
Normal file
133
test/dashboardRuntime.test.js
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
26
test/dashboardUtils.test.js
Normal file
26
test/dashboardUtils.test.js
Normal 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);
|
||||
});
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
|
||||
28
test/pollingConfig.test.js
Normal file
28
test/pollingConfig.test.js
Normal 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"), /정수여야 합니다/);
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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 이상이어야 합니다/
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user