|
| 1 | +// Tests for round-5 bug fixes (issues #9, #15, #17 — 2026-05-28): |
| 2 | +// §1 — #9: parseScaffoldingTables empty visibleTableOrder short-circuits before tableById |
| 3 | +// §2 — #15: formulaTextParsed is the live key; fallback chain must check it first |
| 4 | +// §3 — #17: resolveField exposes existing typeOptions for update_formula_field merge |
| 5 | + |
| 6 | +import { describe, it } from 'node:test'; |
| 7 | +import assert from 'node:assert/strict'; |
| 8 | +import { AirtableClient } from '../src/client.js'; |
| 9 | + |
| 10 | +function createMockAuth(schemaResponse) { |
| 11 | + return { |
| 12 | + getSecretSocketId: () => null, |
| 13 | + get: () => ({ |
| 14 | + ok: true, |
| 15 | + status: 200, |
| 16 | + json: async () => schemaResponse, |
| 17 | + text: async () => JSON.stringify(schemaResponse), |
| 18 | + }), |
| 19 | + postForm: () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}' }), |
| 20 | + }; |
| 21 | +} |
| 22 | + |
| 23 | +// ─── §1 — parseScaffoldingTables ────────────────────────────────────────────── |
| 24 | + |
| 25 | +describe('#9 — parseScaffoldingTables', () => { |
| 26 | + const auth = createMockAuth({}); |
| 27 | + const client = new AirtableClient(auth); |
| 28 | + const ps = d => client.parseScaffoldingTables(d); |
| 29 | + |
| 30 | + it('returns tableSchemas when present and non-empty', () => { |
| 31 | + const d = { tableSchemas: [{ id: 'tbl1', name: 'A' }] }; |
| 32 | + assert.deepEqual(ps(d), [{ id: 'tbl1', name: 'A' }]); |
| 33 | + }); |
| 34 | + |
| 35 | + it('returns tables when tableSchemas absent', () => { |
| 36 | + const d = { tables: [{ id: 'tbl1', name: 'B' }] }; |
| 37 | + assert.deepEqual(ps(d), [{ id: 'tbl1', name: 'B' }]); |
| 38 | + }); |
| 39 | + |
| 40 | + it('empty tableSchemas [] does NOT short-circuit — falls through to visibleTableOrder', () => { |
| 41 | + const d = { |
| 42 | + tableSchemas: [], |
| 43 | + visibleTableOrder: ['tbl1'], |
| 44 | + tableById: { tbl1: { name: 'C' } }, |
| 45 | + }; |
| 46 | + const result = ps(d); |
| 47 | + assert.equal(result.length, 1); |
| 48 | + assert.equal(result[0].id, 'tbl1'); |
| 49 | + assert.equal(result[0].name, 'C'); |
| 50 | + }); |
| 51 | + |
| 52 | + it('empty visibleTableOrder [] does NOT short-circuit (#9 regression) — falls through to tableById', () => { |
| 53 | + const d = { |
| 54 | + visibleTableOrder: [], // empty — was returning [] instead of falling through |
| 55 | + tableById: { |
| 56 | + tbl1: { name: 'Table One' }, |
| 57 | + tbl2: { name: 'Table Two' }, |
| 58 | + }, |
| 59 | + }; |
| 60 | + const result = ps(d); |
| 61 | + assert.equal(result.length, 2, 'should return all tables from tableById, not []'); |
| 62 | + }); |
| 63 | + |
| 64 | + it('non-empty visibleTableOrder maps to tableById entries in order', () => { |
| 65 | + const d = { |
| 66 | + visibleTableOrder: ['tbl2', 'tbl1'], |
| 67 | + tableById: { |
| 68 | + tbl1: { name: 'Alpha' }, |
| 69 | + tbl2: { name: 'Beta' }, |
| 70 | + }, |
| 71 | + }; |
| 72 | + const result = ps(d); |
| 73 | + assert.equal(result[0].id, 'tbl2'); |
| 74 | + assert.equal(result[0].name, 'Beta'); |
| 75 | + assert.equal(result[1].id, 'tbl1'); |
| 76 | + assert.equal(result[1].name, 'Alpha'); |
| 77 | + }); |
| 78 | + |
| 79 | + it('no order arrays at all — falls back to Object.values(tableById)', () => { |
| 80 | + const d = { |
| 81 | + tableById: { |
| 82 | + tblA: { name: 'Standalone' }, |
| 83 | + }, |
| 84 | + }; |
| 85 | + const result = ps(d); |
| 86 | + assert.equal(result.length, 1); |
| 87 | + assert.equal(result[0].name, 'Standalone'); |
| 88 | + }); |
| 89 | + |
| 90 | + it('null / undefined data returns []', () => { |
| 91 | + assert.deepEqual(ps(null), []); |
| 92 | + assert.deepEqual(ps(undefined), []); |
| 93 | + assert.deepEqual(ps({}), []); |
| 94 | + }); |
| 95 | +}); |
| 96 | + |
| 97 | +// ─── §2 — formulaTextParsed fallback (#15) ─────────────────────────────────── |
| 98 | + |
| 99 | +describe('#15 — resolveField returns formulaTextParsed for formula fields', () => { |
| 100 | + it('resolveField exposes typeOptions.formulaTextParsed as the live key', async () => { |
| 101 | + const schema = { |
| 102 | + data: { |
| 103 | + tableSchemas: [{ |
| 104 | + id: 'tblX', |
| 105 | + name: 'T', |
| 106 | + columns: [{ |
| 107 | + id: 'fldFormula', |
| 108 | + name: 'Calc', |
| 109 | + type: 'formula', |
| 110 | + typeOptions: { |
| 111 | + formulaTextParsed: 'IF({column_value_fld123}, 1, 0)', |
| 112 | + resultType: 'number', |
| 113 | + }, |
| 114 | + }], |
| 115 | + views: [], |
| 116 | + }], |
| 117 | + }, |
| 118 | + }; |
| 119 | + const client = new AirtableClient(createMockAuth(schema)); |
| 120 | + const { field } = await client.resolveField('appX', 'fldFormula'); |
| 121 | + // The fallback chain in download_formula_field should find this key first |
| 122 | + assert.equal( |
| 123 | + field.typeOptions?.formulaTextParsed, |
| 124 | + 'IF({column_value_fld123}, 1, 0)', |
| 125 | + ); |
| 126 | + }); |
| 127 | + |
| 128 | + it('resolveField still returns typeOptions.formulaText for older fields', async () => { |
| 129 | + const schema = { |
| 130 | + data: { |
| 131 | + tableSchemas: [{ |
| 132 | + id: 'tblX', |
| 133 | + name: 'T', |
| 134 | + columns: [{ |
| 135 | + id: 'fldOld', |
| 136 | + name: 'OldCalc', |
| 137 | + type: 'formula', |
| 138 | + typeOptions: { |
| 139 | + formulaText: 'NOW()', |
| 140 | + resultType: 'date', |
| 141 | + }, |
| 142 | + }], |
| 143 | + views: [], |
| 144 | + }], |
| 145 | + }, |
| 146 | + }; |
| 147 | + const client = new AirtableClient(createMockAuth(schema)); |
| 148 | + const { field } = await client.resolveField('appX', 'fldOld'); |
| 149 | + assert.equal(field.typeOptions?.formulaText, 'NOW()'); |
| 150 | + }); |
| 151 | +}); |
| 152 | + |
| 153 | +// ─── §3 — resolveField provides existing typeOptions for update_formula_field merge (#17) ── |
| 154 | + |
| 155 | +describe('#17 — resolveField exposes existing typeOptions for merge before formula update', () => { |
| 156 | + it('formula field with format typeOptions is accessible via resolveField', async () => { |
| 157 | + const schema = { |
| 158 | + data: { |
| 159 | + tableSchemas: [{ |
| 160 | + id: 'tblX', |
| 161 | + name: 'T', |
| 162 | + columns: [{ |
| 163 | + id: 'fldPct', |
| 164 | + name: 'Pct', |
| 165 | + type: 'formula', |
| 166 | + typeOptions: { |
| 167 | + formulaText: 'SUM(1,2)', |
| 168 | + resultType: 'percent', |
| 169 | + percentV2: true, |
| 170 | + precision: 0, |
| 171 | + }, |
| 172 | + }], |
| 173 | + views: [], |
| 174 | + }], |
| 175 | + }, |
| 176 | + }; |
| 177 | + const client = new AirtableClient(createMockAuth(schema)); |
| 178 | + const { field } = await client.resolveField('appX', 'fldPct'); |
| 179 | + const existing = field?.typeOptions || {}; |
| 180 | + // Simulates what update_formula_field does: merge existing into new formula update |
| 181 | + const merged = { ...existing, formulaText: 'SUM(3,4)' }; |
| 182 | + assert.equal(merged.resultType, 'percent', 'resultType must survive merge'); |
| 183 | + assert.equal(merged.precision, 0, 'precision must survive merge'); |
| 184 | + assert.equal(merged.percentV2, true, 'percentV2 must survive merge'); |
| 185 | + assert.equal(merged.formulaText, 'SUM(3,4)', 'formulaText must be updated'); |
| 186 | + }); |
| 187 | +}); |
0 commit comments