diff --git a/.gitignore b/.gitignore index 9f4be179..5f430002 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,4 @@ node_modules ./tmp .cursor /tmp -/tmp-* \ No newline at end of file +/tmp-* diff --git a/CHANGELOG.md b/CHANGELOG.md index ee8d0f32..e16a77e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ All notable changes to this project will be documented in this file. +## [1.56.4] (06/07/2026) +Added `update-text-properties` command. It uploads custom text properties from a Liquid Test YAML file to a company file at company, period, reconciliation and account levels for the entries referenced in the test scenario. Usage: `silverfin update-text-properties -u -t `. The YAML file read is the one referenced by the template's `config.json` `test` key (same as `run-test`); pass `--file ` to read another file, e.g. a year variant like `*_TY25_liquid_test.yml`. When the test name exists in several templates the command lists them and asks for `--handle` instead of guessing. Also supports `--dry-run` to preview the payload and `--yes` to skip the confirmation prompt. + ## [1.56.3] (03/07/2026) Improve `run-test --status` output for CI: surface the underlying error message when a run ends in `test_error`/`internal_error` (previously reported as a bare `FAILED` with no reason), and suppress the progress spinner when stdout is not a TTY (it flooded CI logs with hundreds of "Running tests.." frames). diff --git a/bin/cli.js b/bin/cli.js index 79858a13..298c8e73 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -19,6 +19,8 @@ const { runCommandChecks } = require("../lib/cli/utils"); const { CwdValidator } = require("../lib/cli/cwdValidator"); const { AutoCompletions } = require("../lib/cli/autoCompletions"); const fsUtils = require("../lib/utils/fsUtils"); +const textPropertyUtils = require("../lib/utils/textPropertyUtils"); +const liquidTestUtils = require("../lib/utils/liquidTestUtils"); const firmIdDefault = cliUtils.loadDefaultFirmId(); cliUtils.handleUncaughtErrors(); @@ -527,6 +529,160 @@ program liquidTestGenerator.testGenerator(options.url, testName, reconciledStatus); }); +// Update Text Properties from Liquid Test data +program + .command("update-text-properties") + .description( + "Upload custom text properties from a Liquid Test YAML file to a company file. Pushes custom data at the company, period, reconciliation and account levels for every entry referenced in the test scenario (not only the reconciliation in the URL). The URL identifies the target firm/company." + ) + .requiredOption("-u, --url ", "Specify the full Silverfin URL of the reconciliation in the company file (mandatory)") + .requiredOption("-t, --test ", "Specify the name of the test to use as data source (mandatory)") + .option("-h, --handle ", "Specify the reconciliation handle to narrow down the YAML file search (optional)") + .option("--file ", "Specify the exact YAML file name (inside the template's tests/ folder) to read the test from, instead of the test file referenced by the template's config.json (optional)") + .option("--dry-run", "Only transform and display the properties without uploading (optional)", false) + .option("-y, --yes", "Skip the confirmation prompt before uploading (optional)", false) + .action(async (options) => { + // Parse URL to extract IDs + const urlData = liquidTestUtils.extractURL(options.url); + const { firmId, companyId } = urlData; + if (!firmId || !companyId) { + consola.error("Could not determine the firm and company from the URL. Double-check the Silverfin URL and try again."); + process.exitCode = 1; + return; + } + + // Find the test data in the YAML files + const testData = textPropertyUtils.findTestData(options.test, options.handle, options.file); + consola.info(`Found test "${options.test}" in ${testData.handle}/tests/${testData.file}`); + + // Collect all updates to perform + const updates = []; + + // Company-level custom + if (testData.company?.custom) { + const properties = textPropertyUtils.transformCustomToProperties(testData.company.custom); + updates.push({ level: "company", properties, apply: () => SF.updateCompanyCustom(firmId, companyId, properties) }); + } + + // Fetch all periods once (paginated) if needed for resolving period dates + let periodsArray = null; + + for (const [periodKey, periodEntry] of Object.entries(testData.periods)) { + // Resolve period date to period ID + if (!periodsArray) { + periodsArray = await SF.getAllPeriods(firmId, companyId); + } + const { period, error: periodError } = textPropertyUtils.findPeriodByKey(periodsArray, periodKey); + if (!period) { + // Skipping still seeds the rest of the scenario, but the partial seed must + // be detectable: flag it as a failure, consistent with the + // reconciliation/account not-found paths. + consola.error(`${periodError} — skipping (marked as failure)`); + process.exitCode = 1; + continue; + } + const targetPeriodId = period.id; + + // Period-level custom + if (periodEntry.custom) { + const properties = textPropertyUtils.transformCustomToProperties(periodEntry.custom); + updates.push({ level: `period [${periodKey}]`, properties, apply: () => SF.updatePeriodCustom(firmId, companyId, targetPeriodId, properties) }); + } + + // Reconciliation-level custom + for (const [reconHandle, customData] of Object.entries(periodEntry.reconciliations)) { + const properties = textPropertyUtils.transformCustomToProperties(customData); + updates.push({ + level: `reconciliation [${reconHandle}] in ${periodKey}`, + properties, + apply: async () => { + const recon = await SF.findReconciliationInWorkflows(firmId, reconHandle, companyId, targetPeriodId); + if (!recon) { + consola.error(`Reconciliation "${reconHandle}" not found in any workflow for period ${periodKey}`); + return null; + } + return SF.updateReconciliationCustom(firmId, companyId, targetPeriodId, recon.id, properties); + }, + }); + } + + // Account-level custom + for (const [accountNumber, customData] of Object.entries(periodEntry.accounts)) { + const properties = textPropertyUtils.transformCustomToProperties(customData); + updates.push({ + level: `account [${accountNumber}] in ${periodKey}`, + properties, + apply: async () => { + const account = await SF.findAccountByNumber(firmId, companyId, targetPeriodId, accountNumber); + const accountId = account?.account?.id; + if (!accountId) { + consola.error(`Account "${accountNumber}" could not be resolved for period ${periodKey}`); + return null; + } + return SF.updateAccountCustom(firmId, companyId, targetPeriodId, accountId, properties); + }, + }); + } + } + + if (updates.length === 0) { + consola.warn("No custom properties found in this test"); + return; + } + + consola.info(`Found ${updates.length} custom update(s) to apply`); + + if (options.dryRun) { + for (const update of updates) { + consola.info(`[dry-run] ${update.level}: ${update.properties.length} properties`); + consola.log(JSON.stringify(update.properties, null, 2)); + } + return; + } + + // Summarise what will be written and where, then confirm (unless --yes) + const firmName = firmCredentials.getFirmName(firmId); + consola.warn(`About to write custom data to company ${companyId} on firm ${firmName ? `${firmName} (${firmId})` : firmId}:`); + for (const update of updates) { + consola.warn(` • ${update.level}: ${update.properties.length} properties`); + } + if (!options.yes) { + cliUtils.promptConfirmation(); + } + + // Apply all updates + let hadFailures = false; + for (const update of updates) { + consola.start(`Updating ${update.level} (${update.properties.length} properties)...`); + // A throw (network error, unexpected lookup failure) must count as a + // per-item failure, not abort the batch mid-way with no summary. + let response; + try { + response = await update.apply(); + } catch (error) { + hadFailures = true; + consola.error(`${update.level}: failed (${error.message})`); + continue; + } + if (!response) { + hadFailures = true; + continue; + } + // Handle both single response (reconciliation) and array of responses (company/period/account) + const responses = Array.isArray(response) ? response : [response]; + const failed = responses.filter((r) => !r || r.status < 200 || r.status >= 300); + if (failed.length === 0) { + consola.success(`${update.level}: updated`); + } else { + hadFailures = true; + consola.error(`${update.level}: ${failed.length}/${responses.length} failed`); + } + } + if (hadFailures) { + process.exitCode = 1; + } + }); + // Check Liquid Test dependencies for a reconciliation template program .command("check-dependencies") diff --git a/lib/api/sfApi.js b/lib/api/sfApi.js index 6e54717d..b7f37a07 100644 --- a/lib/api/sfApi.js +++ b/lib/api/sfApi.js @@ -505,6 +505,34 @@ async function getPeriods(firmId, companyId, page = 1) { } } +async function getAllPeriods(firmId, companyId) { + const fetchPage = async (page) => { + const response = await getPeriods(firmId, companyId, page); + if (!response || !response.data) { + return []; + } + + return response.data || []; + }; + + const items = []; + let page = 1; + let hasMore = true; + + while (hasMore && page <= MAX_PAGES) { + const pageData = await fetchPage(page); + items.push(...pageData); + hasMore = pageData.length === PER_PAGE; + page++; + } + + if (page > MAX_PAGES) { + consola.warn(`Reached maximum page limit (${MAX_PAGES}) while fetching company periods. There might be more data available.`); + } + + return items; +} + function findPeriod(periodId, periodsArray) { return periodsArray.find((period) => period.id == periodId); } @@ -572,6 +600,60 @@ async function getAllPeriodCustom(firmId, companyId, periodId) { return items; } +// The period/reconciliation/account `/custom` endpoints accept a BULK +// `{ properties: [...] }` body (verified live); the company `/custom` endpoint +// does NOT — it returns 400 unless each custom is POSTed as a single +// `{ namespace, key, value }` object, hence the per-property loop there. +// All four use batchResponseErrorHandler so one failed write (e.g. 422) is +// counted as a failure by the caller instead of aborting the whole batch. +async function updateReconciliationCustom(firmId, companyId, periodId, reconciliationId, properties) { + const instance = AxiosFactory.createInstance("firm", firmId); + try { + const response = await instance.post(`/companies/${companyId}/periods/${periodId}/reconciliations/${reconciliationId}/custom`, { properties }); + apiUtils.responseSuccessHandler(response); + return response; + } catch (error) { + return apiUtils.batchResponseErrorHandler(error); + } +} + +async function updateCompanyCustom(firmId, companyId, properties) { + const instance = AxiosFactory.createInstance("firm", firmId); + const results = []; + for (const prop of properties) { + try { + const response = await instance.post(`/companies/${companyId}/custom`, prop); + apiUtils.responseSuccessHandler(response); + results.push(response); + } catch (error) { + results.push(apiUtils.batchResponseErrorHandler(error)); + } + } + return results; +} + +async function updatePeriodCustom(firmId, companyId, periodId, properties) { + const instance = AxiosFactory.createInstance("firm", firmId); + try { + const response = await instance.post(`/companies/${companyId}/periods/${periodId}/custom`, { properties }); + apiUtils.responseSuccessHandler(response); + return response; + } catch (error) { + return apiUtils.batchResponseErrorHandler(error); + } +} + +async function updateAccountCustom(firmId, companyId, periodId, accountId, properties) { + const instance = AxiosFactory.createInstance("firm", firmId); + try { + const response = await instance.post(`/companies/${companyId}/periods/${periodId}/accounts/${accountId}/custom`, { properties }); + apiUtils.responseSuccessHandler(response); + return response; + } catch (error) { + return apiUtils.batchResponseErrorHandler(error); + } +} + async function getWorkflows(firmId, companyId, periodId) { const instance = AxiosFactory.createInstance("firm", firmId); try { @@ -616,7 +698,7 @@ async function findReconciliationInWorkflows(firmId, reconciliationHandle, compa // Get data from all workflows const responseWorkflows = await getWorkflows(firmId, companyId, periodId); // Check in each workflow - for (const workflow of responseWorkflows.data) { + for (const workflow of responseWorkflows?.data ?? []) { const reconciliationInformation = await findReconciliationInWorkflow(firmId, reconciliationHandle, companyId, periodId, workflow.id); // Found if (reconciliationInformation) { @@ -756,11 +838,16 @@ module.exports = { createTestRun, createPreviewRun, getPeriods, + getAllPeriods, findPeriod, getCompanyDrop, getCompanyCustom, getPeriodCustom, getAllPeriodCustom, + updateReconciliationCustom, + updateCompanyCustom, + updatePeriodCustom, + updateAccountCustom, getWorkflows, getWorkflowInformation, findReconciliationInWorkflow, diff --git a/lib/utils/apiUtils.js b/lib/utils/apiUtils.js index 5069698e..7be87760 100644 --- a/lib/utils/apiUtils.js +++ b/lib/utils/apiUtils.js @@ -59,9 +59,25 @@ async function responseErrorHandler(error) { throw error; } +// Like responseErrorHandler, but NEVER terminates the process — for batch flows +// (e.g. the update*Custom writes behind `update-text-properties`) where a single +// failed write (422/403/...) must not abort the remaining updates. Logs the error +// and returns the error response so callers can count it as a failure. +function batchResponseErrorHandler(error) { + if (error?.response) { + const { status, statusText, data, config } = error.response; + consola.debug(`Response Status: ${status} (${statusText}) - method: ${config?.method} - url: ${config?.url}`); + consola.error(`Response Error (${status}): ${JSON.stringify(data?.error ?? data)}`); + return error.response; + } + // No HTTP response (network-level error): not a per-item failure, so rethrow. + throw error; +} + module.exports = { checkAuthorizePartners, checkRequiredEnvVariables, responseSuccessHandler, responseErrorHandler, + batchResponseErrorHandler, }; diff --git a/lib/utils/textPropertyUtils.js b/lib/utils/textPropertyUtils.js new file mode 100644 index 00000000..3d31646a --- /dev/null +++ b/lib/utils/textPropertyUtils.js @@ -0,0 +1,197 @@ +const fs = require("fs"); +const path = require("path"); +const yaml = require("yaml"); +const { consola } = require("consola"); +const fsUtils = require("./fsUtils"); + +/** + * Transform a YAML custom properties object into the Silverfin API format. + * Input: flat object with dot-notation keys (e.g. "namespace.key.subkey": value) + * Output: array of { namespace, key, value } objects + */ +function transformCustomToProperties(customData) { + const namespaceMap = new Map(); + + for (const [fullKey, value] of Object.entries(customData)) { + const keyParts = fullKey.split("."); + + if (keyParts.length < 2) { + consola.warn(`Skipping key "${fullKey}" — expected namespace.key format`); + continue; + } + + const namespace = keyParts[0]; + const key = keyParts[1]; + const namespaceKey = `${namespace}.${key}`; + + if (keyParts.length === 2) { + if (!namespaceMap.has(namespaceKey)) { + namespaceMap.set(namespaceKey, { namespace, key, value }); + } + } else { + if (!namespaceMap.has(namespaceKey)) { + namespaceMap.set(namespaceKey, { namespace, key, value: {} }); + } + const subKey = keyParts.slice(2).join("."); + namespaceMap.get(namespaceKey).value[subKey] = value; + } + } + + return Array.from(namespaceMap.values()); +} + +/** + * Resolve a YAML period key to a company period. + * Captured YAML keys are the period's fiscal_year.end_date when present, otherwise the + * period id (lib/dataCapture.js, lib/deepCapture.js). Several periods can share the same + * fiscal_year.end_date (e.g. monthly bookkeeping periods within one fiscal year); in that + * case the year-end period (own end_date === key) is the intended target. Refuses to pick + * an arbitrary period when the key stays ambiguous. + * Returns { period } on success, { error } with a reason otherwise. + */ +function findPeriodByKey(periodsArray, periodKey) { + const key = String(periodKey); + + const byId = periodsArray.find((p) => String(p.id) === key); + if (byId) return { period: byId }; + + const byFiscalYear = periodsArray.filter((p) => p.fiscal_year?.end_date === key); + if (byFiscalYear.length === 1) return { period: byFiscalYear[0] }; + if (byFiscalYear.length > 1) { + const yearEnd = byFiscalYear.filter((p) => p.end_date === key); + if (yearEnd.length === 1) return { period: yearEnd[0] }; + return { error: `Period key "${key}" is ambiguous: ${byFiscalYear.length} periods share that fiscal year end date` }; + } + + return { error: `Period "${key}" not found in company` }; +} + +/** + * Read the test file path referenced by a template's config.json ("test" key). + * Local read instead of fsUtils.readConfig: that helper creates a missing + * config.json as a side effect and exits the process on parse errors — both + * wrong while scanning across template folders. + */ +function readConfigTestPath(baseDir, dir) { + try { + const config = JSON.parse(fs.readFileSync(path.join(baseDir, dir, "config.json"), "utf-8")); + return config.test || null; + } catch { + return null; + } +} + +/** + * Find a test by name in the templates' liquid test YAML files. + * Per template, only ONE file is considered: the one referenced by the template's + * config.json "test" key (the same file run-test uses), or — when fileName is + * provided — exactly tests/, so year variants (e.g. *_TY25_*.yml) can be + * selected explicitly. If handle is provided, only that template folder is searched. + * When the test name matches in several templates, the match is refused instead of + * guessing: the candidates are listed and --handle is requested. + * Extracts custom data from all 4 levels: company, period, reconciliation, account. + * Returns { file, handle, company, periods } where periods contains per-period custom, + * reconciliation custom, and account custom data. + */ +function findTestData(testName, handle, fileName) { + const templateType = "reconciliationText"; + const baseDir = path.join(process.cwd(), fsUtils.FOLDERS[templateType]); + + if (!fs.existsSync(baseDir)) { + consola.error(`Directory not found: ${fsUtils.FOLDERS[templateType]}`); + process.exit(1); + } + + const handleDirs = handle ? [handle] : fs.readdirSync(baseDir).filter((entry) => { + return fs.statSync(path.join(baseDir, entry)).isDirectory(); + }); + + const matches = []; + for (const dir of handleDirs) { + const relativeTestPath = fileName ? path.join("tests", fileName) : readConfigTestPath(baseDir, dir); + if (!relativeTestPath) continue; + + const filePath = path.join(baseDir, dir, relativeTestPath); + if (!fs.existsSync(filePath)) continue; + + const content = fs.readFileSync(filePath, "utf-8"); + let parsed; + try { + parsed = yaml.parse(content, { maxAliasCount: 10000, merge: true }); + } catch (error) { + consola.warn(`Skipping malformed YAML file "${filePath}": ${error.message}`); + continue; + } + + if (!parsed || !parsed[testName]) continue; + + matches.push({ file: path.basename(relativeTestPath), handle: dir, testData: parsed[testName] }); + } + + if (matches.length === 0) { + if (fileName) { + consola.error(`Test "${testName}" not found in any YAML file named "${fileName}"`); + } else { + consola.error( + `Test "${testName}" not found in any test file referenced by a template's config.json. Use --file to read a specific YAML file instead (e.g. a year variant not referenced in config.json).` + ); + } + process.exit(1); + } + + if (matches.length > 1) { + consola.error(`Test "${testName}" found in multiple templates:`); + for (const match of matches) { + consola.error(` - ${match.handle}/tests/${match.file}`); + } + consola.error("Re-run with --handle to select one."); + process.exit(1); + } + + const { file, handle: dir, testData } = matches[0]; + const result = { file, handle: dir, company: null, periods: {} }; + + // Company-level custom + if (testData?.data?.company?.custom) { + result.company = { custom: testData.data.company.custom }; + } + + // Period-level data + const periods = testData?.data?.periods; + if (periods) { + for (const [periodKey, periodData] of Object.entries(periods)) { + if (!periodData) continue; + + const periodEntry = { custom: null, reconciliations: {}, accounts: {} }; + + // Period-level custom + if (periodData.custom) { + periodEntry.custom = periodData.custom; + } + + // Reconciliation-level custom + if (periodData.reconciliations) { + for (const [reconHandle, reconData] of Object.entries(periodData.reconciliations)) { + if (reconData?.custom) { + periodEntry.reconciliations[reconHandle] = reconData.custom; + } + } + } + + // Account-level custom + if (periodData.accounts) { + for (const [accountNumber, accountData] of Object.entries(periodData.accounts)) { + if (accountData?.custom) { + periodEntry.accounts[accountNumber] = accountData.custom; + } + } + } + + result.periods[periodKey] = periodEntry; + } + } + + return result; +} + +module.exports = { transformCustomToProperties, findTestData, findPeriodByKey }; diff --git a/package-lock.json b/package-lock.json index 2f2f721c..0137fb44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "silverfin-cli", - "version": "1.56.3", + "version": "1.56.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "silverfin-cli", - "version": "1.56.3", + "version": "1.56.4", "license": "MIT", "dependencies": { "axios": "^1.6.2", diff --git a/package.json b/package.json index ce4d566e..ebfa4310 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "silverfin-cli", - "version": "1.56.3", + "version": "1.56.4", "description": "Command line tool for Silverfin template development", "main": "index.js", "license": "MIT", diff --git a/tests/lib/utils/textPropertyUtils.test.js b/tests/lib/utils/textPropertyUtils.test.js new file mode 100644 index 00000000..16c286ce --- /dev/null +++ b/tests/lib/utils/textPropertyUtils.test.js @@ -0,0 +1,246 @@ +jest.mock("fs"); +jest.mock("consola"); +jest.mock("yaml"); + +const fs = require("fs"); +const yaml = require("yaml"); +const { consola } = require("consola"); +const { transformCustomToProperties, findTestData, findPeriodByKey } = require("../../../lib/utils/textPropertyUtils"); + +describe("textPropertyUtils", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("transformCustomToProperties", () => { + it("maps a simple namespace.key to a flat property", () => { + const result = transformCustomToProperties({ "ns.key": "value" }); + expect(result).toEqual([{ namespace: "ns", key: "key", value: "value" }]); + }); + + it("maps a nested namespace.key.subkey to an object value", () => { + const result = transformCustomToProperties({ "ns.key.sub": "value" }); + expect(result).toEqual([{ namespace: "ns", key: "key", value: { sub: "value" } }]); + }); + + it("merges multiple subkeys under the same namespace.key", () => { + const result = transformCustomToProperties({ "ns.key.a": "1", "ns.key.b": "2" }); + expect(result).toEqual([{ namespace: "ns", key: "key", value: { a: "1", b: "2" } }]); + }); + + it("collapses deeper-than-3 segment keys into a single dotted subkey", () => { + // Documented as a 3-segment-max assumption in practice; this locks current behaviour. + const result = transformCustomToProperties({ "ns.key.a.b": "v" }); + expect(result).toEqual([{ namespace: "ns", key: "key", value: { "a.b": "v" } }]); + }); + + it("warns and skips keys without a namespace.key shape", () => { + const result = transformCustomToProperties({ single: "value" }); + expect(result).toEqual([]); + expect(consola.warn).toHaveBeenCalled(); + }); + }); + + describe("findTestData", () => { + const yamlPayload = { + my_test: { + data: { + company: { custom: { "co.k": "cv" } }, + periods: { + "2024-12-31": { + custom: { "p.k": "pv" }, + reconciliations: { my_handle: { custom: { "r.k": "rv" } } }, + accounts: { "610000": { custom: { "a.k": "av" } } }, + }, + }, + }, + }, + }; + + // Simulate a repo layout: each handle dir has a config.json whose "test" key + // points at the canonical YAML file, mirroring how run-test resolves it. + const mockRepo = (configs, yamlContents) => { + fs.existsSync.mockImplementation((p) => { + const file = String(p); + if (file.endsWith("config.json")) return true; + if (file.endsWith(".yml")) return Object.keys(yamlContents).some((name) => file.endsWith(name)); + return true; // base dir and everything else + }); + fs.statSync.mockReturnValue({ isDirectory: () => true }); + fs.readdirSync.mockReturnValue(Object.keys(configs)); + fs.readFileSync.mockImplementation((p) => { + const file = String(p); + if (file.endsWith("config.json")) { + const dir = Object.keys(configs).find((d) => file.includes(`/${d}/`)); + return JSON.stringify(configs[dir] ?? {}); + } + return "yaml-content:" + file; + }); + yaml.parse.mockImplementation((content) => { + const name = Object.keys(yamlContents).find((n) => String(content).endsWith(n)); + const payload = yamlContents[name]; + if (payload instanceof Error) throw payload; + return payload; + }); + }; + + it("extracts custom data at all four levels from the config-referenced file when scoped by handle", () => { + mockRepo({ my_handle: { test: "tests/my_handle_liquid_test.yml" } }, { "my_handle_liquid_test.yml": yamlPayload }); + + const result = findTestData("my_test", "my_handle"); + + expect(result.handle).toBe("my_handle"); + expect(result.file).toBe("my_handle_liquid_test.yml"); + expect(result.company).toEqual({ custom: { "co.k": "cv" } }); + expect(result.periods["2024-12-31"].custom).toEqual({ "p.k": "pv" }); + expect(result.periods["2024-12-31"].reconciliations.my_handle).toEqual({ "r.k": "rv" }); + expect(result.periods["2024-12-31"].accounts["610000"]).toEqual({ "a.k": "av" }); + }); + + it("scans all templates' config-referenced test files when no handle is given", () => { + mockRepo( + { + handle_a: { test: "tests/handle_a_liquid_test.yml" }, + handle_b: { test: "tests/handle_b_liquid_test.yml" }, + }, + { + "handle_a_liquid_test.yml": { some_other_test: {} }, + "handle_b_liquid_test.yml": { my_test: { data: { periods: {} } } }, + } + ); + + const result = findTestData("my_test"); + + expect(result.handle).toBe("handle_b"); + expect(result.file).toBe("handle_b_liquid_test.yml"); + }); + + it("skips a template with a malformed YAML file and continues with the others", () => { + mockRepo( + { + handle_a: { test: "tests/bad_liquid_test.yml" }, + handle_b: { test: "tests/good_liquid_test.yml" }, + }, + { + "bad_liquid_test.yml": new Error("bad indentation"), + "good_liquid_test.yml": { my_test: { data: { periods: {} } } }, + } + ); + + const result = findTestData("my_test"); + + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining("malformed YAML")); + expect(result.file).toBe("good_liquid_test.yml"); + }); + + it("skips templates whose config.json has no test key", () => { + mockRepo( + { + handle_a: {}, + handle_b: { test: "tests/handle_b_liquid_test.yml" }, + }, + { "handle_b_liquid_test.yml": { my_test: { data: { periods: {} } } } } + ); + + const result = findTestData("my_test"); + + expect(result.handle).toBe("handle_b"); + }); + + it("errors and exits when the test name is not found, pointing at --file", () => { + mockRepo({ my_handle: { test: "tests/my_handle_liquid_test.yml" } }, { "my_handle_liquid_test.yml": { some_other_test: {} } }); + const exitSpy = jest.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + expect(() => findTestData("missing_test", "my_handle")).toThrow("process.exit"); + expect(consola.error).toHaveBeenCalledWith(expect.stringContaining("--file")); + + exitSpy.mockRestore(); + }); + + it("refuses to guess when the test name exists in multiple templates", () => { + const payload = { my_test: { data: { periods: {} } } }; + mockRepo( + { + handle_a: { test: "tests/handle_a_liquid_test.yml" }, + handle_b: { test: "tests/handle_b_liquid_test.yml" }, + }, + { "handle_a_liquid_test.yml": payload, "handle_b_liquid_test.yml": payload } + ); + const exitSpy = jest.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + expect(() => findTestData("my_test")).toThrow("process.exit"); + expect(consola.error).toHaveBeenCalledWith(expect.stringContaining("multiple templates")); + expect(consola.error).toHaveBeenCalledWith(expect.stringContaining("handle_a/tests/handle_a_liquid_test.yml")); + expect(consola.error).toHaveBeenCalledWith(expect.stringContaining("--handle")); + + exitSpy.mockRestore(); + }); + + it("reads the exact tests/ when fileName is given, overriding config.json", () => { + mockRepo( + { my_handle: { test: "tests/my_handle_liquid_test.yml" } }, + { + "my_handle_liquid_test.yml": { my_test: { data: { periods: { "2023-12-31": {} } } } }, + "my_handle_TY25_liquid_test.yml": { my_test: { data: { periods: { "2024-12-31": {} } } } }, + } + ); + + const result = findTestData("my_test", "my_handle", "my_handle_TY25_liquid_test.yml"); + + expect(result.file).toBe("my_handle_TY25_liquid_test.yml"); + expect(Object.keys(result.periods)).toEqual(["2024-12-31"]); + expect(yaml.parse).toHaveBeenCalledTimes(1); + }); + + it("errors when fileName matches no file containing the test", () => { + mockRepo({ my_handle: { test: "tests/my_handle_liquid_test.yml" } }, { "my_handle_liquid_test.yml": { my_test: {} } }); + const exitSpy = jest.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + expect(() => findTestData("my_test", "my_handle", "does_not_exist.yml")).toThrow("process.exit"); + expect(consola.error).toHaveBeenCalledWith(expect.stringContaining('named "does_not_exist.yml"')); + + exitSpy.mockRestore(); + }); + }); + + describe("findPeriodByKey", () => { + const yearEnd2024 = { id: 101, end_date: "2024-12-31", fiscal_year: { end_date: "2024-12-31" } }; + const monthly202401 = { id: 102, end_date: "2024-01-31", fiscal_year: { end_date: "2024-12-31" } }; + const monthly202402 = { id: 103, end_date: "2024-02-29", fiscal_year: { end_date: "2024-12-31" } }; + const noFiscalYear = { id: 205, end_date: "2025-03-31", fiscal_year: null }; + + it("resolves a unique fiscal year end date", () => { + const { period, error } = findPeriodByKey([monthly202401, { id: 300, end_date: "2023-12-31", fiscal_year: { end_date: "2023-12-31" } }], "2023-12-31"); + expect(error).toBeUndefined(); + expect(period.id).toBe(300); + }); + + it("prefers the year-end period when several periods share a fiscal year end date", () => { + const { period } = findPeriodByKey([monthly202401, monthly202402, yearEnd2024], "2024-12-31"); + expect(period.id).toBe(101); + }); + + it("resolves an id-based key for periods without a fiscal year", () => { + const { period } = findPeriodByKey([yearEnd2024, noFiscalYear], "205"); + expect(period.id).toBe(205); + }); + + it("returns an error instead of picking an arbitrary period when the key stays ambiguous", () => { + const { period, error } = findPeriodByKey([monthly202401, monthly202402], "2024-12-31"); + expect(period).toBeUndefined(); + expect(error).toContain("ambiguous"); + }); + + it("returns an error when no period matches", () => { + const { period, error } = findPeriodByKey([yearEnd2024], "2030-12-31"); + expect(period).toBeUndefined(); + expect(error).toContain("not found"); + }); + }); +});