Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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
24 changes: 19 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
8 changes: 7 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -43,6 +46,9 @@ npm run check:schema -- --project=ultracart-dw-<merchant> --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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
90 changes: 89 additions & 1 deletion test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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);
});
65 changes: 65 additions & 0 deletions test/index.test.js
Original file line number Diff line number Diff line change
@@ -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'));
});
71 changes: 71 additions & 0 deletions test/project.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading