From 763870e135dd103ce6fdd8637aa89807a6e6a0d9 Mon Sep 17 00:00:00 2001 From: Alexander Schmidt Date: Sun, 8 Feb 2026 14:08:57 +0100 Subject: [PATCH] test: add service tests for DirectusClient, DirectusError, categories, files, listings, and notifications --- tests/client.test.js | 323 +++++++++++++++++++++++++++++++++++++++++ tests/index.html | 10 +- tests/services.test.js | 296 +++++++++++++++++++++++++++++++++++++ tests/test-runner.js | 46 ++++++ 4 files changed, 674 insertions(+), 1 deletion(-) create mode 100644 tests/client.test.js create mode 100644 tests/services.test.js diff --git a/tests/client.test.js b/tests/client.test.js new file mode 100644 index 0000000..95196d1 --- /dev/null +++ b/tests/client.test.js @@ -0,0 +1,323 @@ +/** + * Tests for js/services/directus/client.js + */ + +import { test, asyncTest, describe, assertEquals, assertTrue, assertFalse, assertThrows, assertDeepEquals, assertNotEquals } from './test-runner.js' +import { client, DirectusError, setPersist, getPersist } from '../js/services/directus/client.js' + +// ── DirectusError ── + +describe('DirectusError', () => { + test('creates error with correct properties', () => { + const err = new DirectusError(404, 'Not found', { foo: 'bar' }) + assertEquals(err.status, 404) + assertEquals(err.message, 'Not found') + assertEquals(err.name, 'DirectusError') + assertDeepEquals(err.data, { foo: 'bar' }) + }) + + test('extends Error', () => { + const err = new DirectusError(500, 'fail') + assertTrue(err instanceof Error) + assertTrue(err instanceof DirectusError) + }) + + test('defaults data to empty object', () => { + const err = new DirectusError(500, 'fail') + assertDeepEquals(err.data, {}) + }) + + test('isAuthError returns true for 401', () => { + assertTrue(new DirectusError(401, 'Unauthorized').isAuthError()) + }) + + test('isAuthError returns true for 403', () => { + assertTrue(new DirectusError(403, 'Forbidden').isAuthError()) + }) + + test('isAuthError returns false for other codes', () => { + assertFalse(new DirectusError(404, 'Not found').isAuthError()) + assertFalse(new DirectusError(500, 'Server error').isAuthError()) + }) + + test('isNotFound returns true for 404', () => { + assertTrue(new DirectusError(404, 'Not found').isNotFound()) + }) + + test('isNotFound returns false for other codes', () => { + assertFalse(new DirectusError(401, 'Unauthorized').isNotFound()) + }) + + test('isValidationError returns true for 400', () => { + assertTrue(new DirectusError(400, 'Bad request').isValidationError()) + }) + + test('isValidationError returns false for other codes', () => { + assertFalse(new DirectusError(404, 'Not found').isValidationError()) + }) +}) + +// ── buildQueryString ── + +describe('DirectusClient.buildQueryString', () => { + test('builds simple key-value pairs', () => { + const qs = client.buildQueryString({ limit: 10, offset: 5 }) + assertTrue(qs.includes('limit=10')) + assertTrue(qs.includes('offset=5')) + }) + + test('joins array fields with comma', () => { + const qs = client.buildQueryString({ fields: ['id', 'title', 'status'] }) + assertTrue(qs.includes('fields=id%2Ctitle%2Cstatus') || qs.includes('fields=id,title,status')) + }) + + test('joins array sort with comma', () => { + const qs = client.buildQueryString({ sort: ['-date_created', 'title'] }) + assertTrue(qs.includes('sort=')) + }) + + test('stringifies object values as JSON', () => { + const filter = { status: { _eq: 'published' } } + const qs = client.buildQueryString({ filter }) + assertTrue(qs.includes('filter=')) + assertTrue(qs.includes('published')) + }) + + test('skips null and undefined values', () => { + const qs = client.buildQueryString({ a: null, b: undefined, c: 'value' }) + assertFalse(qs.includes('a=')) + assertFalse(qs.includes('b=')) + assertTrue(qs.includes('c=value')) + }) + + test('returns empty string for empty params', () => { + assertEquals(client.buildQueryString({}), '') + }) +}) + +// ── Persist toggle ── + +describe('setPersist / getPersist', () => { + test('defaults to false after module load', () => { + // Note: may have been set by loadTokens() — just verify type + assertEquals(typeof getPersist(), 'boolean') + }) + + test('setPersist changes value', () => { + const original = getPersist() + setPersist(true) + assertEquals(getPersist(), true) + setPersist(false) + assertEquals(getPersist(), false) + setPersist(original) + }) +}) + +// ── Token management ── + +describe('DirectusClient token management', () => { + test('clearTokens resets all token state', () => { + client.clearTokens() + assertEquals(client.accessToken, null) + assertEquals(client.refreshToken, null) + assertEquals(client.tokenExpiry, null) + }) + + test('isAuthenticated returns false without token', () => { + client.clearTokens() + assertFalse(client.isAuthenticated()) + }) + + test('saveTokens stores tokens and sets expiry', () => { + client.saveTokens('test-access', 'test-refresh', 900000) + assertEquals(client.accessToken, 'test-access') + assertEquals(client.refreshToken, 'test-refresh') + assertTrue(client.tokenExpiry > Date.now()) + + // Cleanup + client.clearTokens() + sessionStorage.removeItem('dgray_auth') + localStorage.removeItem('dgray_auth') + }) + + test('isAuthenticated returns true with valid token', () => { + client.saveTokens('valid-token', 'refresh', 900000) + assertTrue(client.isAuthenticated()) + + client.clearTokens() + sessionStorage.removeItem('dgray_auth') + localStorage.removeItem('dgray_auth') + }) + + test('isAuthenticated returns false when token expired', () => { + client.accessToken = 'expired' + client.tokenExpiry = Date.now() - 10000 + assertFalse(client.isAuthenticated()) + + client.clearTokens() + }) + + test('loadTokens restores from storage', () => { + const authData = JSON.stringify({ + accessToken: 'stored-access', + refreshToken: 'stored-refresh', + expiry: Date.now() + 600000 + }) + sessionStorage.setItem('dgray_auth', authData) + + client.loadTokens() + assertEquals(client.accessToken, 'stored-access') + assertEquals(client.refreshToken, 'stored-refresh') + + client.clearTokens() + sessionStorage.removeItem('dgray_auth') + }) + + test('loadTokens clears on invalid JSON', () => { + sessionStorage.setItem('dgray_auth', 'not-json') + client.loadTokens() + assertEquals(client.accessToken, null) + + sessionStorage.removeItem('dgray_auth') + }) +}) + +// ── HTTP request with mocked fetch ── + +describe('DirectusClient.request', () => { + asyncTest('makes GET request with auth header', async () => { + const originalFetch = window.fetch + + client.accessToken = 'mock-token' + let capturedHeaders = null + + window.fetch = async (url, opts) => { + capturedHeaders = opts.headers + return { + ok: true, + status: 200, + json: async () => ({ data: { id: 1 } }) + } + } + + const result = await client.request('/items/test', { method: 'GET' }) + assertEquals(capturedHeaders['Authorization'], 'Bearer mock-token') + assertEquals(result.data.id, 1) + + window.fetch = originalFetch + client.clearTokens() + }) + + asyncTest('throws DirectusError on non-ok response', async () => { + const originalFetch = window.fetch + + window.fetch = async () => ({ + ok: false, + status: 404, + json: async () => ({ errors: [{ message: 'Item not found' }] }) + }) + + let caught = null + try { + await client.request('/items/missing') + } catch (e) { + caught = e + } + + assertTrue(caught instanceof DirectusError) + assertEquals(caught.status, 404) + assertTrue(caught.isNotFound()) + + window.fetch = originalFetch + }) + + asyncTest('throws DirectusError with status 0 on network error', async () => { + const originalFetch = window.fetch + + window.fetch = async () => { throw new TypeError('Failed to fetch') } + + let caught = null + try { + await client.request('/items/test') + } catch (e) { + caught = e + } + + assertTrue(caught instanceof DirectusError) + assertEquals(caught.status, 0) + assertEquals(caught.message, 'Network error') + + window.fetch = originalFetch + }) + + asyncTest('returns null for 204 No Content', async () => { + const originalFetch = window.fetch + + window.fetch = async () => ({ + ok: true, + status: 204 + }) + + const result = await client.request('/items/test', { method: 'DELETE' }) + assertEquals(result, null) + + window.fetch = originalFetch + }) + + asyncTest('retries on 429 rate limit', async () => { + const originalFetch = window.fetch + let callCount = 0 + + window.fetch = async () => { + callCount++ + if (callCount === 1) { + return { + ok: false, + status: 429, + headers: { get: () => '0' }, + json: async () => ({}) + } + } + return { + ok: true, + status: 200, + json: async () => ({ data: 'ok' }) + } + } + + const result = await client.request('/items/test') + assertEquals(callCount, 2) + assertEquals(result.data, 'ok') + + window.fetch = originalFetch + }) + + asyncTest('does not retry 401 on auth endpoints', async () => { + const originalFetch = window.fetch + client.refreshToken = 'some-token' + let callCount = 0 + + window.fetch = async () => { + callCount++ + return { + ok: false, + status: 401, + json: async () => ({ errors: [{ message: 'Unauthorized' }] }) + } + } + + let caught = null + try { + await client.request('/auth/login') + } catch (e) { + caught = e + } + + assertTrue(caught instanceof DirectusError) + assertEquals(caught.status, 401) + assertEquals(callCount, 1) + + window.fetch = originalFetch + client.clearTokens() + }) +}) diff --git a/tests/index.html b/tests/index.html index 7795b33..cb0d50b 100644 --- a/tests/index.html +++ b/tests/index.html @@ -93,17 +93,25 @@