diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..34ec141 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,59 @@ +version: 2 + +updates: + # --------------------------------------------------------------------------------------- + # npm dependencies + # --------------------------------------------------------------------------------------- + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + time: '07:00' + open-pull-requests-limit: 5 + labels: + - dependencies + commit-message: + prefix: chore + include: scope + groups: + # One PR for the routine noise. Majors stay separate — @google-cloud/bigquery v9 + # raised the Node floor, which is a consumer-visible break that deserves its own + # review rather than being buried in a batch. + minor-and-patch: + patterns: + - '*' + update-types: + - minor + - patch + ignore: + # The SDK is a peer dependency: consumers pick the version, and the declared range + # (^4.1.103) already admits every 4.x release. A lockfile bump here does not change + # what consumers install, so weekly PRs for it would be pure noise. Refresh it + # deliberately with `npm update ultra_cart_rest_api_v2` instead. + - dependency-name: ultra_cart_rest_api_v2 + + # --------------------------------------------------------------------------------------- + # GitHub Actions + # --------------------------------------------------------------------------------------- + # Workflows pin actions to commit SHAs. Dependabot understands SHA pins and bumps both the + # SHA and its trailing "# vX.Y.Z" comment, so pinning does not mean going stale. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: '07:00' + open-pull-requests-limit: 5 + labels: + - dependencies + - github-actions + commit-message: + prefix: ci + groups: + actions: + patterns: + - '*' + update-types: + - minor + - patch diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62b6cad..80b6a53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,19 +5,33 @@ on: branches: [main, master] pull_request: +# Scope the default GITHUB_TOKEN down to what the job actually needs, matching publish.yml. +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + # Actions are pinned to exact commit SHAs, not mutable tags: a tag can be repointed at + # new code, a SHA cannot. Dependabot (.github/dependabot.yml) keeps these current. + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: # Matches the engines floor in package.json. Testing the floor (not a newer # version) is what catches "works on 24, broken on 22". node-version: '22' - - run: npm ci || npm install - - name: Unit tests (transform, client, schema-drift logic) - run: npm test + + # `npm ci` only — never fall back to `npm install`, which would ignore the committed + # lockfile and silently test a different dependency tree than the one we ship. + - name: Install dependencies + run: npm ci + + - name: Unit tests with coverage thresholds + run: npm run test:coverage # Note: the live SDK <-> BigQuery schema-drift check is not run here. It needs warehouse # credentials this repo does not hold, and its previous incarnation used `secrets` inside a diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 555860e..58dfdf7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,14 +27,14 @@ jobs: steps: # Check out the tagged commit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.ref }} # Set up Node.js with the npm registry. # Node 24 ships an npm new enough for OIDC trusted publishing (>= 11.5.1). - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' registry-url: 'https://registry.npmjs.org' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b019601..b4123f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,9 +9,12 @@ extension. git clone https://github.com/UltraCart/rest_api_v2_sdk_javascript_bigquery cd rest_api_v2_sdk_javascript_bigquery npm install -npm test # runs the offline unit tests (no BigQuery access needed) +npm test # runs the offline unit tests (no BigQuery access needed) +npm run test:coverage # same tests, plus the coverage thresholds CI enforces ``` +Requires **Node.js 22 or newer** (matching `engines` in `package.json`). + The unit tests use synthetic BigQuery rows and run with no network or credentials, so they're safe to run anywhere. @@ -43,6 +46,9 @@ npm run check:schema -- --project=ultracart-dw- --dataset=ultracart_dw - **Add tests** for behavior changes. Keep them offline (mock the BigQuery client like the existing `test/client.test.js` does). - **Run `npm test`** before opening a PR; all tests must pass. +- **Keep coverage up.** CI runs `npm run test:coverage`, which fails below 99% lines / 90% + branches / 90% functions. If a change legitimately can't be covered offline, say why in + the PR rather than lowering the threshold. - **Schema drift:** if the SDK or warehouse schema legitimately changes the accepted set of SDK-only fields, refresh the baseline with `npm run baseline:schema -- --project=… --dataset=…` and explain why in the PR. diff --git a/package.json b/package.json index a2251ca..93cad5f 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "src/index.js", "scripts": { "test": "node --test", + "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=99 --test-coverage-branches=90 --test-coverage-functions=90", "check:schema": "node scripts/check-schema-drift.js", "baseline:schema": "node scripts/check-schema-drift.js --write-baseline" }, diff --git a/test/client.test.js b/test/client.test.js index 926eadd..1ecf034 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -3,7 +3,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { UltraCartBigQuery, DEFAULT_MAX_BYTES_BILLED } = require('../src/client'); +const { UltraCartBigQuery, DEFAULT_MAX_BYTES_BILLED, DEFAULT_PAGE_SIZE } = require('../src/client'); const UltraCartApi = require('ultra_cart_rest_api_v2'); const wrap = (value) => ({ value }); // mimic @google-cloud/bigquery scalar wrappers @@ -134,6 +134,71 @@ test('constructor maxBytesBilled:0 disables the default cap', async () => { assert.equal('maximumBytesBilled' in captured.jobConfig, false); }); +test('constructor requires a merchantId or an explicit projectId', () => { + // Without this the client would build a BigQuery instance with projectId undefined and + // fail much later, at query time, with an opaque error from the API. + assert.throws(() => new UltraCartBigQuery(), /merchantId or an explicit projectId/); + assert.throws(() => new UltraCartBigQuery({}), /merchantId or an explicit projectId/); + assert.throws( + () => new UltraCartBigQuery({ bigquery: {} }), + /merchantId or an explicit projectId/, + ); +}); + +test('explicit projectId overrides merchantId derivation', () => { + const ucbq = new UltraCartBigQuery({ + merchantId: 'DEMO', + projectId: 'ultracart-dw-override', + bigquery: {}, + }); + assert.equal(ucbq.projectId, 'ultracart-dw-override'); +}); + +test('query() without a model yields plain SDK-shaped objects', async () => { + const captured = {}; + const pages = [[{ order_id: 'P-1', creation_dts: wrap('2025-03-01 08:00:00') }]]; + const ucbq = new UltraCartBigQuery({ projectId: 'p', bigquery: makeFakeBigQuery({ pages, captured }) }); + + const [row] = await collect(ucbq.query('SELECT * FROM t')); + + assert.equal(row instanceof UltraCartApi.Order, false, 'no model -> not hydrated'); + assert.deepEqual(row, { order_id: 'P-1', creation_dts: '2025-03-01T08:00:00Z' }); + assert.equal(Object.getPrototypeOf(row), Object.prototype, 'a plain object'); +}); + +test('query() forwards named parameters to the job config', async () => { + // Named params are the library's safe alternative to string-interpolating SQL. If they + // stopped reaching createQueryJob, queries would fail or silently match nothing. + const captured = {}; + const ucbq = new UltraCartBigQuery({ projectId: 'p', bigquery: makeFakeBigQuery({ pages: [[]], captured }) }); + + const params = { since: '2025-01-01T00:00:00', merchant: 'DEMO' }; + await collect(ucbq.query('SELECT * FROM t WHERE creation_dts >= @since', { params })); + + assert.deepEqual(captured.jobConfig.params, params); + assert.equal(captured.jobConfig.query, 'SELECT * FROM t WHERE creation_dts >= @since'); +}); + +test('query() page size defaults to 50k and honors both override levels', async () => { + const captured = {}; + let ucbq = new UltraCartBigQuery({ projectId: 'p', bigquery: makeFakeBigQuery({ pages: [[]], captured }) }); + await collect(ucbq.query('SELECT 1')); + assert.equal(captured.pageQueries[0].maxResults, DEFAULT_PAGE_SIZE, 'default'); + assert.equal(captured.pageQueries[0].autoPaginate, false, 'manual pagination'); + + // constructor-level override + const captured2 = {}; + ucbq = new UltraCartBigQuery({ projectId: 'p', pageSize: 1000, bigquery: makeFakeBigQuery({ pages: [[]], captured: captured2 }) }); + await collect(ucbq.query('SELECT 1')); + assert.equal(captured2.pageQueries[0].maxResults, 1000); + + // per-query override wins over the constructor + const captured3 = {}; + ucbq = new UltraCartBigQuery({ projectId: 'p', pageSize: 1000, bigquery: makeFakeBigQuery({ pages: [[]], captured: captured3 }) }); + await collect(ucbq.query('SELECT 1', { pageSize: 25 })); + assert.equal(captured3.pageQueries[0].maxResults, 25); +}); + test('constructs a real @google-cloud/bigquery client when none is injected', () => { // Every other test injects a fake client, so nothing else would notice a breaking change // in @google-cloud/bigquery itself. This exercises the real constructor and the methods @@ -157,3 +222,26 @@ test('dryRun() estimates bytes/GB/cost without running', async () => { assert.equal(est.gigabytesProcessed, 2); assert.ok(Math.abs(est.estimatedCostUsd - (2 / 1024) * 6.25) < 1e-9); }); + +test('dryRun() reports zero when the job carries no byte statistics', async () => { + // A dry run against a fully-cached or trivial query can come back without + // totalBytesProcessed. Returning 0 beats NaN propagating into a cost estimate. + const bigquery = { + createQueryJob: async () => [{ metadata: {} }], + }; + const ucbq = new UltraCartBigQuery({ projectId: 'p', bigquery }); + const est = await ucbq.dryRun('SELECT 1'); + + assert.equal(est.totalBytesProcessed, 0); + assert.equal(est.gigabytesProcessed, 0); + assert.equal(est.estimatedCostUsd, 0); +}); + +test('dryRun() forwards named parameters', async () => { + const captured = {}; + const ucbq = new UltraCartBigQuery({ projectId: 'p', bigquery: makeFakeBigQuery({ pages: [[]], captured }) }); + await ucbq.dryRun('SELECT * FROM t WHERE id = @id', { params: { id: 'X-1' } }); + + assert.deepEqual(captured.jobConfig.params, { id: 'X-1' }); + assert.equal(captured.jobConfig.dryRun, true); +}); diff --git a/test/index.test.js b/test/index.test.js new file mode 100644 index 0000000..c7e6045 --- /dev/null +++ b/test/index.test.js @@ -0,0 +1,65 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const sdk = require('../src/index'); + +/** + * The published entry point. Anything reachable from here is public API for consumers of + * @ultracart/bigquery-sdk, so removing or renaming one of these is a breaking change even + * when every other test still passes. This pins the surface so that can't happen silently. + */ +const EXPECTED_EXPORTS = { + UltraCartBigQuery: 'function', + DEFAULT_MAX_BYTES_BILLED: 'number', + DEFAULT_PAGE_SIZE: 'number', + resolveDataset: 'function', + projectIdForMerchant: 'function', + DATASET_STANDARD: 'string', + DATASET_MEDIUM: 'string', + DATASET_HIGH: 'string', + DATASET_STREAMING: 'string', + transformRows: 'function', + toBigQueryDatetime: 'function', +}; + +test('index exposes exactly the documented public surface', () => { + for (const [name, type] of Object.entries(EXPECTED_EXPORTS)) { + assert.equal(typeof sdk[name], type, `export ${name} should be a ${type}`); + } + + // Catches accidental additions too — an unintended export is a maintenance commitment. + assert.deepEqual( + Object.keys(sdk).sort(), + Object.keys(EXPECTED_EXPORTS).sort(), + 'unexpected or missing export at the package entry point', + ); +}); + +test('the documented default constants hold their published values', () => { + // Both are quoted in the README and CHANGELOG; changing either is consumer-visible. + assert.equal(sdk.DEFAULT_MAX_BYTES_BILLED, 10 * 1024 * 1024 * 1024, '10 GB cost cap'); + assert.equal(sdk.DEFAULT_PAGE_SIZE, 50000); +}); + +test('entry-point exports are the same functions the modules export', () => { + // index.js re-exports by hand, so a typo could bind a name to the wrong implementation. + const project = require('../src/project'); + const transform = require('../src/transform'); + const { UltraCartBigQuery } = require('../src/client'); + + assert.equal(sdk.UltraCartBigQuery, UltraCartBigQuery); + assert.equal(sdk.resolveDataset, project.resolveDataset); + assert.equal(sdk.projectIdForMerchant, project.projectIdForMerchant); + assert.equal(sdk.transformRows, transform.transformRows); + assert.equal(sdk.toBigQueryDatetime, transform.toBigQueryDatetime); +}); + +test('the package entry point resolves to the tested module', () => { + // package.json "main" must point at what these tests exercise, or the published package + // could ship a different entry than the one under test. + const pkg = require('../package.json'); + assert.equal(pkg.main, 'src/index.js'); + assert.equal(require.resolve('../' + pkg.main), require.resolve('../src/index')); +}); diff --git a/test/project.test.js b/test/project.test.js new file mode 100644 index 0000000..6191b04 --- /dev/null +++ b/test/project.test.js @@ -0,0 +1,71 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + resolveDataset, + projectIdForMerchant, + DATASET_STANDARD, + DATASET_MEDIUM, + DATASET_HIGH, + DATASET_STREAMING, + DATASET_LINKED, + DATASET_LINKED_LOW, + DATASET_LINKED_MEDIUM, + DATASET_LINKED_HIGH, + DATASET_LINKED_STREAMING, +} = require('../src/project'); + +test('resolveDataset covers the linked x taxonomy matrix', () => { + // base (no-PII), no taxonomy + assert.equal(resolveDataset(), 'ultracart_dw'); + assert.equal(resolveDataset({}), 'ultracart_dw'); + + // taxonomy tiers on the base datasets + assert.equal(resolveDataset({ taxonomy: 'low' }), 'ultracart_dw_low'); + assert.equal(resolveDataset({ taxonomy: 'medium' }), 'ultracart_dw_medium'); + assert.equal(resolveDataset({ taxonomy: 'high' }), 'ultracart_dw_high'); + + // linked datasets — required for a parent account, whose base datasets may be EMPTY + // while the linked ones carry rows for every child merchant id. + assert.equal(resolveDataset({ linked: true }), 'ultracart_dw_linked'); + assert.equal(resolveDataset({ linked: true, taxonomy: 'low' }), 'ultracart_dw_linked_low'); + assert.equal(resolveDataset({ linked: true, taxonomy: 'medium' }), 'ultracart_dw_linked_medium'); + assert.equal(resolveDataset({ linked: true, taxonomy: 'high' }), 'ultracart_dw_linked_high'); + + // linked:false is explicitly the base set, not a linked one + assert.equal(resolveDataset({ linked: false, taxonomy: 'high' }), 'ultracart_dw_high'); +}); + +test('resolveDataset agrees with the exported dataset constants', () => { + // The constants and the resolver are two ways to name the same datasets; if they ever + // disagree, callers mixing the two would silently query the wrong tier. + assert.equal(resolveDataset(), DATASET_STANDARD); + assert.equal(resolveDataset({ taxonomy: 'medium' }), DATASET_MEDIUM); + assert.equal(resolveDataset({ taxonomy: 'high' }), DATASET_HIGH); + assert.equal(resolveDataset({ linked: true }), DATASET_LINKED); + assert.equal(resolveDataset({ linked: true, taxonomy: 'low' }), DATASET_LINKED_LOW); + assert.equal(resolveDataset({ linked: true, taxonomy: 'medium' }), DATASET_LINKED_MEDIUM); + assert.equal(resolveDataset({ linked: true, taxonomy: 'high' }), DATASET_LINKED_HIGH); + + // streaming is not reachable via resolveDataset (it is not a taxonomy tier) — assert the + // constants stand on their own so a rename cannot go unnoticed. + assert.equal(DATASET_STREAMING, 'ultracart_dw_streaming'); + assert.equal(DATASET_LINKED_STREAMING, 'ultracart_dw_linked_streaming'); +}); + +test('projectIdForMerchant normalizes case and surrounding whitespace', () => { + assert.equal(projectIdForMerchant('DEMO'), 'ultracart-dw-demo'); + assert.equal(projectIdForMerchant(' DEMO '), 'ultracart-dw-demo', 'trims'); + assert.equal(projectIdForMerchant('MixedCase'), 'ultracart-dw-mixedcase'); +}); + +test('projectIdForMerchant rejects a missing or non-string merchant id', () => { + // Failing loudly here beats deriving "ultracart-dw-undefined" and getting an opaque + // permission error from BigQuery much later. + assert.throws(() => projectIdForMerchant(), /merchantId is required/); + assert.throws(() => projectIdForMerchant(''), /merchantId is required/); + assert.throws(() => projectIdForMerchant(null), /merchantId is required/); + assert.throws(() => projectIdForMerchant(12345), /merchantId is required/); +}); diff --git a/test/schema-drift.test.js b/test/schema-drift.test.js index 66d7ce4..66b78cb 100644 --- a/test/schema-drift.test.js +++ b/test/schema-drift.test.js @@ -44,6 +44,15 @@ const fakeRead = (file) => { return FAKE_FILES[base]; }; +test('buildSdkTree flags a model whose source file cannot be read', () => { + // A referenced model that is missing from the installed SDK must surface as missingModel + // rather than silently producing an empty field tree, which would read as "no drift". + const tree = buildSdkTree({ modelName: 'NotAModel', modelsDir: '/models', readFile: fakeRead }); + + assert.equal(tree.missingModel, true); + assert.deepEqual(tree.children, {}, 'no fields invented for a model we could not read'); +}); + test('parseSdkModelFields captures convertToType AND constructFromObject patterns', () => { const fields = parseSdkModelFields(ORDER_SRC); const byName = Object.fromEntries(fields.map((f) => [f.name, f])); @@ -105,6 +114,48 @@ test('diffTrees reports sdkOnly, bqOnly (with ignore), and shape mismatches', () assert.ok(!bqOnly.includes('email_hash'), 'email_hash ignored by default predicate'); }); +test('diffTrees descends into nested records and reports dotted paths', () => { + // The case above stops at the top level because `billing` mismatches on shape. When both + // sides agree it is a container, the diff must recurse — otherwise drift *inside* a + // nested record (the shape most of this warehouse's PII lives in) goes unreported. + const sdk = buildSdkTree({ modelName: 'Order', modelsDir: '/x', readFile: fakeRead }); + const bq = buildBqTree([ + { name: 'order_id', type: 'STRING', mode: 'NULLABLE' }, + { name: 'exchange_rate', type: 'NUMERIC', mode: 'NULLABLE' }, + { name: 'cc_emails', type: 'RECORD', mode: 'REPEATED', fields: [{ name: 'value', type: 'STRING' }] }, + { + name: 'billing', + type: 'RECORD', + mode: 'NULLABLE', + fields: [ + // first_name present in the SDK model but absent here -> nested sdkOnly + { name: 'cc_emails', type: 'RECORD', mode: 'REPEATED', fields: [{ name: 'value', type: 'STRING' }] }, + { name: 'postal_code', type: 'STRING', mode: 'NULLABLE' }, // nested bqOnly + ], + }, + { + name: 'items', + type: 'RECORD', + mode: 'REPEATED', + fields: [ + { name: 'merchant_item_id', type: 'STRING', mode: 'NULLABLE' }, + // quantity present in the SDK but not here, inside a REPEATED record + { name: 'quantity', type: 'RECORD', mode: 'NULLABLE', fields: [{ name: 'x', type: 'STRING' }] }, + ], + }, + ]); + + const { sdkOnly, bqOnly, mismatches } = diffTrees(sdk, bq, { ignoreBqOnly: defaultIgnoreBqOnly }); + + assert.ok(sdkOnly.includes('billing.first_name'), 'nested missing column -> dotted sdkOnly path'); + assert.ok(bqOnly.includes('billing.postal_code'), 'nested new column -> dotted bqOnly path'); + assert.ok( + mismatches.some((m) => m.startsWith('items.quantity')), + 'shape mismatch inside a repeated record -> dotted mismatch path', + ); + assert.ok(!sdkOnly.includes('billing'), 'billing itself aligns; only its children drift'); +}); + test('defaultIgnoreBqOnly ignores hashes and partition/bookkeeping columns', () => { assert.equal(defaultIgnoreBqOnly('billing.email_hash', 'email_hash'), true); assert.equal(defaultIgnoreBqOnly('cc_emails_hashes', 'cc_emails_hashes'), true); diff --git a/test/transform.test.js b/test/transform.test.js index 8f7010c..aedaa41 100644 --- a/test/transform.test.js +++ b/test/transform.test.js @@ -142,6 +142,61 @@ test('transformRows produces SDK-shaped plain objects', () => { assert.deepEqual(row.Tags, [{ tag_value: 'vip' }, { tag_value: 'loyalty' }]); }); +test('REPEATED scalar columns become plain arrays', () => { + // Rare in this warehouse (primitive arrays are normally REPEATED RECORD{value}), but a + // genuine BigQuery shape: REPEATED STRING/INT64 with no sub-fields. + const fields = [ + { name: 'tags', type: 'STRING', mode: 'REPEATED' }, + { name: 'counts', type: 'INTEGER', mode: 'REPEATED' }, + { name: 'seen_at', type: 'DATETIME', mode: 'REPEATED' }, + ]; + const [row] = transformRows( + [{ tags: ['a', 'b'], counts: [wrap('1'), wrap('2')], seen_at: [wrap('2025-01-15 10:30:00')] }], + fields, + ); + + assert.deepEqual(row.tags, ['a', 'b']); + assert.deepEqual(row.counts, [1, 2], 'element wrappers unwrapped and coerced to Number'); + assert.deepEqual(row.seen_at, ['2025-01-15T10:30:00Z'], 'per-element date transform'); +}); + +test('a REPEATED field arriving as a bare scalar is still wrapped into an array', () => { + // Defensive: asArray() means a single value never silently becomes a non-array, which + // would break callers that always iterate. + const fields = [{ name: 'tags', type: 'STRING', mode: 'REPEATED' }]; + const [row] = transformRows([{ tags: 'solo' }], fields); + assert.deepEqual(row.tags, ['solo']); +}); + +test('primitive-array elements survive both wrapped and bare encodings', () => { + // extractValue() pulls `value` out of the struct; elements that are already bare scalars + // must pass through untouched rather than becoming undefined. + const fields = [ + { + name: 'cc_emails', + type: 'RECORD', + mode: 'REPEATED', + fields: [{ name: 'value', type: 'STRING', mode: 'NULLABLE' }], + }, + ]; + const [row] = transformRows([{ cc_emails: [{ value: 'a@example.com' }, 'b@example.com'] }], fields); + assert.deepEqual(row.cc_emails, ['a@example.com', 'b@example.com']); +}); + +test('null and absent fields are preserved, not invented', () => { + // constructFromObject would otherwise turn an absent column into an explicit undefined + // property, which changes JSON output for consumers. + const fields = [ + { name: 'order_id', type: 'STRING', mode: 'NULLABLE' }, + { name: 'creation_dts', type: 'DATETIME', mode: 'NULLABLE' }, + { name: 'never_selected', type: 'STRING', mode: 'NULLABLE' }, + ]; + const [row] = transformRows([{ order_id: 'N-1', creation_dts: null }], fields); + + assert.equal(row.creation_dts, null, 'null date stays null'); + assert.equal('never_selected' in row, false, 'a column absent from the row is not added'); +}); + test('round-trip: transform + hydrate into a real SDK Order instance', () => { const ucbq = new UltraCartBigQuery({ merchantId: 'DEMO', bigquery: {} }); const [order] = ucbq.hydrate([RAW_ROW], SCHEMA_FIELDS, UltraCartApi.Order);