From addb4110c0fba71c53ea4750c62e10e74695589f Mon Sep 17 00:00:00 2001 From: Daijiro Wachi Date: Tue, 11 Nov 2025 22:48:22 +0900 Subject: [PATCH] test: add Jest coverage for ChartUtils and service clients --- tests/services/Permission.test.js | 109 ++++++++++++++++++++++++ tests/services/Source.test.js | 38 +++++++++ tests/utils/ChartUtils.test.js | 129 +++++++++++++++++++++++++++++ tests/utils/ExecutionUtils.test.js | 62 ++++++++++++++ tests/utils/Renderers.test.js | 40 +++++++++ 5 files changed, 378 insertions(+) create mode 100644 tests/services/Permission.test.js create mode 100644 tests/services/Source.test.js create mode 100644 tests/utils/ChartUtils.test.js create mode 100644 tests/utils/ExecutionUtils.test.js create mode 100644 tests/utils/Renderers.test.js diff --git a/tests/services/Permission.test.js b/tests/services/Permission.test.js new file mode 100644 index 000000000..e780c5a35 --- /dev/null +++ b/tests/services/Permission.test.js @@ -0,0 +1,109 @@ +const httpStub = { + doGet: jest.fn(), + doPost: jest.fn(), + doDelete: jest.fn(), +}; + +const appConfigStub = { + webAPIRoot: '/api/', + userAuthenticationEnabled: true, +}; + +const authApiStub = { + subject: jest.fn(() => 'owner'), +}; + +const defineOrReplace = (name, factory) => { + if (requirejsInstance.defined(name)) { + requirejsInstance.undef(name); + } + requirejsInstance.define(name, [], factory); +}; + +let permissionService; +let ko; + +beforeAll(() => { + defineOrReplace('appConfig', () => appConfigStub); + defineOrReplace('services/http', () => httpStub); + defineOrReplace('services/AuthAPI', () => authApiStub); +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +beforeAll(async () => { + [permissionService, ko] = await requireAmd(['services/Permission', 'knockout']); +}); + +describe('Permission service', () => { + test('loadRoleSuggestions forwards role search term to backend', async () => { + httpStub.doGet.mockResolvedValue({ data: ['admin'] }); + + const data = await permissionService.loadRoleSuggestions('adm'); + + expect(httpStub.doGet).toHaveBeenCalledWith('/api/permission/access/suggest', { roleSearch: 'adm' }); + expect(data).toEqual(['admin']); + }); + + test('loadEntityAccessList defaults perm_type to WRITE', async () => { + httpStub.doGet.mockResolvedValue({ data: [{ id: 1 }] }); + + const data = await permissionService.loadEntityAccessList('cohort', 42); + + expect(httpStub.doGet).toHaveBeenCalledWith('/api/permission/access/cohort/42/WRITE'); + expect(data).toEqual([{ id: 1 }]); + }); + + test('grantEntityAccess posts access request payload', () => { + permissionService.grantEntityAccess('cohort', 42, 99, 'READ'); + + expect(httpStub.doPost).toHaveBeenCalledWith( + '/api/permission/access/cohort/42/role/99', + { accessType: 'READ' } + ); + }); + + test('revokeEntityAccess sends delete with chosen access type', () => { + permissionService.revokeEntityAccess('cohort', 42, 99, 'CUSTOM'); + + expect(httpStub.doDelete).toHaveBeenCalledWith( + '/api/permission/access/cohort/42/role/99', + { accessType: 'CUSTOM' } + ); + }); + + test('decorateComponent wires owner checks and proxy methods', async () => { + httpStub.doGet.mockResolvedValue({ data: [] }); + const component = {}; + const getters = { + entityTypeGetter: () => 'cohort', + entityIdGetter: () => 12, + createdByUsernameGetter: () => 'owner', + }; + + permissionService.decorateComponent(component, getters); + + expect(component.isAccessModalShown()).toBe(false); + expect(component.isOwner()).toBe(true); + + await component.loadAccessList('READ'); + expect(httpStub.doGet).toHaveBeenCalledWith('/api/permission/access/cohort/12/READ'); + + component.grantAccess(5, 'READ'); + expect(httpStub.doPost).toHaveBeenLastCalledWith( + '/api/permission/access/cohort/12/role/5', + { accessType: 'READ' } + ); + + component.revokeAccess(5, 'READ'); + expect(httpStub.doDelete).toHaveBeenLastCalledWith( + '/api/permission/access/cohort/12/role/5', + { accessType: 'READ' } + ); + + await component.loadAccessRoleSuggestions('adm'); + expect(httpStub.doGet).toHaveBeenLastCalledWith('/api/permission/access/suggest', { roleSearch: 'adm' }); + }); +}); diff --git a/tests/services/Source.test.js b/tests/services/Source.test.js new file mode 100644 index 000000000..c0c164b58 --- /dev/null +++ b/tests/services/Source.test.js @@ -0,0 +1,38 @@ +const httpStub = { + doGet: jest.fn(), +}; + +const appConfigStub = { + webAPIRoot: '/api/', +}; + +const defineOrReplace = (name, factory) => { + if (requirejsInstance.defined(name)) { + requirejsInstance.undef(name); + } + requirejsInstance.define(name, [], factory); +}; + +let sourceService; + +beforeAll(() => { + defineOrReplace('services/http', () => httpStub); + defineOrReplace('appConfig', () => appConfigStub); +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +beforeAll(async () => { + sourceService = await requireAmd(['services/Source']); +}); + +test('loadSourceList returns sources from API response', async () => { + httpStub.doGet.mockResolvedValue({ data: [{ sourceId: 1 }] }); + + const sources = await sourceService.loadSourceList(); + + expect(httpStub.doGet).toHaveBeenCalledWith('/api/source/sources'); + expect(sources).toEqual([{ sourceId: 1 }]); +}); diff --git a/tests/utils/ChartUtils.test.js b/tests/utils/ChartUtils.test.js new file mode 100644 index 000000000..a2beba14c --- /dev/null +++ b/tests/utils/ChartUtils.test.js @@ -0,0 +1,129 @@ +const ensureModule = (name, factory) => { + if (!requirejsInstance.defined(name)) { + requirejsInstance.define(name, [], factory); + } +}; + +let ChartUtils; +const createModuleArray = () => { + // Use ChartUtils itself to create an Array instance from the AMD context so instanceof checks succeed. + const [series] = ChartUtils.mapMonthYearDataToSeries({ x: [], y: [], p: [] }); + series.values.length = 0; + return series.values; +}; + +beforeAll(() => { + ensureModule('d3', () => ({ + format: () => (value) => value, + keys: Object.keys, + })); + + ensureModule('lodash', () => ({ + isFinite: Number.isFinite, + })); + + ensureModule('html2canvas', () => jest.fn(() => Promise.resolve())); + ensureModule('file-saver', () => ({})); + ensureModule('svgsaver', () => class MockSvgSaver {}); +}); + +beforeAll(async () => { + ChartUtils = await requireAmd(['utils/ChartUtils']); +}); + +describe('ChartUtils', () => { + describe('mapConceptData', () => { + test('transforms simple concept array and sorts alphabetically', () => { + const dataset = createModuleArray(); + dataset.push( + { conceptId: '2', conceptName: 'Beta', countValue: '15' }, + { conceptId: '1', conceptName: 'Alpha', countValue: '10' }, + { conceptName: null, countValue: '5' }, + ); + + const result = ChartUtils.mapConceptData(dataset); + + expect(result).toEqual([ + { id: 1, label: 'Alpha', value: 10 }, + { id: 2, label: 'Beta', value: 15 }, + { id: null, label: 'NULL (empty)', value: 5 }, + ]); + }); + + test('creates rows from single-value datasets', () => { + const dataset = { + conceptId: 7, + conceptName: 'Solo', + countValue: 3, + }; + + const result = ChartUtils.mapConceptData(dataset); + + expect(result).toEqual([ + { id: 7, label: 'Solo', value: 3 }, + ]); + }); + }); + + test('normalizeArray numerifies string values when requested', () => { + const table = createModuleArray(); + table.push( + { a: '1', b: '2' }, + { a: '3', b: '4' }, + ); + + const normalized = ChartUtils.normalizeArray(table, true); + + expect(normalized).toEqual({ + a: [1, 3], + b: [2, 4], + }); + }); + + test('mapMonthYearDataToSeries converts integers to chronological series', () => { + const data = { + x: [202201, 202112], + y: [10, 5], + p: [0.5, 0.25], + }; + + const [series] = ChartUtils.mapMonthYearDataToSeries(data); + + expect(series.name).toBe('All Time'); + expect(series.values).toHaveLength(2); + expect(series.values[0].xValue).toEqual(new Date(2021, 11, 1)); + expect(series.values[0].yValue).toBe(5); + expect(series.values[0].yPercent).toBe(0.25); + expect(series.values[1].xValue).toEqual(new Date(2022, 0, 1)); + }); + + test('buildHierarchyFromJSON constructs nested nodes honoring threshold', () => { + const data = { + conceptPath: ['Root||ChildA', 'Root||ChildB'], + percentPersons: [70, 30], + numPersons: [7, 3], + conceptId: [1, 2], + agg: [100, 50], + }; + + const hierarchy = ChartUtils.buildHierarchyFromJSON(data, 0.2, { name: 'agg' }); + + expect(hierarchy.name).toBe('root'); + expect(hierarchy.children).toHaveLength(1); + const root = hierarchy.children[0]; + expect(root.name).toBe('Root'); + expect(root.children).toHaveLength(2); + expect(root.children[0]).toMatchObject({ + name: 'ChildA', + num_persons: 7, + agg_value: 100, + }); + }); + + test('filterByConcept returns predicate matching concept ids', () => { + const predicate = ChartUtils.filterByConcept(99); + + expect(predicate({ conceptId: 99 })).toBe(true); + expect(predicate({ conceptId: 1 })).toBe(false); + }); +}); diff --git a/tests/utils/ExecutionUtils.test.js b/tests/utils/ExecutionUtils.test.js new file mode 100644 index 000000000..7f9d93ef9 --- /dev/null +++ b/tests/utils/ExecutionUtils.test.js @@ -0,0 +1,62 @@ +const constStub = { + generationStatuses: { + STARTED: 'STARTED', + RUNNING: 'RUNNING', + COMPLETED: 'COMPLETED', + }, + executionStatuses: { + PENDING: 'PENDING', + STARTED: 'STARTED', + RUNNING: 'RUNNING', + COMPLETED: 'COMPLETED', + }, +}; + +let executionUtils; +let ko; + +beforeAll(() => { + if (requirejsInstance.defined('const')) { + requirejsInstance.undef('const'); + } + + requirejsInstance.define('const', [], () => constStub); +}); + +beforeAll(async () => { + [executionUtils, ko] = await requireAmd(['utils/ExecutionUtils', 'knockout']); + ko.i18n = jest.fn(() => () => 'Start a new execution?'); +}); + +describe('ExecutionUtils', () => { + describe('StartExecution', () => { + test('rejects when no execution group is provided', async () => { + await expect(executionUtils.StartExecution()).rejects.toBeUndefined(); + }); + + test('resolves immediately when generation is not running', async () => { + const executionGroup = { status: () => constStub.generationStatuses.COMPLETED }; + + await expect(executionUtils.StartExecution(executionGroup)).resolves.toBeUndefined(); + }); + }); + + describe('getExecutionGroupStatus', () => { + test('prioritizes pending over other statuses', () => { + const submissions = ko.observableArray([ + { status: constStub.executionStatuses.RUNNING }, + { status: constStub.executionStatuses.PENDING }, + ]); + + expect(executionUtils.getExecutionGroupStatus(submissions)).toBe(constStub.executionStatuses.PENDING); + }); + + test('falls back to completed when no active submissions remain', () => { + const submissions = ko.observableArray([ + { status: constStub.executionStatuses.COMPLETED }, + ]); + + expect(executionUtils.getExecutionGroupStatus(submissions)).toBe(constStub.executionStatuses.COMPLETED); + }); + }); +}); diff --git a/tests/utils/Renderers.test.js b/tests/utils/Renderers.test.js new file mode 100644 index 000000000..354bc6568 --- /dev/null +++ b/tests/utils/Renderers.test.js @@ -0,0 +1,40 @@ +let renderers; +let ko; + +beforeAll(async () => { + [renderers, ko] = await requireAmd(['utils/Renderers', 'knockout']); +}); + +describe('Renderers', () => { + describe('renderCheckbox', () => { + test('includes click binding when checkbox is interactive', () => { + const template = renderers.renderCheckbox('isSelected'); + + expect(template).toContain('click: function(d) { d.isSelected(!d.isSelected()); },'); + expect(template).toContain('css: { selected: isSelected }'); + }); + + test('omits click binding when checkbox is read-only', () => { + const template = renderers.renderCheckbox('isActive', false); + + expect(template).not.toContain('click: function'); + expect(template).toContain('css: { selected: isActive }'); + }); + }); + + describe('renderConceptSetCheckbox', () => { + test('returns interactive markup when permissions are granted', () => { + const template = renderers.renderConceptSetCheckbox(ko.observable(true), 'isConceptSelected'); + + expect(template).toContain('click: function(d) { d.isConceptSelected(!d.isConceptSelected()); },'); + expect(template).not.toContain('readonly'); + }); + + test('returns readonly markup when permissions are missing or readonly flag is true', () => { + const template = renderers.renderConceptSetCheckbox(ko.observable(false), 'isConceptSelected', true); + + expect(template).toContain('readonly'); + expect(template).not.toContain('click: function'); + }); + }); +});