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