From 3715d292c3038a1bb4ffd979d8397657ba3104dc Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 11:28:04 +0800 Subject: [PATCH 1/2] Fix fuel integration credential persistence and sync history records - Declare credentials on fuel provider connections so the token and auth method verified by Test Connection are included when saving an integration. - Omit null or absent credentials on updates to fetched records, preserving stored write-only tokens unless the operator supplies a replacement. - Expose imported and unmatched summary counts with explicit computed dependencies so integration tables update when their summaries change. - Register fuel-provider-sync-run models and serializers in addon and host namespaces, including date windows, progress, outcomes, errors and metadata. - Add regression coverage for create/update serialization, write-only save responses, repeated public-ID queries retaining UUID record identity, and deserialization of connection-scoped sync history. Companion data contracts for the Fleet-Ops fuel integration improvements in fleetbase/fleetops@cce089dc, targeting the v0.2.1 release branch. Validation: all 23 focused fuel-provider tests passed; all eight changed JavaScript files passed ESLint, and the staged diff passed whitespace checks. --- addon/models/fuel-provider-connection.js | 9 +++ addon/models/fuel-provider-sync-run.js | 23 ++++++ addon/serializers/fuel-provider-connection.js | 14 +++- addon/serializers/fuel-provider-sync-run.js | 3 + app/models/fuel-provider-sync-run.js | 1 + app/serializers/fuel-provider-sync-run.js | 1 + .../fuel-provider-connection-test.js | 70 +++++++++++++++++++ .../fuel-provider-sync-run-test.js | 48 +++++++++++++ 8 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 addon/models/fuel-provider-sync-run.js create mode 100644 addon/serializers/fuel-provider-sync-run.js create mode 100644 app/models/fuel-provider-sync-run.js create mode 100644 app/serializers/fuel-provider-sync-run.js create mode 100644 tests/unit/serializers/fuel-provider-sync-run-test.js diff --git a/addon/models/fuel-provider-connection.js b/addon/models/fuel-provider-connection.js index 3ba7305..93a266c 100644 --- a/addon/models/fuel-provider-connection.js +++ b/addon/models/fuel-provider-connection.js @@ -9,6 +9,7 @@ export default class FuelProviderConnectionModel extends Model { @attr('string') name; @attr('string', { defaultValue: 'production' }) environment; @attr('string', { defaultValue: 'configured' }) status; + @attr('raw') credentials; @attr('raw') sync_settings; @attr('raw') last_sync_state; @attr('string') last_error; @@ -25,6 +26,14 @@ export default class FuelProviderConnectionModel extends Model { return this.name || this.provider; } + @computed('last_sync_state.summary.imported') get lastImported() { + return String(this.last_sync_state?.summary?.imported ?? 0); + } + + @computed('last_sync_state.summary.unmatched') get lastUnmatched() { + return String(this.last_sync_state?.summary?.unmatched ?? 0); + } + @computed('last_synced_at') get lastSyncedAt() { return this.formatDate(this.last_synced_at); } diff --git a/addon/models/fuel-provider-sync-run.js b/addon/models/fuel-provider-sync-run.js new file mode 100644 index 0000000..34ee344 --- /dev/null +++ b/addon/models/fuel-provider-sync-run.js @@ -0,0 +1,23 @@ +import Model, { attr } from '@ember-data/model'; + +export default class FuelProviderSyncRunModel extends Model { + @attr('string') public_id; + @attr('string') fuel_provider_connection_uuid; + @attr('string') provider; + @attr('string') status; + @attr('string') error; + @attr('number') imported; + @attr('number') matched; + @attr('number') unmatched; + @attr('number') fuel_reports_created; + @attr('number') liters; + @attr('number') amount; + @attr('date') from; + @attr('date') to; + @attr('date') started_at; + @attr('date') finished_at; + @attr('date') created_at; + @attr('date') updated_at; + @attr('raw') summary; + @attr('raw') meta; +} diff --git a/addon/serializers/fuel-provider-connection.js b/addon/serializers/fuel-provider-connection.js index 6bf40a2..a93dcc8 100644 --- a/addon/serializers/fuel-provider-connection.js +++ b/addon/serializers/fuel-provider-connection.js @@ -1,4 +1,16 @@ import ApplicationSerializer from '@fleetbase/ember-core/serializers/application'; import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest'; -export default class FuelProviderConnectionSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) {} +export default class FuelProviderConnectionSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) { + serialize() { + const json = super.serialize(...arguments); + + // Credentials are write-only. A fetched record has no credentials to send + // until the operator supplies a replacement. + if (json.credentials === null || json.credentials === undefined) { + delete json.credentials; + } + + return json; + } +} diff --git a/addon/serializers/fuel-provider-sync-run.js b/addon/serializers/fuel-provider-sync-run.js new file mode 100644 index 0000000..6814f9f --- /dev/null +++ b/addon/serializers/fuel-provider-sync-run.js @@ -0,0 +1,3 @@ +import ApplicationSerializer from '@fleetbase/ember-core/serializers/application'; + +export default class FuelProviderSyncRunSerializer extends ApplicationSerializer {} diff --git a/app/models/fuel-provider-sync-run.js b/app/models/fuel-provider-sync-run.js new file mode 100644 index 0000000..510a1ad --- /dev/null +++ b/app/models/fuel-provider-sync-run.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/models/fuel-provider-sync-run'; diff --git a/app/serializers/fuel-provider-sync-run.js b/app/serializers/fuel-provider-sync-run.js new file mode 100644 index 0000000..9ee9f83 --- /dev/null +++ b/app/serializers/fuel-provider-sync-run.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/serializers/fuel-provider-sync-run'; diff --git a/tests/unit/serializers/fuel-provider-connection-test.js b/tests/unit/serializers/fuel-provider-connection-test.js index ed42864..db54dd0 100644 --- a/tests/unit/serializers/fuel-provider-connection-test.js +++ b/tests/unit/serializers/fuel-provider-connection-test.js @@ -1,5 +1,6 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import RESTAdapter from '@ember-data/adapter/rest'; import ApplicationSerializer from '@fleetbase/ember-core/serializers/application'; import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest'; import { assertEmbeddedAttrs, assertNormalizesUuidAsId, assertPrimaryKeyIsUuid } from 'dummy/tests/helpers/serializer-contract'; @@ -33,4 +34,73 @@ module('Unit | Serializer | fuel provider connection', function (hooks) { assert.strictEqual(record.serialize().provider, 'contract-value'); }); + + test('the token used for connection testing is included in the create payload', function (assert) { + const credentials = { api_token: 'test-token', auth_type: 'ws_sk_header' }; + const record = this.store.createRecord('fuel-provider-connection', { provider: 'petroapp', environment: 'sandbox', credentials }); + const payload = record.serialize(); + + assert.deepEqual(payload.credentials, credentials, 'Ember Data serializes the token and authentication method'); + assert.strictEqual(payload.environment, 'sandbox'); + record.set('credentials', { api_token: 'replacement-token' }); + assert.deepEqual(record.serialize().credentials, { api_token: 'replacement-token' }, 'credential edits are tracked and serialized'); + }); + + test('editing a fetched connection omits credentials until a replacement is supplied', function (assert) { + const record = assertNormalizesUuidAsId(assert, this.store, 'fuel-provider-connection', { provider: 'petroapp', name: 'Sandbox' }); + record.set('name', 'Renamed sandbox'); + + assert.false(Object.hasOwn(record.serialize(), 'credentials'), 'a name-only update cannot clear the stored token'); + record.set('credentials', null); + assert.false(Object.hasOwn(record.serialize(), 'credentials'), 'null credentials are also omitted'); + record.set('credentials', { api_token: 'replacement-token' }); + assert.deepEqual(record.serialize().credentials, { api_token: 'replacement-token' }); + }); + + test('saving a connection sends credentials and accepts the write-only server response', async function (assert) { + const credentials = { api_token: 'test-token', auth_type: 'ws_sk_header' }; + this.owner.register( + 'adapter:fuel-provider-connection', + class extends RESTAdapter { + ajax(url, method, options) { + assert.strictEqual(method, 'POST'); + assert.deepEqual(options.data.fuelProviderConnection.credentials, credentials); + assert.strictEqual(options.data.fuelProviderConnection.environment, 'sandbox'); + + return Promise.resolve({ + fuelProviderConnection: { + uuid: 'saved-connection', + provider: 'petroapp', + name: 'Sandbox', + environment: 'sandbox', + status: 'configured', + }, + }); + } + } + ); + const record = this.store.createRecord('fuel-provider-connection', { provider: 'petroapp', name: 'Sandbox', environment: 'sandbox', credentials }); + await record.save(); + assert.strictEqual(record.id, 'saved-connection'); + assert.false(record.isNew, 'the save completes without requiring credentials in the response'); + }); + + test('repeated public-ID lookups reuse the UUID record without an identifier collision', async function (assert) { + let requests = 0; + this.owner.register( + 'adapter:fuel-provider-connection', + class extends RESTAdapter { + queryRecord(store, type, query) { + requests++; + assert.deepEqual(query, { public_id: 'fuel_provider_connection_test', single: true }); + return Promise.resolve({ fuelProviderConnection: { uuid: 'connection-uuid', public_id: 'fuel_provider_connection_test', provider: 'petroapp', status: 'connected' } }); + } + } + ); + const first = await this.store.queryRecord('fuel-provider-connection', { public_id: 'fuel_provider_connection_test', single: true }); + const second = await this.store.queryRecord('fuel-provider-connection', { public_id: 'fuel_provider_connection_test', single: true }); + assert.strictEqual(first.id, 'connection-uuid'); + assert.strictEqual(second, first, 'refreshing the public URL keeps one record with its canonical UUID'); + assert.strictEqual(requests, 2); + }); }); diff --git a/tests/unit/serializers/fuel-provider-sync-run-test.js b/tests/unit/serializers/fuel-provider-sync-run-test.js new file mode 100644 index 0000000..54245b9 --- /dev/null +++ b/tests/unit/serializers/fuel-provider-sync-run-test.js @@ -0,0 +1,48 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import RESTAdapter from '@ember-data/adapter/rest'; + +module('Unit | Serializer | fuel provider sync run', function (hooks) { + setupTest(hooks); + + test('querying sync history resolves the registered model and server payload', async function (assert) { + this.owner.register( + 'adapter:fuel-provider-sync-run', + class extends RESTAdapter { + ajax(url, method, options) { + assert.strictEqual(method, 'GET'); + assert.deepEqual(options.data, { connection: 'connection-123', sort: '-created_at' }); + return Promise.resolve({ + fuelProviderSyncRuns: [ + { + uuid: 'run-123', + fuel_provider_connection_uuid: 'connection-123', + provider: 'petroapp', + status: 'completed', + from: '2026-09-01T00:00:00Z', + to: '2026-09-15T00:00:00Z', + finished_at: '2026-09-15T01:00:00Z', + imported: 12, + matched: 10, + unmatched: 2, + summary: { imported: 12 }, + meta: {}, + }, + ], + }); + } + } + ); + const store = this.owner.lookup('service:store'); + const runs = await store.query('fuel-provider-sync-run', { connection: 'connection-123', sort: '-created_at' }); + const run = runs.objectAt(0); + assert.strictEqual(runs.length, 1); + assert.strictEqual(run.id, 'run-123', 'the internal UUID is the record identity'); + assert.strictEqual(run.status, 'completed'); + assert.strictEqual(run.imported, 12); + assert.strictEqual(run.matched, 10); + assert.strictEqual(run.from.toISOString(), '2026-09-01T00:00:00.000Z'); + assert.strictEqual(run.finished_at.toISOString(), '2026-09-15T01:00:00.000Z'); + assert.deepEqual(run.summary, { imported: 12 }); + }); +}); From 297e76a667859b5bbbbe289a4e9786dde4ef4d84 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 11:43:57 +0800 Subject: [PATCH 2/2] Cover fuel connection summary counts and restore full coverage Exercise lastImported and lastUnmatched for new connections, absent or partial sync summaries, explicit null counts, populated results, and zero unmatched purchases. Verify that replacing a sync result and updating its nested counts both invalidate the cached display values. These two previously untested getters accounted for all CI coverage gaps: two statements, two functions, two lines, and four branch paths. Keep the 100% coverage thresholds, instrumentation, and exclusions unchanged. Validation: 1,051 tests passed. The full coverage gate passes across 147 source files: 2,258/2,258 statements, 1,467/1,467 branches, 727/727 functions, and 2,205/2,205 lines. The changed test also passes ESLint. --- .../models/fuel-provider-connection-test.js | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/unit/models/fuel-provider-connection-test.js b/tests/unit/models/fuel-provider-connection-test.js index 8db3f5b..71f1416 100644 --- a/tests/unit/models/fuel-provider-connection-test.js +++ b/tests/unit/models/fuel-provider-connection-test.js @@ -1,4 +1,5 @@ import { module, test } from 'qunit'; +import { set } from '@ember/object'; import { setupTest } from 'dummy/tests/helpers'; import { FIXED_DATE, FIXED_DATE_LONG, THREE_DAYS_DISTANCE, assertDateGetters, assertDefaults, assertRelationships } from 'dummy/tests/helpers/model-contract'; @@ -35,6 +36,37 @@ module('Unit | Model | fuel provider connection', function (hooks) { }); module('display and formatting', function () { + test('sync counts default to zero when a connection has no complete summary', function (assert) { + const connection = this.store.createRecord('fuel-provider-connection'); + + assert.strictEqual(connection.lastImported, '0', 'a new connection has no imports'); + assert.strictEqual(connection.lastUnmatched, '0', 'a new connection has no unmatched purchases'); + + for (const state of [null, {}, { summary: null }, { summary: {} }, { summary: { imported: null, unmatched: null } }]) { + connection.set('last_sync_state', state); + assert.strictEqual(connection.lastImported, '0', 'an absent import count has a readable fallback'); + assert.strictEqual(connection.lastUnmatched, '0', 'an absent unmatched count has a readable fallback'); + } + }); + + test('sync counts display the summary and update when a later sync changes it', function (assert) { + const connection = this.store.createRecord('fuel-provider-connection', { + last_sync_state: { summary: { imported: 12, unmatched: 3 } }, + }); + + assert.strictEqual(connection.lastImported, '12'); + assert.strictEqual(connection.lastUnmatched, '3'); + + connection.set('last_sync_state', { summary: { imported: 5, unmatched: 0 } }); + assert.strictEqual(connection.lastImported, '5', 'replacing the sync result invalidates the cached count'); + assert.strictEqual(connection.lastUnmatched, '0', 'a successful zero count is preserved'); + + set(connection, 'last_sync_state.summary.imported', 8); + set(connection, 'last_sync_state.summary.unmatched', 2); + assert.strictEqual(connection.lastImported, '8', 'nested import updates invalidate the cached count'); + assert.strictEqual(connection.lastUnmatched, '2', 'nested unmatched updates invalidate the cached count'); + }); + test('displayName prefers the connection name over the provider', function (assert) { const connection = this.store.createRecord('fuel-provider-connection', { name: 'Shell APAC', provider: 'shell' }); assert.strictEqual(connection.displayName, 'Shell APAC');