From 9436f1942fc9f7ec8fb9c856bf8489fccf5dfd98 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 16:15:53 +0800 Subject: [PATCH 1/6] Format the inspection models' display dates The inspection form and submission models handed out `createdAt` and its siblings as the raw `Date` from the attribute. A table column bound to `createdAt` therefore rendered a full datetime instance string instead of a date, which is what the inspection forms index shows today. Every other model in this package formats these getters, guarding an unparseable value with `isValidDate` first, so do the same here. The form model also carried a `frequency` attribute. Nothing schedules an inspection from it, and FleetOps has dropped it from the API resource and the console, so it is removed here too. --- addon/models/inspection-form.js | 32 +++++++++++++++++----- addon/models/inspection-submission.js | 39 +++++++++++++++++++++------ 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/addon/models/inspection-form.js b/addon/models/inspection-form.js index 96a362e..6ac4d58 100644 --- a/addon/models/inspection-form.js +++ b/addon/models/inspection-form.js @@ -1,4 +1,6 @@ import Model, { attr, belongsTo } from '@ember-data/model'; +import { computed } from '@ember/object'; +import { format as formatDate, isValid as isValidDate } from 'date-fns'; export default class InspectionFormModel extends Model { @attr('string') uuid; @@ -10,7 +12,6 @@ export default class InspectionFormModel extends Model { @attr('string') description; @attr('string') type; @attr('string') status; - @attr('string') frequency; @belongsTo('maintenance-subject', { polymorphic: true, async: false }) subject; @attr('raw') items; @attr('raw') settings; @@ -25,15 +26,32 @@ export default class InspectionFormModel extends Model { return this.name || this.public_id; } - get createdAt() { - return this.created_at; + /** + * The display dates a table or a details panel reads. They returned the + * raw `Date`, which rendered as a full datetime instance string in a + * column; every other model in this package formats them here. + */ + @computed('created_at') get createdAt() { + if (!isValidDate(this.created_at)) { + return null; + } + + return formatDate(this.created_at, 'yyyy-MM-dd HH:mm'); } - get updatedAt() { - return this.updated_at; + @computed('updated_at') get updatedAt() { + if (!isValidDate(this.updated_at)) { + return null; + } + + return formatDate(this.updated_at, 'yyyy-MM-dd HH:mm'); } - get publishedAt() { - return this.published_at; + @computed('published_at') get publishedAt() { + if (!isValidDate(this.published_at)) { + return null; + } + + return formatDate(this.published_at, 'yyyy-MM-dd HH:mm'); } } diff --git a/addon/models/inspection-submission.js b/addon/models/inspection-submission.js index 0c03e97..f4e5088 100644 --- a/addon/models/inspection-submission.js +++ b/addon/models/inspection-submission.js @@ -1,4 +1,6 @@ import Model, { attr, belongsTo } from '@ember-data/model'; +import { computed } from '@ember/object'; +import { format as formatDate, isValid as isValidDate } from 'date-fns'; export default class InspectionSubmissionModel extends Model { @attr('string') uuid; @@ -43,19 +45,40 @@ export default class InspectionSubmissionModel extends Model { return this.public_id || this.form_name || 'Inspection'; } - get createdAt() { - return this.created_at; + /** + * The display dates a table or a details panel reads — formatted here, as + * in every other model in this package, rather than handed out as a raw + * `Date` that renders as a full datetime instance string. + */ + @computed('created_at') get createdAt() { + if (!isValidDate(this.created_at)) { + return null; + } + + return formatDate(this.created_at, 'yyyy-MM-dd HH:mm'); } - get updatedAt() { - return this.updated_at; + @computed('updated_at') get updatedAt() { + if (!isValidDate(this.updated_at)) { + return null; + } + + return formatDate(this.updated_at, 'yyyy-MM-dd HH:mm'); } - get submittedAt() { - return this.submitted_at; + @computed('submitted_at') get submittedAt() { + if (!isValidDate(this.submitted_at)) { + return null; + } + + return formatDate(this.submitted_at, 'yyyy-MM-dd HH:mm'); } - get resolvedAt() { - return this.resolved_at; + @computed('resolved_at') get resolvedAt() { + if (!isValidDate(this.resolved_at)) { + return null; + } + + return formatDate(this.resolved_at, 'yyyy-MM-dd HH:mm'); } } From cf162ced0ca4638ce201c01b828c1cdee529b9b9 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 11 Sep 2026 19:09:49 +0800 Subject: [PATCH 2/6] Test the inspection models' formatted display dates The inspection form and submission tests still asserted that the camelCase date getters return the raw Date, so they failed once the getters began formatting it, and the null branch of each isValidDate guard was never run, which held coverage below the 100% gate. Assert each getter through assertDateGetters, like the other models: the yyyy-MM-dd HH:mm rendering of a real date, and null for null, undefined and an unparseable Date. Also assert that the form no longer declares frequency. --- tests/unit/models/inspection-form-test.js | 29 +++++++++++----- .../unit/models/inspection-submission-test.js | 33 ++++++++++++------- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/tests/unit/models/inspection-form-test.js b/tests/unit/models/inspection-form-test.js index 448efc9..1521ce8 100644 --- a/tests/unit/models/inspection-form-test.js +++ b/tests/unit/models/inspection-form-test.js @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; -import { assertAttributeTypes, assertRelationships } from 'dummy/tests/helpers/model-contract'; +import { FIXED_DATE_LONG, assertAttributeTypes, assertDateGetters, assertRelationships } from 'dummy/tests/helpers/model-contract'; module('Unit | Model | inspection-form', function (hooks) { setupTest(hooks); @@ -37,14 +37,25 @@ module('Unit | Model | inspection-form', function (hooks) { assert.strictEqual(form.displayName, 'Pre-trip'); }); - test('the camelCase date getters expose the raw dates', function (assert) { - const published = new Date(2026, 8, 1); - const created = new Date(2026, 7, 1); - const updated = new Date(2026, 7, 2); - const form = this.store.createRecord('inspection-form', { published_at: published, created_at: created, updated_at: updated }); + test('it no longer declares a frequency attribute', function (assert) { + assert.notOk(this.store.modelFor('inspection-form').attributes.has('frequency'), 'inspection-form does not declare `frequency`'); + }); + + test('published_at renders its formatting getter', function (assert) { + assertDateGetters(assert, this.store.createRecord('inspection-form'), 'published_at', { + publishedAt: FIXED_DATE_LONG, + }); + }); - assert.strictEqual(form.publishedAt, published); - assert.strictEqual(form.createdAt, created); - assert.strictEqual(form.updatedAt, updated); + test('created_at renders its formatting getter', function (assert) { + assertDateGetters(assert, this.store.createRecord('inspection-form'), 'created_at', { + createdAt: FIXED_DATE_LONG, + }); + }); + + test('updated_at renders its formatting getter', function (assert) { + assertDateGetters(assert, this.store.createRecord('inspection-form'), 'updated_at', { + updatedAt: FIXED_DATE_LONG, + }); }); }); diff --git a/tests/unit/models/inspection-submission-test.js b/tests/unit/models/inspection-submission-test.js index 59544fe..19db47e 100644 --- a/tests/unit/models/inspection-submission-test.js +++ b/tests/unit/models/inspection-submission-test.js @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; -import { assertAttributeTypes, assertRelationships } from 'dummy/tests/helpers/model-contract'; +import { FIXED_DATE_LONG, assertAttributeTypes, assertDateGetters, assertRelationships } from 'dummy/tests/helpers/model-contract'; module('Unit | Model | inspection-submission', function (hooks) { setupTest(hooks); @@ -50,16 +50,27 @@ module('Unit | Model | inspection-submission', function (hooks) { assert.strictEqual(submission.displayName, 'submission_1'); }); - test('the camelCase date getters expose the raw dates', function (assert) { - const submitted = new Date(2026, 8, 1); - const resolved = new Date(2026, 8, 2); - const created = new Date(2026, 7, 1); - const updated = new Date(2026, 7, 2); - const submission = this.store.createRecord('inspection-submission', { submitted_at: submitted, resolved_at: resolved, created_at: created, updated_at: updated }); + test('submitted_at renders its formatting getter', function (assert) { + assertDateGetters(assert, this.store.createRecord('inspection-submission'), 'submitted_at', { + submittedAt: FIXED_DATE_LONG, + }); + }); + + test('resolved_at renders its formatting getter', function (assert) { + assertDateGetters(assert, this.store.createRecord('inspection-submission'), 'resolved_at', { + resolvedAt: FIXED_DATE_LONG, + }); + }); - assert.strictEqual(submission.submittedAt, submitted); - assert.strictEqual(submission.resolvedAt, resolved); - assert.strictEqual(submission.createdAt, created); - assert.strictEqual(submission.updatedAt, updated); + test('created_at renders its formatting getter', function (assert) { + assertDateGetters(assert, this.store.createRecord('inspection-submission'), 'created_at', { + createdAt: FIXED_DATE_LONG, + }); + }); + + test('updated_at renders its formatting getter', function (assert) { + assertDateGetters(assert, this.store.createRecord('inspection-submission'), 'updated_at', { + updatedAt: FIXED_DATE_LONG, + }); }); }); From fe5f2e9181a682fe4bf381a8447b8ce15af12d1d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 14 Sep 2026 12:41:44 +0800 Subject: [PATCH 3/6] chore(release): open v0.2.1 release branch Bump package.json to 0.2.1 and seed RELEASE.md so the release-tag workflow can validate the version once this branch merges to main. --- RELEASE.md | 10 +++++----- package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 8d44f09..7db2865 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,12 +1,12 @@ -> v0.2.0 ~ "First-class trailers, inspections and full test coverage" +> v0.2.1 ~ "Inspection display dates" --- ## Highlights -- **First-class Trailer data models** — trailer models, serializers and data contracts for the console, with current trailers embedded on the vehicle serializer. -- **Inspection data models** — models and serializers backing the maintenance platform upgrade. -- **`is-waypoint-record` util** — moved out of `ember-core` and into this package. -- **Test coverage campaign** — a 100% coverage gate wired into CI, with Codecov reporting. +- **Inspection display dates are formatted** — `inspection-form` and `inspection-submission` format their display-date getters as `yyyy-MM-dd HH:mm` and answer `null` for a date they cannot read, the way every other model in this package does. The console's inspection indexes were showing a raw datetime instance string. +- **`frequency` is gone from `inspection-form`** — nothing scheduled an inspection from it, and it is being dropped from the FleetOps API resource, report schema and console in fleetbase/fleetops#319. + +The underscored attributes (`created_at`, `published_at`, …) are untouched, so anything needing a real `Date` is unaffected. --- ## Need help? diff --git a/package.json b/package.json index 9e9eb79..77c38ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/fleetops-data", - "version": "0.2.0", + "version": "0.2.1", "description": "Fleetbase Fleet-Ops based models, serializers, transforms, adapters and GeoJson utility functions.", "keywords": [ "fleetbase-data", From 3715d292c3038a1bb4ffd979d8397657ba3104dc Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 11:28:04 +0800 Subject: [PATCH 4/6] 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 5/6] 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'); From 130e11d533155384792ea02537d9236af649e76f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 18:44:17 +0800 Subject: [PATCH 6/6] Serialize work order, maintenance and schedule relationships without asserting, and send cleared ones as cleared The work order, maintenance and maintenance schedule serializers read their polymorphic type with snapshot.attr('_type'), but those models declare no such attribute, so saving one in a debug build failed with "Model has no attribute named 'target_type' defined". They now read it through snapshot.attributes(). Ember Data only calls serializePolymorphicType for a related record that is present, so removing a target or assignee sent null embedded data while the _uuid and _type columns kept the old record. Unset relationships are now sent with both cleared. --- addon/serializers/maintenance-schedule.js | 6 +++- addon/serializers/maintenance.js | 6 +++- addon/serializers/work-order.js | 6 +++- .../clear-unset-polymorphic-relationships.js | 23 ++++++++++++++ .../clear-unset-polymorphic-relationships.js | 1 + tests/helpers/polymorphic-contract.js | 3 +- .../serializers/maintenance-schedule-test.js | 10 +++++++ tests/unit/serializers/maintenance-test.js | 10 +++++++ tests/unit/serializers/work-order-test.js | 10 +++++++ ...ar-unset-polymorphic-relationships-test.js | 30 +++++++++++++++++++ 10 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 addon/utils/clear-unset-polymorphic-relationships.js create mode 100644 app/utils/clear-unset-polymorphic-relationships.js create mode 100644 tests/unit/utils/clear-unset-polymorphic-relationships-test.js diff --git a/addon/serializers/maintenance-schedule.js b/addon/serializers/maintenance-schedule.js index 7832ab1..339992f 100644 --- a/addon/serializers/maintenance-schedule.js +++ b/addon/serializers/maintenance-schedule.js @@ -1,6 +1,7 @@ import ApplicationSerializer from '@fleetbase/ember-core/serializers/application'; import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest'; import { isBlank } from '@ember/utils'; +import clearUnsetPolymorphicRelationships from '../utils/clear-unset-polymorphic-relationships'; export default class MaintenanceScheduleSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) { /** @@ -26,6 +27,7 @@ export default class MaintenanceScheduleSerializer extends ApplicationSerializer const json = super.serialize(snapshot, options); const readOnly = ['subject_name', 'default_assignee_name']; readOnly.forEach((attr) => delete json[attr]); + clearUnsetPolymorphicRelationships(snapshot, json, ['subject', 'default_assignee']); return json; } @@ -44,7 +46,9 @@ export default class MaintenanceScheduleSerializer extends ApplicationSerializer let key = relationship.key; let belongsTo = snapshot.belongsTo(key); - const isPolymorphicTypeBlank = isBlank(snapshot.attr(key + '_type')); + // Read the type through attributes(): these models declare no `_type` + // attribute, and snapshot.attr() asserts on an undeclared one. + const isPolymorphicTypeBlank = isBlank(snapshot.attributes()[key + '_type']); if (isPolymorphicTypeBlank) { key = this.keyForAttribute ? this.keyForAttribute(key, 'serialize') : key; if (!belongsTo) { diff --git a/addon/serializers/maintenance.js b/addon/serializers/maintenance.js index 8b2155d..5a0aee0 100644 --- a/addon/serializers/maintenance.js +++ b/addon/serializers/maintenance.js @@ -1,6 +1,7 @@ import ApplicationSerializer from '@fleetbase/ember-core/serializers/application'; import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest'; import { isBlank } from '@ember/utils'; +import clearUnsetPolymorphicRelationships from '../utils/clear-unset-polymorphic-relationships'; export default class MaintenanceSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) { /** @@ -28,6 +29,7 @@ export default class MaintenanceSerializer extends ApplicationSerializer.extend( const json = super.serialize(snapshot, options); const readOnly = ['maintainable_name', 'performed_by_name', 'work_order_subject', 'duration_hours', 'is_overdue', 'days_until_due', 'cost_breakdown']; readOnly.forEach((attr) => delete json[attr]); + clearUnsetPolymorphicRelationships(snapshot, json, ['maintainable', 'performed_by']); return json; } @@ -46,7 +48,9 @@ export default class MaintenanceSerializer extends ApplicationSerializer.extend( let key = relationship.key; let belongsTo = snapshot.belongsTo(key); - const isPolymorphicTypeBlank = isBlank(snapshot.attr(key + '_type')); + // Read the type through attributes(): these models declare no `_type` + // attribute, and snapshot.attr() asserts on an undeclared one. + const isPolymorphicTypeBlank = isBlank(snapshot.attributes()[key + '_type']); if (isPolymorphicTypeBlank) { key = this.keyForAttribute ? this.keyForAttribute(key, 'serialize') : key; if (!belongsTo) { diff --git a/addon/serializers/work-order.js b/addon/serializers/work-order.js index 292124c..842d729 100644 --- a/addon/serializers/work-order.js +++ b/addon/serializers/work-order.js @@ -1,6 +1,7 @@ import ApplicationSerializer from '@fleetbase/ember-core/serializers/application'; import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest'; import { isBlank } from '@ember/utils'; +import clearUnsetPolymorphicRelationships from '../utils/clear-unset-polymorphic-relationships'; export default class WorkOrderSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) { /** @@ -27,6 +28,7 @@ export default class WorkOrderSerializer extends ApplicationSerializer.extend(Em const json = super.serialize(snapshot, options); const readOnly = ['target_name', 'assignee_name', 'is_overdue', 'days_until_due', 'completion_percentage']; readOnly.forEach((attr) => delete json[attr]); + clearUnsetPolymorphicRelationships(snapshot, json, ['target', 'assignee']); return json; } @@ -45,7 +47,9 @@ export default class WorkOrderSerializer extends ApplicationSerializer.extend(Em let key = relationship.key; let belongsTo = snapshot.belongsTo(key); - const isPolymorphicTypeBlank = isBlank(snapshot.attr(key + '_type')); + // Read the type through attributes(): these models declare no `_type` + // attribute, and snapshot.attr() asserts on an undeclared one. + const isPolymorphicTypeBlank = isBlank(snapshot.attributes()[key + '_type']); if (isPolymorphicTypeBlank) { key = this.keyForAttribute ? this.keyForAttribute(key, 'serialize') : key; if (!belongsTo) { diff --git a/addon/utils/clear-unset-polymorphic-relationships.js b/addon/utils/clear-unset-polymorphic-relationships.js new file mode 100644 index 0000000..b94f8e7 --- /dev/null +++ b/addon/utils/clear-unset-polymorphic-relationships.js @@ -0,0 +1,23 @@ +/** + * Send each unset polymorphic relationship as cleared. + * + * Ember Data only calls `serializePolymorphicType` for a related record that is + * present, so a removed relationship otherwise reaches the server as `null` + * embedded data while its `_uuid` and `_type` columns keep pointing + * at the old record. + * + * @param {Snapshot} snapshot + * @param {Object} json the serialized payload, changed in place + * @param {Array} keys the polymorphic relationship names + * @return {Object} json + */ +export default function clearUnsetPolymorphicRelationships(snapshot, json, keys) { + for (const key of keys) { + if (!snapshot.belongsTo(key)) { + json[`${key}_uuid`] = null; + json[`${key}_type`] = null; + } + } + + return json; +} diff --git a/app/utils/clear-unset-polymorphic-relationships.js b/app/utils/clear-unset-polymorphic-relationships.js new file mode 100644 index 0000000..fcbe70a --- /dev/null +++ b/app/utils/clear-unset-polymorphic-relationships.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/utils/clear-unset-polymorphic-relationships'; diff --git a/tests/helpers/polymorphic-contract.js b/tests/helpers/polymorphic-contract.js index b745fff..23f9e75 100644 --- a/tests/helpers/polymorphic-contract.js +++ b/tests/helpers/polymorphic-contract.js @@ -17,13 +17,14 @@ * Build the smallest snapshot `serializePolymorphicType` actually reads. * * @param {Object} [options] - * @param {Object} [options.attrs] values `snapshot.attr(key)` should return + * @param {Object} [options.attrs] values `snapshot.attr(key)` and `snapshot.attributes()` should return * @param {Object|null} [options.belongsTo] value `snapshot.belongsTo(key)` should return * @return {Object} */ export function snapshotStub({ attrs = {}, belongsTo = null } = {}) { return { attr: (key) => attrs[key], + attributes: () => attrs, belongsTo: () => belongsTo, }; } diff --git a/tests/unit/serializers/maintenance-schedule-test.js b/tests/unit/serializers/maintenance-schedule-test.js index 1f2b240..8a9112e 100644 --- a/tests/unit/serializers/maintenance-schedule-test.js +++ b/tests/unit/serializers/maintenance-schedule-test.js @@ -76,4 +76,14 @@ module('Unit | Serializer | maintenance schedule', function (hooks) { assert.strictEqual(normalized.data.relationships.default_assignee.data.type, 'facilitator-driver'); assert.strictEqual(normalized.data.relationships.default_assignee.data.id, 'driver-1'); }); + + test('a record whose model declares no polymorphic type attributes serializes without asserting', function (assert) { + const record = this.store.createRecord('maintenance-schedule', { subject: this.store.createRecord('maintenance-subject-vehicle', { id: 'subject_1' }) }); + + const json = record.serialize(); + + assert.strictEqual(json.subject_type, 'fleet-ops:vehicle', 'the type is derived from the related record'); + assert.strictEqual(json.default_assignee_uuid, null, 'an unset default_assignee clears its uuid'); + assert.strictEqual(json.default_assignee_type, null, 'an unset default_assignee clears its type'); + }); }); diff --git a/tests/unit/serializers/maintenance-test.js b/tests/unit/serializers/maintenance-test.js index c5b29ea..fbb7420 100644 --- a/tests/unit/serializers/maintenance-test.js +++ b/tests/unit/serializers/maintenance-test.js @@ -72,4 +72,14 @@ module('Unit | Serializer | maintenance', function (hooks) { assert.strictEqual(json['maintainable_type'], 'fleet-ops:vendor', 'the raw key still produces a usable type field'); }); + + test('a record whose model declares no polymorphic type attributes serializes without asserting', function (assert) { + const record = this.store.createRecord('maintenance', { maintainable: this.store.createRecord('maintenance-subject-vehicle', { id: 'subject_1' }) }); + + const json = record.serialize(); + + assert.strictEqual(json.maintainable_type, 'fleet-ops:vehicle', 'the type is derived from the related record'); + assert.strictEqual(json.performed_by_uuid, null, 'an unset performed_by clears its uuid'); + assert.strictEqual(json.performed_by_type, null, 'an unset performed_by clears its type'); + }); }); diff --git a/tests/unit/serializers/work-order-test.js b/tests/unit/serializers/work-order-test.js index 998547b..6408144 100644 --- a/tests/unit/serializers/work-order-test.js +++ b/tests/unit/serializers/work-order-test.js @@ -69,4 +69,14 @@ module('Unit | Serializer | work order', function (hooks) { assert.strictEqual(json['target_type'], 'fleet-ops:vendor', 'the raw key still produces a usable type field'); }); + + test('a record whose model declares no polymorphic type attributes serializes without asserting', function (assert) { + const record = this.store.createRecord('work-order', { target: this.store.createRecord('maintenance-subject-vehicle', { id: 'subject_1' }) }); + + const json = record.serialize(); + + assert.strictEqual(json.target_type, 'fleet-ops:vehicle', 'the type is derived from the related record'); + assert.strictEqual(json.assignee_uuid, null, 'an unset assignee clears its uuid'); + assert.strictEqual(json.assignee_type, null, 'an unset assignee clears its type'); + }); }); diff --git a/tests/unit/utils/clear-unset-polymorphic-relationships-test.js b/tests/unit/utils/clear-unset-polymorphic-relationships-test.js new file mode 100644 index 0000000..d41f4e3 --- /dev/null +++ b/tests/unit/utils/clear-unset-polymorphic-relationships-test.js @@ -0,0 +1,30 @@ +import clearUnsetPolymorphicRelationships from '@fleetbase/fleetops-data/utils/clear-unset-polymorphic-relationships'; +import { module, test } from 'qunit'; + +/** + * A removed polymorphic relationship has to reach the server as cleared + * columns; a relationship that is still set must be left to the type hook. + */ +module('Unit | Utility | clear-unset-polymorphic-relationships', function () { + const snapshot = (related) => ({ belongsTo: (key) => related[key] ?? null }); + + test('an unset relationship is sent with both its uuid and type cleared', function (assert) { + const json = { target: null }; + + const result = clearUnsetPolymorphicRelationships(snapshot({}), json, ['target']); + + assert.strictEqual(result, json, 'the payload is changed in place and returned'); + assert.strictEqual(json.target_uuid, null); + assert.strictEqual(json.target_type, null); + }); + + test('a relationship that is set is left untouched', function (assert) { + const json = { assignee_uuid: 'vendor_1', assignee_type: 'fleet-ops:vendor' }; + + clearUnsetPolymorphicRelationships(snapshot({ assignee: { id: 'vendor_1' } }), json, ['assignee', 'target']); + + assert.strictEqual(json.assignee_uuid, 'vendor_1'); + assert.strictEqual(json.assignee_type, 'fleet-ops:vendor'); + assert.strictEqual(json.target_uuid, null, 'the unset sibling is still cleared'); + }); +});