feat(flight-finder): implement milestone M2 - report workflow and delivery gates
This commit is contained in:
38
skills/flight-finder/tests/price-normalization.test.ts
Normal file
38
skills/flight-finder/tests/price-normalization.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { normalizePriceToUsd } from "../src/price-normalization.js";
|
||||
|
||||
test("normalizePriceToUsd passes through USD values", () => {
|
||||
const result = normalizePriceToUsd({
|
||||
amount: 2847,
|
||||
currency: "USD"
|
||||
});
|
||||
|
||||
assert.equal(result.amountUsd, 2847);
|
||||
assert.equal(result.currency, "USD");
|
||||
});
|
||||
|
||||
test("normalizePriceToUsd converts foreign currencies when a rate is supplied", () => {
|
||||
const result = normalizePriceToUsd({
|
||||
amount: 100,
|
||||
currency: "EUR",
|
||||
exchangeRates: {
|
||||
EUR: 1.08
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.amountUsd, 108);
|
||||
assert.match(result.notes.join(" "), /EUR/i);
|
||||
});
|
||||
|
||||
test("normalizePriceToUsd rejects missing exchange rates", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePriceToUsd({
|
||||
amount: 100,
|
||||
currency: "EUR"
|
||||
}),
|
||||
/exchange rate/i
|
||||
);
|
||||
});
|
||||
44
skills/flight-finder/tests/report-delivery.test.ts
Normal file
44
skills/flight-finder/tests/report-delivery.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildLukeDeliveryPlan } from "../src/report-delivery.js";
|
||||
|
||||
test("buildLukeDeliveryPlan requires a plausible recipient email", () => {
|
||||
const plan = buildLukeDeliveryPlan({
|
||||
recipientEmail: "invalid-email",
|
||||
subject: "DFW ↔ BLQ flight report",
|
||||
body: "Summary",
|
||||
attachmentPath: "/tmp/report.pdf"
|
||||
});
|
||||
|
||||
assert.equal(plan.ready, false);
|
||||
assert.equal(plan.needsRecipientEmail, true);
|
||||
assert.equal(plan.command, null);
|
||||
});
|
||||
|
||||
test("buildLukeDeliveryPlan uses Luke's wrapper path when recipient is valid", () => {
|
||||
const plan = buildLukeDeliveryPlan({
|
||||
recipientEmail: "stefano@fiorinis.com",
|
||||
subject: "DFW ↔ BLQ flight report",
|
||||
body: "Summary",
|
||||
attachmentPath: "/tmp/report.pdf"
|
||||
});
|
||||
|
||||
assert.equal(plan.ready, true);
|
||||
assert.match(String(plan.command), /gog-luke gmail send/);
|
||||
assert.match(String(plan.command), /stefano@fiorinis.com/);
|
||||
assert.match(String(plan.command), /report\.pdf/);
|
||||
});
|
||||
|
||||
test("buildLukeDeliveryPlan safely quotes shell-sensitive content", () => {
|
||||
const plan = buildLukeDeliveryPlan({
|
||||
recipientEmail: "stefano@fiorinis.com",
|
||||
subject: "Luke's flight report; rm -rf /",
|
||||
body: "It's ready.",
|
||||
attachmentPath: "/tmp/report's-final.pdf"
|
||||
});
|
||||
|
||||
assert.equal(plan.ready, true);
|
||||
assert.match(String(plan.command), /--subject 'Luke'"'"'s flight report; rm -rf \/'/);
|
||||
assert.match(String(plan.command), /--attach '\/tmp\/report'"'"'s-final\.pdf'/);
|
||||
});
|
||||
76
skills/flight-finder/tests/report-pdf.test.ts
Normal file
76
skills/flight-finder/tests/report-pdf.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
import { renderFlightReportPdf, ReportValidationError } from "../src/report-pdf.js";
|
||||
import type { FlightReportPayload } from "../src/types.js";
|
||||
import {
|
||||
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
normalizeFlightReportRequest
|
||||
} from "../src/request-normalizer.js";
|
||||
|
||||
function samplePayload(): FlightReportPayload {
|
||||
return {
|
||||
request: normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request,
|
||||
sourceFindings: [
|
||||
{
|
||||
source: "kayak",
|
||||
status: "viable",
|
||||
checkedAt: "2026-03-30T21:00:00Z",
|
||||
notes: ["Returned usable options."]
|
||||
}
|
||||
],
|
||||
quotes: [
|
||||
{
|
||||
id: "quote-1",
|
||||
source: "kayak",
|
||||
legId: "outbound",
|
||||
passengerGroupIds: ["pair", "solo"],
|
||||
bookingLink: "https://example.com/quote-1",
|
||||
itinerarySummary: "DFW -> BLQ via LHR",
|
||||
totalPriceUsd: 2847,
|
||||
displayPriceUsd: "$2,847"
|
||||
}
|
||||
],
|
||||
rankedOptions: [
|
||||
{
|
||||
id: "primary",
|
||||
title: "Best overall itinerary",
|
||||
quoteIds: ["quote-1"],
|
||||
totalPriceUsd: 2847,
|
||||
rationale: "Best price-to-convenience tradeoff."
|
||||
}
|
||||
],
|
||||
executiveSummary: ["Best fare currently found is $2,847 total for 3 adults."],
|
||||
reportWarnings: [],
|
||||
degradedReasons: [],
|
||||
comparisonCurrency: "USD",
|
||||
generatedAt: "2026-03-30T21:00:00Z",
|
||||
lastCompletedPhase: "ranking"
|
||||
};
|
||||
}
|
||||
|
||||
test("renderFlightReportPdf writes a non-empty PDF", async () => {
|
||||
const outputPath = path.join(os.tmpdir(), `flight-finder-${Date.now()}.pdf`);
|
||||
await renderFlightReportPdf(samplePayload(), outputPath);
|
||||
assert.ok(fs.existsSync(outputPath));
|
||||
assert.ok(fs.statSync(outputPath).size > 0);
|
||||
});
|
||||
|
||||
test("renderFlightReportPdf rejects incomplete report payloads", async () => {
|
||||
const outputPath = path.join(os.tmpdir(), `flight-finder-incomplete-${Date.now()}.pdf`);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
renderFlightReportPdf(
|
||||
{
|
||||
...samplePayload(),
|
||||
rankedOptions: [],
|
||||
executiveSummary: []
|
||||
},
|
||||
outputPath
|
||||
),
|
||||
ReportValidationError
|
||||
);
|
||||
});
|
||||
99
skills/flight-finder/tests/report-status.test.ts
Normal file
99
skills/flight-finder/tests/report-status.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
normalizeFlightReportRequest
|
||||
} from "../src/request-normalizer.js";
|
||||
import { getFlightReportStatus } from "../src/report-status.js";
|
||||
import type { FlightReportPayload } from "../src/types.js";
|
||||
|
||||
function buildPayload(): FlightReportPayload {
|
||||
return {
|
||||
request: normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request,
|
||||
sourceFindings: [
|
||||
{
|
||||
source: "kayak",
|
||||
status: "viable",
|
||||
checkedAt: "2026-03-30T21:00:00Z",
|
||||
notes: ["Returned usable options."]
|
||||
}
|
||||
],
|
||||
quotes: [
|
||||
{
|
||||
id: "quote-1",
|
||||
source: "kayak",
|
||||
legId: "outbound",
|
||||
passengerGroupIds: ["pair", "solo"],
|
||||
bookingLink: "https://example.com/quote-1",
|
||||
itinerarySummary: "DFW -> BLQ via LHR",
|
||||
totalPriceUsd: 2847,
|
||||
displayPriceUsd: "$2,847",
|
||||
crossCheckStatus: "not-available"
|
||||
}
|
||||
],
|
||||
rankedOptions: [
|
||||
{
|
||||
id: "primary",
|
||||
title: "Best overall itinerary",
|
||||
quoteIds: ["quote-1"],
|
||||
totalPriceUsd: 2847,
|
||||
rationale: "Best price-to-convenience tradeoff."
|
||||
}
|
||||
],
|
||||
executiveSummary: ["Best fare currently found is $2,847 total for 3 adults."],
|
||||
reportWarnings: [],
|
||||
degradedReasons: [],
|
||||
comparisonCurrency: "USD",
|
||||
generatedAt: "2026-03-30T21:00:00Z",
|
||||
lastCompletedPhase: "ranking"
|
||||
};
|
||||
}
|
||||
|
||||
test("getFlightReportStatus treats missing recipient email as delivery-only blocker", () => {
|
||||
const normalized = normalizeFlightReportRequest({
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
recipientEmail: null
|
||||
});
|
||||
const payload = {
|
||||
...buildPayload(),
|
||||
request: normalized.request
|
||||
};
|
||||
|
||||
const status = getFlightReportStatus(normalized, payload);
|
||||
|
||||
assert.equal(status.readyToSearch, true);
|
||||
assert.equal(status.pdfReady, true);
|
||||
assert.equal(status.emailReady, false);
|
||||
assert.deepEqual(status.needsMissingInputs, ["recipient email"]);
|
||||
});
|
||||
|
||||
test("getFlightReportStatus marks all-sources-failed as a blocked but chat-summarizable outcome", () => {
|
||||
const normalized = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT);
|
||||
const status = getFlightReportStatus(normalized, {
|
||||
...buildPayload(),
|
||||
sourceFindings: [
|
||||
{
|
||||
source: "kayak",
|
||||
status: "blocked",
|
||||
checkedAt: "2026-03-30T21:00:00Z",
|
||||
notes: ["Timed out."]
|
||||
},
|
||||
{
|
||||
source: "skyscanner",
|
||||
status: "blocked",
|
||||
checkedAt: "2026-03-30T21:00:00Z",
|
||||
notes: ["Timed out."]
|
||||
}
|
||||
],
|
||||
quotes: [],
|
||||
rankedOptions: [],
|
||||
executiveSummary: [],
|
||||
degradedReasons: ["All configured travel sources failed."]
|
||||
});
|
||||
|
||||
assert.equal(status.terminalOutcome, "all-sources-failed");
|
||||
assert.equal(status.chatSummaryReady, true);
|
||||
assert.equal(status.pdfReady, false);
|
||||
assert.equal(status.degraded, true);
|
||||
});
|
||||
42
skills/flight-finder/tests/run-state.test.ts
Normal file
42
skills/flight-finder/tests/run-state.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import {
|
||||
clearFlightFinderRunState,
|
||||
loadFlightFinderRunState,
|
||||
saveFlightFinderRunState
|
||||
} from "../src/run-state.js";
|
||||
import {
|
||||
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
normalizeFlightReportRequest
|
||||
} from "../src/request-normalizer.js";
|
||||
|
||||
test("save/load/clearFlightFinderRunState persists the resumable phase context", async () => {
|
||||
const baseDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "flight-finder-run-state-")
|
||||
);
|
||||
const request = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request;
|
||||
|
||||
await saveFlightFinderRunState(
|
||||
{
|
||||
request,
|
||||
lastCompletedPhase: "search",
|
||||
updatedAt: "2026-03-30T21:00:00Z",
|
||||
reportWarnings: [],
|
||||
degradedReasons: [],
|
||||
sourceFindings: [],
|
||||
comparisonCurrency: "USD"
|
||||
},
|
||||
baseDir
|
||||
);
|
||||
|
||||
const loaded = await loadFlightFinderRunState(baseDir);
|
||||
assert.equal(loaded?.lastCompletedPhase, "search");
|
||||
assert.equal(loaded?.request.tripName, "DFW ↔ BLQ flight report");
|
||||
|
||||
await clearFlightFinderRunState(baseDir);
|
||||
assert.equal(await loadFlightFinderRunState(baseDir), null);
|
||||
});
|
||||
46
skills/flight-finder/tests/search-orchestration.test.ts
Normal file
46
skills/flight-finder/tests/search-orchestration.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
normalizeFlightReportRequest
|
||||
} from "../src/request-normalizer.js";
|
||||
import {
|
||||
VPN_CONNECT_TIMEOUT_MS,
|
||||
VPN_DISCONNECT_TIMEOUT_MS,
|
||||
buildFlightSearchPlan
|
||||
} from "../src/search-orchestration.js";
|
||||
|
||||
test("buildFlightSearchPlan activates VPN only when marketCountry is explicit", () => {
|
||||
const request = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request;
|
||||
const plan = buildFlightSearchPlan(request);
|
||||
|
||||
assert.equal(plan.vpn.enabled, true);
|
||||
assert.equal(plan.vpn.marketCountry, "TH");
|
||||
assert.equal(plan.vpn.connectTimeoutMs, VPN_CONNECT_TIMEOUT_MS);
|
||||
assert.equal(plan.vpn.disconnectTimeoutMs, VPN_DISCONNECT_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
test("buildFlightSearchPlan keeps airline-direct best-effort when degraded", () => {
|
||||
const request = normalizeFlightReportRequest({
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
preferences: {
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT.preferences,
|
||||
marketCountry: null
|
||||
}
|
||||
}).request;
|
||||
const plan = buildFlightSearchPlan(request, [
|
||||
{
|
||||
source: "airline-direct",
|
||||
status: "degraded",
|
||||
checkedAt: "2026-03-30T21:00:00Z",
|
||||
notes: ["Direct booking shell loads but search completion is unreliable."]
|
||||
}
|
||||
]);
|
||||
|
||||
assert.equal(plan.vpn.enabled, false);
|
||||
const directSource = plan.sourceOrder.find((entry) => entry.source === "airline-direct");
|
||||
assert.equal(directSource?.enabled, true);
|
||||
assert.equal(directSource?.required, false);
|
||||
assert.match(plan.degradedReasons.join(" "), /airline-direct/i);
|
||||
});
|
||||
Reference in New Issue
Block a user