Files
MBN_STOCK_WEBVIEW/tests/Web/overseas-workflow.test.cjs

397 lines
15 KiB
JavaScript

"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const workflow = require("../../Web/overseas-workflow.js");
const industry = Object.freeze({
koreanName: "Philadelphia Semiconductor",
inputName: "US Semiconductor Index",
symbol: "NAS@SOX",
nationCode: "US",
fdtc: "0"
});
const usStock = Object.freeze({
inputName: "NVIDIA",
symbol: "NAS@NVDA",
nationCode: "US",
fdtc: "1"
});
const twStock = Object.freeze({
inputName: "Taiwan Semiconductor",
symbol: "TWS@2330",
nationCode: "TW",
fdtc: "1"
});
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function industryResponse(overrides = {}) {
return {
requestId: "overseas-industry-1",
query: "",
retrievedAt: "2026-07-11T12:34:56+09:00",
totalRowCount: 2,
truncated: false,
results: [
{
koreanName: "Alpha Industry",
inputName: "ALPHA_INDEX",
symbol: "NAS@ALPHA",
nationCode: "US",
fdtc: "0"
},
{
koreanName: "Beta Industry",
inputName: "BETA_INDEX",
symbol: "NAS@BETA",
nationCode: "US",
fdtc: "0"
}
],
...overrides
};
}
function stockResponse(overrides = {}) {
return {
requestId: "overseas-stock-1",
query: "",
retrievedAt: "2026-07-11T12:34:56+09:00",
totalRowCount: 2,
truncated: false,
results: [
{ inputName: "Apple", symbol: "NAS@AAPL", nationCode: "US", fdtc: "1" },
{ inputName: "Taiwan Semiconductor", symbol: "TWS@2330", nationCode: "TW", fdtc: "1" }
],
...overrides
};
}
test("UC5 bridge contracts expose one industry action and six stock actions", () => {
assert.deepEqual(workflow.bridgeContracts.industry, {
requestType: "search-overseas-industries",
resultType: "overseas-industry-results",
errorType: "overseas-industry-error",
defaultMaximumResults: 100,
maximumResults: 500,
maximumQueryLength: 64
});
assert.deepEqual(workflow.bridgeContracts.stock, {
requestType: "search-world-stocks",
resultType: "world-stock-search-results",
errorType: "world-stock-search-error",
defaultMaximumResults: 100,
maximumResults: 500,
maximumQueryLength: 64
});
assert.deepEqual(workflow.industryActions.map(value => [value.id, value.code]), [
["industry-current", "5001"]
]);
assert.deepEqual(workflow.stockActions.map(value => [value.id, value.code, value.periodDays]), [
["stock-current", "5001", null],
["stock-candle-5d", "8061", 5],
["stock-candle-20d", "8040", 20],
["stock-candle-60d", "8046", 60],
["stock-candle-120d", "8051", 120],
["stock-candle-240d", "8056", 240]
]);
assert.deepEqual(workflow.sourceContract.industry, {
fdtc: "0", nationCodes: ["US"], lookupField: "F_KNAM", currentCutCode: "5001"
});
assert.deepEqual(workflow.sourceContract.stock.candleCutCodes, [
"8061", "8040", "8046", "8051", "8056"
]);
});
test("industry and stock search requests normalize blank initial lists and exact limits", () => {
assert.deepEqual(
workflow.createIndustrySearchRequest("industry-request", " "),
{ requestId: "industry-request", query: "" });
assert.deepEqual(
workflow.createStockSearchRequest("stock-request", " NVDA ", 200),
{ requestId: "stock-request", query: "NVDA", maximumResults: 200 });
for (const create of [workflow.createIndustrySearchRequest, workflow.createStockSearchRequest]) {
assert.throws(() => create("bad id", ""), /safe/i);
assert.throws(() => create("request", "bad\nquery"), /safe/i);
assert.throws(() => create("request", "x".repeat(65)), /64/);
assert.throws(() => create("request", "", 0), /1 through 500/);
assert.throws(() => create("request", "", 501), /1 through 500/);
assert.throws(() => create("request", "", "100"), /1 through 500/);
}
});
test("native identities require exact name, symbol, nation and FDTC fields", () => {
assert.deepEqual(workflow.normalizeIndustry(industry), industry);
assert.deepEqual(workflow.normalizeStock(usStock), usStock);
assert.deepEqual(workflow.normalizeStock(twStock), twStock);
assert.equal(workflow.normalizeIndustry({ ...industry, nationCode: "TW" }), null);
assert.equal(workflow.normalizeIndustry({ ...industry, fdtc: "1" }), null);
assert.equal(workflow.normalizeIndustry({ ...industry, koreanName: " Industry" }), null);
assert.equal(workflow.normalizeIndustry({ ...industry, inputName: "A|B" }), null);
assert.equal(workflow.normalizeStock({ ...usStock, nationCode: "JP" }), null);
assert.equal(workflow.normalizeStock({ ...usStock, fdtc: 1 }), null);
assert.equal(workflow.normalizeStock({ ...usStock, symbol: "../../NVDA" }), null);
assert.equal(workflow.normalizeStock({ ...usStock, inputName: "NV\u200BIDIA" }), null);
assert.equal(workflow.normalizeStock({ ...usStock, koreanName: "extra" }), null);
});
test("industry responses are request-bound, ordered and deeply normalized", () => {
const request = workflow.createIndustrySearchRequest("overseas-industry-1", "", 2);
const normalized = workflow.normalizeIndustrySearchResponse(industryResponse(), request);
assert.ok(normalized);
assert.deepEqual(normalized, industryResponse());
assert.equal(Object.isFrozen(normalized), true);
assert.equal(Object.isFrozen(normalized.results), true);
assert.equal(Object.isFrozen(normalized.results[0]), true);
assert.equal(workflow.normalizeIndustrySearchResponse(
industryResponse({ requestId: "stale" }), request), null);
assert.equal(workflow.normalizeIndustrySearchResponse(
industryResponse({ totalRowCount: 1 }), request), null);
assert.equal(workflow.normalizeIndustrySearchResponse(
industryResponse({ retrievedAt: "invalid" }), request), null);
assert.equal(workflow.normalizeIndustrySearchResponse(
{ ...industryResponse(), extra: true }, request), null);
assert.equal(workflow.normalizeIndustrySearchResponse(industryResponse({
results: [...industryResponse().results].reverse()
}), request), null);
const overDefault = Array.from({ length: 101 }, (_, index) => {
const key = String(index).padStart(3, "0");
return {
koreanName: `Industry ${key}`,
inputName: `INDEX_${key}`,
symbol: `NAS@I${key}`,
nationCode: "US",
fdtc: "0"
};
});
assert.equal(workflow.normalizeIndustrySearchResponse({
...industryResponse(),
totalRowCount: overDefault.length,
results: overDefault
}, workflow.createIndustrySearchRequest("overseas-industry-1", "")), null);
});
test("industry responses reject every ambiguous downstream lookup key", () => {
for (const key of ["koreanName", "inputName", "symbol"]) {
const rows = clone(industryResponse().results);
rows[1][key] = rows[0][key];
assert.equal(workflow.normalizeIndustrySearchResponse(industryResponse({ results: rows })), null);
}
const wrongScope = clone(industryResponse());
wrongScope.results[1].fdtc = "1";
assert.equal(workflow.normalizeIndustrySearchResponse(wrongScope), null);
});
test("stock responses preserve US/TW identity and reject duplicate input-name lookups", () => {
const request = workflow.createStockSearchRequest("overseas-stock-1", "", 2);
assert.ok(workflow.normalizeStockSearchResponse(stockResponse(), request));
const duplicateName = clone(stockResponse());
duplicateName.results[1].inputName = duplicateName.results[0].inputName;
assert.equal(workflow.normalizeStockSearchResponse(duplicateName), null);
const sameSymbol = clone(stockResponse());
sameSymbol.results[1].symbol = sameSymbol.results[0].symbol;
assert.ok(workflow.normalizeStockSearchResponse(sameSymbol));
const wrongNation = clone(stockResponse());
wrongNation.results[1].nationCode = "JP";
assert.equal(workflow.normalizeStockSearchResponse(wrongNation), null);
assert.equal(workflow.normalizeStockSearchResponse(stockResponse({
results: [...stockResponse().results].reverse()
})), null);
});
test("provider symbols accept Unicode letters but keep serialized delimiters closed", () => {
const unicode = workflow.normalizeStock({
inputName: "Provider Unicode Symbol",
symbol: "NAS@해외 종목1",
nationCode: "US",
fdtc: "1"
});
assert.ok(unicode);
const entry = workflow.createOverseasStockPlaylistEntry(
unicode,
"stock-candle-5d",
{ id: "unicode-symbol", fadeDuration: 6, ma5: true, ma20: false });
assert.equal(entry.selection.dataCode, "NAS@해외 종목1|US|1");
for (const symbol of ["NAS|NVDA", "NAS^NVDA", " NAS@NVDA", "NAS@NVDA ", "NAS!NVDA", "NAS\nNVDA", "NAS😀NVDA"]) {
assert.equal(workflow.normalizeStock({ ...unicode, symbol }), null, symbol);
}
});
test("bridge errors use exact payloads and reject stale request correlation", () => {
const industryRequest = workflow.createIndustrySearchRequest("industry-error-1", "AI");
const industryError = {
requestId: "industry-error-1",
query: "AI",
message: "Industry lookup failed."
};
assert.deepEqual(
workflow.normalizeIndustrySearchError(industryError, industryRequest),
industryError);
assert.equal(workflow.normalizeIndustrySearchError({
...industryError, requestId: "stale"
}, industryRequest), null);
assert.equal(workflow.normalizeIndustrySearchError({
...industryError, extra: true
}, industryRequest), null);
const stockRequest = workflow.createStockSearchRequest("stock-error-1", "NVDA");
const stockError = { requestId: "stock-error-1", query: "NVDA", message: "Failed." };
assert.deepEqual(workflow.normalizeStockSearchError(stockError, stockRequest), stockError);
assert.equal(workflow.normalizeStockSearchError({
...stockError, message: "bad\nmessage"
}, stockRequest), null);
});
test("US overseas industries expose only the 5001 current one-column mapping", () => {
assert.equal(workflow.isActionAllowed("industry", "industry-current", industry), true);
assert.equal(workflow.isActionAllowed("industry", "stock-candle-5d", industry), false);
const mapping = workflow.buildOverseasIndustrySelection(industry);
assert.equal(mapping.builderKey, "s5001");
assert.equal(mapping.code, "5001");
assert.deepEqual(mapping.aliases, ["5001"]);
assert.deepEqual(mapping.selection, {
groupCode: "FOREIGN_INDUSTRY",
subject: "Philadelphia Semiconductor",
graphicType: "1\uC5F4\uD310\uAE30\uBCF8",
subtype: "CURRENT",
dataCode: "US Semiconductor Index|NAS@SOX|US|0"
});
assert.throws(() => workflow.buildOverseasIndustrySelection({
...industry, nationCode: "TW"
}), /valid/i);
});
test("US/TW stocks expose 5001 current and the five exact UC5 candle aliases", () => {
const current = workflow.buildOverseasStockSelection(usStock, "stock-current", {
ma5: true,
ma20: true
});
assert.equal(current.builderKey, "s5001");
assert.deepEqual(current.selection, {
groupCode: "FOREIGN_STOCK",
subject: "NVIDIA",
graphicType: "1\uC5F4\uD310\uAE30\uBCF8",
subtype: "CURRENT",
dataCode: "NAS@NVDA|US|1"
});
assert.deepEqual(current.movingAverages, { ma5: false, ma20: false });
for (const action of workflow.stockActions.filter(value => value.kind === "candle")) {
const mapping = workflow.buildOverseasStockSelection(twStock, action.id, {
ma5: true,
ma20: false
});
assert.equal(mapping.builderKey, "s8010");
assert.equal(mapping.code, action.code);
assert.deepEqual(mapping.aliases, [action.code]);
assert.deepEqual(mapping.selection, {
groupCode: "FOREIGN_STOCK",
subject: "Taiwan Semiconductor",
graphicType: "MA5",
subtype: "PRICE",
dataCode: "TWS@2330|TW|1"
});
}
assert.throws(() => workflow.buildOverseasStockSelection(usStock, "stock-candle-intraday"), /unknown/i);
assert.throws(() => workflow.buildOverseasStockSelection(usStock, "industry-current"), /unknown/i);
assert.throws(() => workflow.buildOverseasStockSelection(usStock, "stock-current", {
ma5: "true"
}), /boolean/i);
});
test("playlist entries preserve typed FDTC identity and runtime candle bindings", () => {
const industryEntry = workflow.createOverseasIndustryPlaylistEntry(industry, {
id: "industry-entry",
fadeDuration: 7
});
assert.equal(industryEntry.category, "plate");
assert.equal(industryEntry.symbol, "NAS@SOX");
assert.equal(industryEntry.nationCode, "US");
assert.equal(industryEntry.fdtc, "0");
assert.equal(industryEntry.operator.kind, "industry");
assert.equal(industryEntry.operator.schemaVersion, 1);
const candleEntry = workflow.createOverseasStockPlaylistEntry(
twStock,
"stock-candle-240d",
{ id: "tw-candle", fadeDuration: 8, ma5: true, ma20: true });
assert.equal(candleEntry.builderKey, "s8010");
assert.equal(candleEntry.code, "8056");
assert.equal(candleEntry.category, "chart");
assert.equal(candleEntry.selection.dataCode, "TWS@2330|TW|1");
assert.equal(candleEntry.selection.graphicType, "MA5,MA20");
assert.equal(candleEntry.operator.kind, "stock");
assert.deepEqual(candleEntry.operator.identity, twStock);
});
test("trusted restore reconstructs entries and rejects identity or mapping tampering", () => {
const original = workflow.createOverseasStockPlaylistEntry(
usStock,
"stock-candle-120d",
{ id: "stored-overseas", fadeDuration: 9, ma5: true, ma20: false });
original.enabled = false;
const restored = workflow.restoreOverseasPlaylistEntry(clone(original));
assert.ok(restored);
assert.equal(restored.enabled, false);
const tampers = [
value => { value.builderKey = "s5001"; },
value => { value.code = "8040"; },
value => { value.selection.groupCode = "FOREIGN_INDEX"; },
value => { value.selection.subject = "Other"; },
value => { value.selection.dataCode = "NAS@NVDA|TW|1"; },
value => { value.selection.subtype = "VOLUME"; },
value => { value.symbol = "NAS@OTHER"; },
value => { value.nationCode = "TW"; },
value => { value.fdtc = "0"; },
value => { value.operator.actionId = "stock-current"; },
value => { value.operator.schemaVersion = 2; },
value => { value.operator.identity.symbol = "NAS@OTHER"; },
value => { value.operator.identity.nationCode = "TW"; },
value => { value.operator.identity.fdtc = "0"; },
value => { value.operator.movingAverages.ma5 = false; },
value => { value.enabled = "false"; }
];
for (const tamper of tampers) {
const candidate = clone(original);
tamper(candidate);
assert.equal(workflow.restoreOverseasPlaylistEntry(candidate), null);
}
const industryEntry = workflow.createOverseasIndustryPlaylistEntry(industry, {
id: "stored-industry"
});
assert.ok(workflow.restoreOverseasPlaylistEntry(clone(industryEntry)));
const tamperedIndustry = clone(industryEntry);
tamperedIndustry.operator.identity.koreanName = "Other Industry";
assert.equal(workflow.restoreOverseasPlaylistEntry(tamperedIndustry), null);
assert.equal(workflow.restoreOverseasPlaylistEntry(null), null);
});
test("playlist option validation rejects unsafe ids, fades and identities", () => {
assert.throws(() => workflow.createOverseasIndustryPlaylistEntry(industry, {
id: "bad id"
}), /safe/i);
assert.throws(() => workflow.createOverseasStockPlaylistEntry(usStock, "stock-current", {
id: "fade", fadeDuration: 61
}), /0 through 60/i);
assert.throws(() => workflow.createOverseasStockPlaylistEntry(usStock, "stock-current", {
id: "flags", ma20: 1
}), /boolean/i);
assert.throws(() => workflow.createOverseasStockPlaylistEntry(
{ ...usStock, nationCode: "JP" },
"stock-current",
{ id: "invalid" }), /valid/i);
});