UNPKG

@memberjunction/ng-shared

Version:

MemberJunction: MJ Explorer Angular Shared Package - utility functions and other reusable elements used across other MJ Angular packages within the MJ Explorer App - do not use outside of MJ Explorer.

307 lines 12.7 kB
/** * Tests for the Navigation Back/Forward Framework: * - BaseResourceComponent query param lifecycle (OnQueryParamsChanged, UpdateQueryParams, GetQueryParams) * - NavigationService.NotifyQueryParamsChanged / QueryParamChanged$ * - Suppression flag prevents loops * - Tab-scoped filtering prevents cross-tab leakage */ import { describe, it, expect, vi } from 'vitest'; // Mock Angular dependencies vi.mock('@angular/core', () => ({ Directive: () => (target) => target, Injectable: () => (target) => target, OnInit: class { }, OnDestroy: class { }, inject: vi.fn(), Input: () => () => { }, Output: () => () => { }, EventEmitter: class { emit() { } }, })); vi.mock('@angular/router', () => ({})); vi.mock('@memberjunction/core', () => ({ BaseEntity: class { }, Metadata: class { }, CompositeKey: class { }, })); vi.mock('@memberjunction/core-entities', () => ({ ResourceData: class { ID = 0; Name = ''; ResourceTypeID = ''; ResourceRecordID = ''; Configuration = {}; constructor(data) { if (data) Object.assign(this, data); } }, })); vi.mock('@memberjunction/global', () => ({ UUIDsEqual: (a, b) => a === b, })); // ---- QueryParamChangeEvent tests ---- describe('QueryParamChangeEvent', () => { it('should have TabId and Params fields', () => { // Verify the interface shape by creating a conforming object const event = { TabId: 'tab-123', Params: { entity: 'Actions', filter: 'active' }, }; expect(event.TabId).toBe('tab-123'); expect(event.Params.entity).toBe('Actions'); expect(event.Params.filter).toBe('active'); }); }); // ---- Shell helper function tests (pure functions extracted for testing) ---- describe('extractQueryParamsFromUrl', () => { // Replicate the shell's extractQueryParamsFromUrl logic for unit testing function extractQueryParamsFromUrl(url) { const fragmentIndex = url.indexOf('#'); const cleanUrl = fragmentIndex !== -1 ? url.substring(0, fragmentIndex) : url; const queryIndex = cleanUrl.indexOf('?'); if (queryIndex === -1) return {}; const params = new URLSearchParams(cleanUrl.substring(queryIndex + 1)); const result = {}; params.forEach((value, key) => { result[key] = value; }); return result; } it('should extract query params from URL', () => { const result = extractQueryParamsFromUrl('/app/data-explorer/Data?entity=Actions&filter=active'); expect(result).toEqual({ entity: 'Actions', filter: 'active' }); }); it('should return empty object for URL without query params', () => { expect(extractQueryParamsFromUrl('/app/data-explorer/Data')).toEqual({}); }); it('should decode encoded values', () => { const result = extractQueryParamsFromUrl('/app/data?entity=MJ%3A%20Actions'); expect(result.entity).toBe('MJ: Actions'); }); it('should handle empty values', () => { const result = extractQueryParamsFromUrl('/app/data?key='); expect(result.key).toBe(''); }); it('should ignore fragment (#hash)', () => { const result = extractQueryParamsFromUrl('/app/data?entity=Members#section'); expect(result.entity).toBe('Members'); expect(result['#section']).toBeUndefined(); }); it('should handle URL with only fragment, no query params', () => { const result = extractQueryParamsFromUrl('/app/data#section'); expect(result).toEqual({}); }); it('should handle + as space', () => { const result = extractQueryParamsFromUrl('/app/data?entity=My+Entity'); expect(result.entity).toBe('My Entity'); }); }); describe('queryParamsEqual', () => { // Replicate the shell's queryParamsEqual logic function queryParamsEqual(a, b) { const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; return keysA.every(key => decodeURIComponent(a[key]?.replace(/\+/g, ' ') || '') === decodeURIComponent(b[key]?.replace(/\+/g, ' ') || '')); } it('should return true for identical params', () => { expect(queryParamsEqual({ entity: 'Actions' }, { entity: 'Actions' })).toBe(true); }); it('should return false for different values', () => { expect(queryParamsEqual({ entity: 'Actions' }, { entity: 'Members' })).toBe(false); }); it('should return false for different key counts', () => { expect(queryParamsEqual({ entity: 'Actions' }, { entity: 'Actions', filter: 'x' })).toBe(false); }); it('should return true for both empty', () => { expect(queryParamsEqual({}, {})).toBe(true); }); it('should return false for one empty, one not', () => { expect(queryParamsEqual({}, { entity: 'Actions' })).toBe(false); }); it('should normalize + vs %20 encoding', () => { expect(queryParamsEqual({ entity: 'My+Entity' }, { entity: 'My%20Entity' })).toBe(true); }); it('should handle missing keys', () => { expect(queryParamsEqual({ a: 'x' }, { b: 'x' })).toBe(false); }); }); // ---- buildResourceUrl appendQP helper tests ---- describe('appendQueryParams (appendQP helper)', () => { function appendQP(url, queryParams) { if (!queryParams || Object.keys(queryParams).length === 0) return url; const separator = url.includes('?') ? '&' : '?'; const params = new URLSearchParams(queryParams); return `${url}${separator}${params.toString()}`; } it('should append params to URL without existing params', () => { const result = appendQP('/app/data/record/Entity/123', { entity: 'Actions' }); expect(result).toBe('/app/data/record/Entity/123?entity=Actions'); }); it('should append params to URL with existing params', () => { const result = appendQP('/app/data?existing=x', { entity: 'Actions' }); expect(result).toBe('/app/data?existing=x&entity=Actions'); }); it('should return URL unchanged for empty params', () => { expect(appendQP('/app/data', {})).toBe('/app/data'); }); it('should return URL unchanged for undefined params', () => { expect(appendQP('/app/data', undefined)).toBe('/app/data'); }); it('should properly encode special characters', () => { const result = appendQP('/app/data', { entity: 'MJ: Actions' }); expect(result).toContain('entity=MJ'); // URLSearchParams encodes spaces as + expect(result).toMatch(/entity=MJ[+%].*Actions/); }); it('should handle multiple params', () => { const result = appendQP('/app/data', { entity: 'Actions', filter: 'active', view: 'grid' }); expect(result).toContain('entity=Actions'); expect(result).toContain('filter=active'); expect(result).toContain('view=grid'); }); }); // ---- shouldReuseRoute logic tests ---- describe('shouldReuseRoute logic', () => { // Test the comparison logic (routeConfig + params, NOT queryParams) function objectContentsEqual(obj1, obj2) { if (obj1 === obj2) return true; if (!obj1 || !obj2) return false; const keys1 = Object.keys(obj1); const keys2 = Object.keys(obj2); if (keys1.length !== keys2.length) return false; return keys1.every(key => obj1[key] === obj2[key]); } function shouldReuseRoute(futureConfig, currConfig, futureParams, currParams) { return futureConfig === currConfig && objectContentsEqual(futureParams, currParams); } it('should return true for same config and params with different query params', () => { const config = {}; // Query params intentionally excluded expect(shouldReuseRoute(config, config, { id: '1' }, { id: '1' })).toBe(true); }); it('should return false for different path params', () => { const config = {}; expect(shouldReuseRoute(config, config, { id: '1' }, { id: '2' })).toBe(false); }); it('should return false for different route configs', () => { expect(shouldReuseRoute({}, {}, { id: '1' }, { id: '1' })).toBe(false); }); it('should return true for same everything', () => { const config = {}; expect(shouldReuseRoute(config, config, {}, {})).toBe(true); }); it('should return false when one has params and other does not', () => { const config = {}; expect(shouldReuseRoute(config, config, { id: '1' }, {})).toBe(false); }); }); // ---- Suppression flag behavior ---- describe('_suppressQueryParamSync behavior', () => { it('should prevent UpdateQueryParams during suppression', () => { let suppressFlag = false; const mockUpdateActiveTabQP = vi.fn(); function updateQueryParams(params) { if (suppressFlag) return; mockUpdateActiveTabQP(params); } // Normal call updateQueryParams({ entity: 'Actions' }); expect(mockUpdateActiveTabQP).toHaveBeenCalledTimes(1); // Suppressed call (simulates being inside OnQueryParamsChanged) suppressFlag = true; updateQueryParams({ entity: 'Members' }); expect(mockUpdateActiveTabQP).toHaveBeenCalledTimes(1); // Still 1, not 2 // After suppression cleared suppressFlag = false; updateQueryParams({ entity: 'Queries' }); expect(mockUpdateActiveTabQP).toHaveBeenCalledTimes(2); }); it('should clear suppression flag even if OnQueryParamsChanged throws', () => { let suppressFlag = false; function simulateSubscription(callback) { suppressFlag = true; try { callback(); } finally { suppressFlag = false; } } // The try/finally ensures flag is cleared even when callback throws try { simulateSubscription(() => { throw new Error('Component error'); }); } catch { // Expected — the error propagates but flag should still be cleared } // Flag should be cleared despite the error expect(suppressFlag).toBe(false); }); }); // ---- GetQueryParams behavior ---- describe('GetQueryParams', () => { it('should return params from Configuration.queryParams', () => { const config = { queryParams: { entity: 'Actions', filter: 'active' } }; const result = config['queryParams'] || {}; expect(result).toEqual({ entity: 'Actions', filter: 'active' }); }); it('should return empty object when no queryParams', () => { const config = {}; const result = config['queryParams'] || {}; expect(result).toEqual({}); }); it('should return empty object when Configuration is null', () => { const config = null; const result = (config?.['queryParams'] ?? {}); expect(result).toEqual({}); }); }); // ---- Tab-scoped filtering ---- describe('Tab-scoped query param filtering', () => { it('should only deliver events matching the component tab ID', () => { const componentTabId = 'tab-abc'; const events = [ { TabId: 'tab-abc', Params: { entity: 'Actions' } }, { TabId: 'tab-xyz', Params: { entity: 'Members' } }, { TabId: 'tab-abc', Params: { entity: 'Queries' } }, ]; const received = events.filter(e => e.TabId === componentTabId); expect(received).toHaveLength(2); expect(received[0].Params.entity).toBe('Actions'); expect(received[1].Params.entity).toBe('Queries'); }); it('should not deliver any events for non-matching tab ID', () => { const componentTabId = 'tab-none'; const events = [ { TabId: 'tab-abc', Params: { entity: 'Actions' } }, { TabId: 'tab-xyz', Params: { entity: 'Members' } }, ]; const received = events.filter(e => e.TabId === componentTabId); expect(received).toHaveLength(0); }); it('should handle empty tab ID gracefully', () => { const componentTabId = ''; const events = [ { TabId: 'tab-abc', Params: { entity: 'Actions' } }, ]; const received = events.filter(e => e.TabId === componentTabId); expect(received).toHaveLength(0); }); }); //# sourceMappingURL=navigation-framework.test.js.map