|
6 | 6 | validateFlowTriggerReadiness as validateFlowTriggerReadinessUnrecorded, |
7 | 7 | FLOW_TRIGGER_UNKNOWN_OBJECT, |
8 | 8 | FLOW_INPUT_UNKNOWN_OBJECT, |
| 9 | + FLOW_INPUT_OBJECT_LESS, |
| 10 | + FLOW_INPUT_RECORD_READ_AS_LIST, |
9 | 11 | FLOW_DRAFT_STATUS_AMBIGUOUS, |
10 | 12 | FLOW_TRIGGER_UNKNOWN_EVENT, |
11 | 13 | FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, |
@@ -1618,6 +1620,49 @@ describe('validateFlowTriggerReadiness', () => { |
1618 | 1620 | flows: [recordFlow()], |
1619 | 1621 | }, |
1620 | 1622 | ], |
| 1623 | + // A declared start input that can never work as written: advisory. |
| 1624 | + [ |
| 1625 | + FLOW_INPUT_OBJECT_LESS, |
| 1626 | + 'warning', |
| 1627 | + { |
| 1628 | + objects: [candidateObject], |
| 1629 | + flows: [ |
| 1630 | + { |
| 1631 | + name: 'object_less_input', |
| 1632 | + type: 'screen', |
| 1633 | + status: 'active', |
| 1634 | + variables: [{ name: 'recordId', type: 'text', isInput: true, objectName: 'global' }], |
| 1635 | + nodes: [{ id: 'start', type: 'start' }], |
| 1636 | + }, |
| 1637 | + ], |
| 1638 | + }, |
| 1639 | + ], |
| 1640 | + [ |
| 1641 | + FLOW_INPUT_RECORD_READ_AS_LIST, |
| 1642 | + 'warning', |
| 1643 | + { |
| 1644 | + objects: [candidateObject], |
| 1645 | + flows: [ |
| 1646 | + { |
| 1647 | + name: 'one_id_read_as_list', |
| 1648 | + type: 'screen', |
| 1649 | + status: 'active', |
| 1650 | + variables: [{ name: 'candidateIds', type: 'text', isInput: true, objectName: 'app_candidate' }], |
| 1651 | + nodes: [ |
| 1652 | + { id: 'start', type: 'start' }, |
| 1653 | + { |
| 1654 | + id: 'load', |
| 1655 | + type: 'get_record', |
| 1656 | + config: { |
| 1657 | + objectName: 'app_candidate', |
| 1658 | + filter: { id: { $in: { dialect: 'cel', source: 'candidateIds' } } }, |
| 1659 | + }, |
| 1660 | + }, |
| 1661 | + ], |
| 1662 | + }, |
| 1663 | + ], |
| 1664 | + }, |
| 1665 | + ], |
1621 | 1666 | ]; |
1622 | 1667 |
|
1623 | 1668 | for (const [rule, severity, stack] of provoke) { |
@@ -1995,6 +2040,128 @@ describe('[#30009] api trigger readiness reads the hook\'s one verifier (config. |
1995 | 2040 | }); |
1996 | 2041 | }); |
1997 | 2042 |
|
| 2043 | +describe('a start input whose record declaration can never work as written', () => { |
| 2044 | + const cel = (source: string) => ({ dialect: 'cel', source }); |
| 2045 | + const getRow = (filter: Record<string, unknown>, label = 'Load candidates') => ({ |
| 2046 | + id: 'load', |
| 2047 | + type: 'get_record', |
| 2048 | + label, |
| 2049 | + config: { objectName: 'app_candidate', filter }, |
| 2050 | + }); |
| 2051 | + const inputFlow = (variables: unknown[], nodes: unknown[] = []) => ({ |
| 2052 | + name: 'review_candidates', |
| 2053 | + type: 'screen', |
| 2054 | + status: 'active', |
| 2055 | + variables, |
| 2056 | + nodes: [{ id: 'start', type: 'start' }, ...nodes], |
| 2057 | + }); |
| 2058 | + const lint = (flow: Record<string, unknown>, objects: unknown[] = [candidateObject]) => |
| 2059 | + validateFlowTriggerReadiness({ objects, flows: [flow] }); |
| 2060 | + |
| 2061 | + describe(`${FLOW_INPUT_OBJECT_LESS} — objectName names the object-less placeholder`, () => { |
| 2062 | + it("warns on 'global', at the entry the author wrote, beside the unknown-object verdict it already had", () => { |
| 2063 | + const findings = lint(inputFlow([ |
| 2064 | + { name: 'note', type: 'text', isInput: true }, |
| 2065 | + { name: 'recordId', type: 'text', isInput: true, objectName: 'global' }, |
| 2066 | + ])); |
| 2067 | + expect(findings.map((f) => [f.rule, f.severity, f.where, f.path])).toEqual([ |
| 2068 | + [FLOW_INPUT_UNKNOWN_OBJECT, 'warning', 'flow "review_candidates" › input "recordId"', 'flows[0].variables[1].objectName'], |
| 2069 | + [FLOW_INPUT_OBJECT_LESS, 'warning', 'flow "review_candidates" › input "recordId"', 'flows[0].variables[1].objectName'], |
| 2070 | + ]); |
| 2071 | + const objectLess = findings[1]; |
| 2072 | + expect(objectLess.message).toBe( |
| 2073 | + "declares input 'recordId' a record of 'global', the object-less placeholder, so no start loads it " + |
| 2074 | + 'from any object and its value reaches the run unjudged', |
| 2075 | + ); |
| 2076 | + expect(objectLess.hint).not.toContain('\n'); |
| 2077 | + }); |
| 2078 | + |
| 2079 | + it("warns even where the stack defines an object named 'global': the load skips the key, not the object", () => { |
| 2080 | + const findings = lint( |
| 2081 | + inputFlow([{ name: 'recordId', type: 'text', isInput: true, objectName: 'global' }]), |
| 2082 | + [candidateObject, { name: 'global', label: 'Global', fields: {} }], |
| 2083 | + ); |
| 2084 | + expect(findings.map((f) => [f.rule, f.path])).toEqual([[FLOW_INPUT_OBJECT_LESS, 'flows[0].variables[0].objectName']]); |
| 2085 | + }); |
| 2086 | + |
| 2087 | + it("warns on the other object-less key, '*', which only a pre-parse stack can carry", () => { |
| 2088 | + const findings = lint(inputFlow([{ name: 'recordId', type: 'text', isInput: true, objectName: '*' }])); |
| 2089 | + expect(findings.filter((f) => f.rule === FLOW_INPUT_OBJECT_LESS).map((f) => f.path)).toEqual([ |
| 2090 | + 'flows[0].variables[0].objectName', |
| 2091 | + ]); |
| 2092 | + }); |
| 2093 | + |
| 2094 | + it('is silent on a declared object the stack defines', () => { |
| 2095 | + expect(lint(inputFlow([{ name: 'recordId', type: 'text', isInput: true, objectName: 'app_candidate' }]))).toEqual([]); |
| 2096 | + }); |
| 2097 | + }); |
| 2098 | + |
| 2099 | + describe(`${FLOW_INPUT_RECORD_READ_AS_LIST} — one declared id the flow reads as a list`, () => { |
| 2100 | + const declared = { name: 'candidateIds', type: 'text', isInput: true, objectName: 'app_candidate' }; |
| 2101 | + |
| 2102 | + it('warns at the $in read, naming the input, the node and both objects', () => { |
| 2103 | + const findings = lint(inputFlow([declared], [getRow({ id: { $in: cel('candidateIds') } })])); |
| 2104 | + expect(findings.map((f) => [f.rule, f.severity, f.where, f.path])).toEqual([ |
| 2105 | + [ |
| 2106 | + FLOW_INPUT_RECORD_READ_AS_LIST, |
| 2107 | + 'warning', |
| 2108 | + 'flow "review_candidates" › input "candidateIds"', |
| 2109 | + 'flows[0].nodes[1].config.filter.id.$in', |
| 2110 | + ], |
| 2111 | + ]); |
| 2112 | + expect(findings[0].message).toBe( |
| 2113 | + "declares input 'candidateIds' one record id of 'app_candidate', and get_record \"Load candidates\" " + |
| 2114 | + "reads it as a list of 'app_candidate' ids (filter.id.$in), so no value is both and every start " + |
| 2115 | + 'that supplies the input is refused', |
| 2116 | + ); |
| 2117 | + expect(findings[0].hint).not.toContain('\n'); |
| 2118 | + }); |
| 2119 | + |
| 2120 | + it('reads a `vars.` hand-off and a node nested in a region, located at the nested node', () => { |
| 2121 | + const findings = lint(inputFlow([declared], [ |
| 2122 | + { |
| 2123 | + id: 'guard', |
| 2124 | + type: 'try_catch', |
| 2125 | + config: { |
| 2126 | + try: { nodes: [{ id: 'drop', type: 'delete_record', config: { objectName: 'app_candidate', filter: { id: { $in: cel('vars.candidateIds') } } } }] }, |
| 2127 | + catch: { nodes: [] }, |
| 2128 | + }, |
| 2129 | + }, |
| 2130 | + ])); |
| 2131 | + expect(findings.map((f) => [f.rule, f.path])).toEqual([ |
| 2132 | + [FLOW_INPUT_RECORD_READ_AS_LIST, 'flows[0].nodes[1].config.try.nodes[0].config.filter.id.$in'], |
| 2133 | + ]); |
| 2134 | + expect(findings[0].message).toContain('delete_record "drop"'); |
| 2135 | + }); |
| 2136 | + |
| 2137 | + it('is silent where admission reads one id, or does not read the declared input at all', () => { |
| 2138 | + const silent: Array<[string, Record<string, unknown>]> = [ |
| 2139 | + ['filter.id is the input', inputFlow([declared], [getRow({ id: cel('candidateIds') })])], |
| 2140 | + ['filter.id.$eq is the input', inputFlow([declared], [getRow({ id: { $eq: cel('candidateIds') } })])], |
| 2141 | + ['the $in reads an input with no declaration', inputFlow( |
| 2142 | + [{ name: 'candidateIds', type: 'text', isInput: true }], |
| 2143 | + [getRow({ id: { $in: cel('candidateIds') } })], |
| 2144 | + )], |
| 2145 | + ['the declared variable is not an input', inputFlow( |
| 2146 | + [{ ...declared, isInput: false }], |
| 2147 | + [getRow({ id: { $in: cel('candidateIds') } })], |
| 2148 | + )], |
| 2149 | + ['the $in source is computed from the input', inputFlow([declared], [getRow({ id: { $in: cel('[candidateIds]') } })])], |
| 2150 | + ['a loop rebinds the name before the read', inputFlow([declared], [ |
| 2151 | + { id: 'each', type: 'loop', config: { collection: cel('rows'), iteratorVariable: 'candidateIds', body: { nodes: [] } } }, |
| 2152 | + getRow({ id: { $in: cel('candidateIds') } }), |
| 2153 | + ])], |
| 2154 | + ['the node is not a row-addressing CRUD node', inputFlow([declared], [ |
| 2155 | + { id: 'mk', type: 'create_record', config: { objectName: 'app_candidate', filter: { id: { $in: cel('candidateIds') } } } }, |
| 2156 | + ])], |
| 2157 | + ]; |
| 2158 | + for (const [label, flow] of silent) { |
| 2159 | + expect(lint(flow).filter((f) => f.rule === FLOW_INPUT_RECORD_READ_AS_LIST), label).toEqual([]); |
| 2160 | + } |
| 2161 | + }); |
| 2162 | + }); |
| 2163 | +}); |
| 2164 | + |
1998 | 2165 | describe('[#22161] one-line verdicts — the never-fire family', () => { |
1999 | 2166 | it('every verdict the cases above fired is one line of at most 200 characters', () => { |
2000 | 2167 | // The coverage control first: each of the five ids fired at least once, so |
|
0 commit comments