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/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'); 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 }); + }); +});