diff --git a/addon/models/api-credential.js b/addon/models/api-credential.js index ac82093..ca20df1 100644 --- a/addon/models/api-credential.js +++ b/addon/models/api-credential.js @@ -17,7 +17,7 @@ export default class ApiCredentialModel extends Model { /** @dates */ @attr('date') last_used_at; - @attr('date') expires_at; + @attr('expiration') expires_at; @attr('date') deleted_at; @attr('date') created_at; @attr('date') updated_at; diff --git a/addon/transforms/expiration.js b/addon/transforms/expiration.js new file mode 100644 index 0000000..5df4b71 --- /dev/null +++ b/addon/transforms/expiration.js @@ -0,0 +1,50 @@ +import Transform from '@ember-data/serializer/transform'; + +/** + * Transform for expiration attributes which the API accepts as either a + * datetime or a relative expiration string ('never', 'immediately', + * 'in 1 hour', 'in 24 hours', ...) resolved server side. + * + * Deserializes like the standard `date` transform so date reads keep + * returning `Date` instances, but serializes strings untouched — the + * `date` transform serializes any non-Date value to `null`, which + * silently discards a selected relative expiration. + */ +export default class ExpirationTransform extends Transform { + deserialize(serialized) { + const type = typeof serialized; + + if (type === 'string') { + let offset = serialized.indexOf('+'); + + if (offset !== -1 && serialized.length - 5 === offset) { + offset += 3; + return new Date(serialized.slice(0, offset) + ':' + serialized.slice(offset)); + } + + return new Date(serialized); + } + + if (type === 'number') { + return new Date(serialized); + } + + if (serialized === null || serialized === undefined) { + return serialized; + } + + return null; + } + + serialize(deserialized) { + if (typeof deserialized === 'string') { + return deserialized; + } + + if (deserialized instanceof Date && !isNaN(deserialized)) { + return deserialized.toISOString(); + } + + return null; + } +} diff --git a/app/transforms/expiration.js b/app/transforms/expiration.js new file mode 100644 index 0000000..03dad7f --- /dev/null +++ b/app/transforms/expiration.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/dev-engine/transforms/expiration'; diff --git a/index.js b/index.js index d8134c2..f8ba24a 100644 --- a/index.js +++ b/index.js @@ -2,22 +2,31 @@ const { buildEngine } = require('ember-engines/lib/engine-addon'); const { name } = require('./package'); +// The engine's own test suite (`ember test` run from this package) needs the +// engine modules loaded eagerly so the dummy app can resolve them; hosts always +// get the lazy engine. Same pattern as the fleetops engine. +const isRunningOwnTests = process.argv.includes('test') && process.cwd() === __dirname; + module.exports = buildEngine({ name, lazyLoading: { - enabled: true, + enabled: !isRunningOwnTests, }, included(app) { this._super.included.apply(this, arguments); - // Configure ember-prism for the addon - app.options = app.options || {}; - app.options['ember-prism'] = { - components: ['json', 'javascript'], - plugins: ['line-highlight', 'line-numbers'], - }; + // Configure ember-prism for the addon; skipped for the eager dummy-app + // test build, where ember-cli-node-assets registers the component and + // plugin imports without funneling the files in and the vendor concat fails + if (!isRunningOwnTests) { + app.options = app.options || {}; + app.options['ember-prism'] = { + components: ['json', 'javascript'], + plugins: ['line-highlight', 'line-numbers'], + }; + } }, isDevelopingAddon() { diff --git a/package.json b/package.json index 0ee9458..883cb37 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "devDependencies": { "@babel/eslint-parser": "^7.22.15", "@babel/plugin-proposal-decorators": "^7.23.2", + "@ember/legacy-built-in-components": "^0.4.2", "@ember/optional-features": "^2.0.0", "@ember/test-helpers": "^3.2.0", "@embroider/test-setup": "^3.0.2", @@ -91,6 +92,7 @@ "eslint-plugin-prettier": "^5.0.1", "eslint-plugin-qunit": "^8.0.1", "loader.js": "^4.7.0", + "prismjs": "^1.29.0", "prettier": "^3.0.3", "qunit": "^2.20.0", "qunit-dom": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0dc0e7..7ce9537 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: '@babel/plugin-proposal-decorators': specifier: ^7.23.2 version: 7.28.0(@babel/core@7.28.5) + '@ember/legacy-built-in-components': + specifier: ^0.4.2 + version: 0.4.2(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0)) '@ember/optional-features': specifier: ^2.0.0 version: 2.3.0 @@ -107,7 +110,7 @@ importers: version: 4.12.8(@babel/core@7.28.5)(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0))(webpack@5.103.0) ember-engines: specifier: ^0.9.0 - version: 0.9.0(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0)) + version: 0.9.0(@ember/legacy-built-in-components@0.4.2(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0)))(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0)) ember-load-initializers: specifier: ^2.1.2 version: 2.1.2(@babel/core@7.28.5) @@ -156,6 +159,9 @@ importers: prettier: specifier: ^3.0.3 version: 3.7.4 + prismjs: + specifier: ^1.29.0 + version: 1.30.0 qunit: specifier: ^2.20.0 version: 2.24.3 @@ -1136,6 +1142,12 @@ packages: '@ember/edition-utils@1.2.0': resolution: {integrity: sha512-VmVq/8saCaPdesQmftPqbFtxJWrzxNGSQ+e8x8LLe3Hjm36pJ04Q8LeORGZkAeOhldoUX9seLGmSaHeXkIqoog==} + '@ember/legacy-built-in-components@0.4.2': + resolution: {integrity: sha512-rJulbyVQIVe1zEDQDqAQHechHy44DsS2qxO24+NmU/AYxwPFSzWC/OZNCDFSfLU+Y5BVd/00qjxF0pu7Nk+TNA==} + engines: {node: 12.* || 14.* || >= 16} + peerDependencies: + ember-source: '*' + '@ember/optional-features@2.3.0': resolution: {integrity: sha512-+M8CkPledQEaDbfIlwlq6Phgpm5jdT3a6WVDJk7b/zadw5xAJkuQKVK7DgR0SFgHGiWlyn6a8AU5p2mCA706RA==} engines: {node: 10.* || 12.* || >= 14} @@ -9700,6 +9712,17 @@ snapshots: '@ember/edition-utils@1.2.0': {} + '@ember/legacy-built-in-components@0.4.2(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0))': + dependencies: + '@embroider/macros': 1.19.5 + ember-cli-babel: 7.26.11 + ember-cli-htmlbars: 5.7.2 + ember-cli-typescript: 4.2.1 + ember-source: 5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0) + transitivePeerDependencies: + - '@glint/template' + - supports-color + '@ember/optional-features@2.3.0': dependencies: chalk: 4.1.2 @@ -9785,9 +9808,9 @@ snapshots: babel-import-util: 3.0.1 ember-cli-babel: 7.26.11 find-up: 5.0.0 - lodash: 4.17.21 + lodash: 4.18.1 resolve: 1.22.11 - semver: 7.7.3 + semver: 7.8.4 transitivePeerDependencies: - supports-color @@ -9802,9 +9825,9 @@ snapshots: ember-rfc176-data: 0.3.18 fs-extra: 9.1.0 js-string-escape: 1.0.1 - lodash: 4.17.21 + lodash: 4.18.1 resolve-package-path: 4.0.3 - semver: 7.7.3 + semver: 7.8.4 typescript-memoize: 1.1.1 '@embroider/shared-internals@2.9.2': @@ -9832,12 +9855,12 @@ snapshots: fs-extra: 9.1.0 is-subdir: 1.2.0 js-string-escape: 1.0.1 - lodash: 4.17.21 - minimatch: 3.1.2 - pkg-entry-points: 1.1.1 + lodash: 4.18.1 + minimatch: 3.1.5 + pkg-entry-points: 1.1.2 resolve-package-path: 4.0.3 resolve.exports: 2.0.3 - semver: 7.7.3 + semver: 7.8.4 typescript-memoize: 1.1.1 transitivePeerDependencies: - supports-color @@ -10805,7 +10828,7 @@ snapshots: '@types/glob@9.0.0': dependencies: - glob: 8.1.0 + glob: 10.5.0 '@types/http-errors@2.0.5': {} @@ -10830,7 +10853,7 @@ snapshots: '@types/minimatch@6.0.0': dependencies: - minimatch: 7.4.6 + minimatch: 9.0.5 '@types/minimist@1.2.5': {} @@ -11295,7 +11318,7 @@ snapshots: async@2.6.4: dependencies: - lodash: 4.17.21 + lodash: 4.18.1 async@3.2.6: {} @@ -11342,8 +11365,8 @@ snapshots: convert-source-map: 1.9.0 debug: 2.6.9 json5: 0.5.1 - lodash: 4.17.21 - minimatch: 3.1.2 + lodash: 4.18.1 + minimatch: 3.1.5 path-is-absolute: 1.0.1 private: 0.1.8 slash: 1.0.0 @@ -11358,7 +11381,7 @@ snapshots: babel-types: 6.26.0 detect-indent: 4.0.0 jsesc: 1.3.0 - lodash: 4.17.21 + lodash: 4.18.1 source-map: 0.5.7 trim-right: 1.0.1 @@ -11487,7 +11510,7 @@ snapshots: babel-runtime: 6.26.0 core-js: 2.6.12 home-or-tmp: 2.0.0 - lodash: 4.17.21 + lodash: 4.18.1 mkdirp: 0.5.6 source-map-support: 0.4.18 transitivePeerDependencies: @@ -11504,7 +11527,7 @@ snapshots: babel-traverse: 6.26.0 babel-types: 6.26.0 babylon: 6.18.0 - lodash: 4.17.21 + lodash: 4.18.1 transitivePeerDependencies: - supports-color @@ -11518,7 +11541,7 @@ snapshots: debug: 2.6.9 globals: 9.18.0 invariant: 2.2.4 - lodash: 4.17.21 + lodash: 4.18.1 transitivePeerDependencies: - supports-color @@ -11526,7 +11549,7 @@ snapshots: dependencies: babel-runtime: 6.26.0 esutils: 2.0.3 - lodash: 4.17.21 + lodash: 4.18.1 to-fast-properties: 1.0.3 babel6-plugin-strip-class-callcheck@6.0.0: {} @@ -11873,7 +11896,7 @@ snapshots: fast-ordered-set: 1.0.3 fs-tree-diff: 0.5.9 heimdalljs: 0.2.6 - minimatch: 3.1.2 + minimatch: 3.1.5 mkdirp: 0.5.6 path-posix: 1.0.0 rimraf: 2.7.1 @@ -12140,7 +12163,7 @@ snapshots: debug: 3.2.7 ensure-posix-path: 1.1.1 fs-extra: 5.0.0 - minimatch: 3.1.2 + minimatch: 3.1.5 resolve: 1.22.11 rsvp: 4.8.5 symlink-or-copy: 1.3.1 @@ -13382,7 +13405,7 @@ snapshots: hash-for-dep: 1.5.1 heimdalljs-logger: 0.1.10 json-stable-stringify: 1.3.0 - semver: 7.7.3 + semver: 7.8.4 silent-error: 1.1.1 strip-bom: 4.0.0 walk-sync: 2.2.0 @@ -13555,7 +13578,7 @@ snapshots: fs-extra: 9.1.0 resolve: 1.22.11 rsvp: 4.8.5 - semver: 7.7.3 + semver: 7.8.4 stagehand: 1.0.1 walk-sync: 2.2.0 transitivePeerDependencies: @@ -13915,7 +13938,7 @@ snapshots: transitivePeerDependencies: - supports-color - ember-engines@0.9.0(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0)): + ember-engines@0.9.0(@ember/legacy-built-in-components@0.4.2(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0)))(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0)): dependencies: '@embroider/macros': 1.19.5 amd-name-resolver: 1.3.1 @@ -13935,6 +13958,8 @@ snapshots: ember-cli-version-checker: 5.1.2 ember-source: 5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0) lodash: 4.17.21 + optionalDependencies: + '@ember/legacy-built-in-components': 0.4.2(ember-source@5.4.1(@babel/core@7.28.5)(@glimmer/component@1.1.2(@babel/core@7.28.5))(rsvp@4.8.5)(webpack@5.103.0)) transitivePeerDependencies: - '@glint/template' - supports-color @@ -15838,7 +15863,7 @@ snapshots: cli-width: 2.2.1 external-editor: 3.1.0 figures: 2.0.0 - lodash: 4.17.21 + lodash: 4.18.1 mute-stream: 0.0.7 run-async: 2.4.1 rxjs: 6.6.7 @@ -16819,7 +16844,7 @@ snapshots: dependencies: growly: 1.3.0 is-wsl: 2.2.0 - semver: 7.7.3 + semver: 7.8.4 shellwords: 0.1.1 uuid: 8.3.2 which: 2.0.2 diff --git a/tests/unit/models/api-credential-test.js b/tests/unit/models/api-credential-test.js index 294e7f3..cdb8df7 100644 --- a/tests/unit/models/api-credential-test.js +++ b/tests/unit/models/api-credential-test.js @@ -1,5 +1,6 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import { format as formatDate } from 'date-fns'; module('Unit | Model | api credential', function (hooks) { setupTest(hooks); @@ -10,4 +11,29 @@ module('Unit | Model | api credential', function (hooks) { let model = store.createRecord('api-credential', {}); assert.ok(model); }); + + test('it serializes relative expiration strings untouched for the API to resolve', function (assert) { + const store = this.owner.lookup('service:store'); + + for (const option of ['never', 'immediately', 'in 1 hour', 'in 24 hours', 'in 3 days', 'in 7 days']) { + const model = store.createRecord('api-credential', { expires_at: option }); + assert.strictEqual(model.serialize().expires_at, option); + model.unloadRecord(); + } + }); + + test('it serializes an expiration date to an ISO string', function (assert) { + const store = this.owner.lookup('service:store'); + const model = store.createRecord('api-credential', { expires_at: new Date('2026-08-28T12:00:00.000Z') }); + + assert.strictEqual(model.serialize().expires_at, '2026-08-28T12:00:00.000Z'); + }); + + test('it deserializes an expiration datetime for display', function (assert) { + const store = this.owner.lookup('service:store'); + const model = store.push(store.normalize('api-credential', { uuid: 'api_credential_uuid', expires_at: '2026-08-28T12:00:00.000Z' })); + + assert.true(model.expires_at instanceof Date); + assert.strictEqual(model.expiresAt, formatDate(new Date('2026-08-28T12:00:00.000Z'), 'yyyy-MM-dd HH:mm')); + }); }); diff --git a/tests/unit/transforms/expiration-test.js b/tests/unit/transforms/expiration-test.js new file mode 100644 index 0000000..36a8750 --- /dev/null +++ b/tests/unit/transforms/expiration-test.js @@ -0,0 +1,41 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Transform | expiration', function (hooks) { + setupTest(hooks); + + test('it serializes relative expiration strings untouched', function (assert) { + const transform = this.owner.lookup('transform:expiration'); + + for (const option of ['never', 'immediately', 'in 1 hour', 'in 24 hours', 'in 3 days', 'in 7 days']) { + assert.strictEqual(transform.serialize(option), option); + } + }); + + test('it serializes dates to ISO strings', function (assert) { + const transform = this.owner.lookup('transform:expiration'); + const date = new Date('2026-08-28T12:00:00.000Z'); + + assert.strictEqual(transform.serialize(date), '2026-08-28T12:00:00.000Z'); + }); + + test('it serializes empty and invalid values to null', function (assert) { + const transform = this.owner.lookup('transform:expiration'); + + assert.strictEqual(transform.serialize(null), null); + assert.strictEqual(transform.serialize(undefined), null); + assert.strictEqual(transform.serialize(new Date('not a date')), null); + assert.strictEqual(transform.serialize(12345), null); + }); + + test('it deserializes datetime strings and timestamps to dates', function (assert) { + const transform = this.owner.lookup('transform:expiration'); + + assert.strictEqual(transform.deserialize('2026-08-28T12:00:00.000Z').getTime(), Date.parse('2026-08-28T12:00:00.000Z')); + assert.strictEqual(transform.deserialize('2026-08-28T12:00:00+0000').getTime(), Date.parse('2026-08-28T12:00:00+00:00')); + assert.strictEqual(transform.deserialize(1756382400000).getTime(), 1756382400000); + assert.strictEqual(transform.deserialize(null), null); + assert.strictEqual(transform.deserialize(undefined), undefined); + assert.strictEqual(transform.deserialize({}), null); + }); +});