diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index bd48e27..8c24696 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -34,4 +34,11 @@ jobs:
- name: make test-js ๐งช
run: |
- npm run test
+ npm run coverage
+
+ - name: upload coverage ๐
+ uses: codecov/codecov-action@v5
+ with:
+ files: ./coverage/coverage-final.json
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: bmlt-enabled/activity
diff --git a/.nvmrc b/.nvmrc
index 4c8f24d..c519bf5 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-v24.7.0
+v24.11.0
diff --git a/README.md b/README.md
index 2e5b743..58e1ad2 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,11 @@
# BMLT Server Activity Report
+[](https://github.com/bmlt-enabled/activity/actions/workflows/test.yml)
+[](https://github.com/bmlt-enabled/activity/actions/workflows/release.yml)
+[](https://codecov.io/gh/bmlt-enabled/activity)
+[](https://opensource.org/licenses/MIT)
+[](https://github.com/bmlt-enabled/activity/releases)
+
+A Svelte 5 application that displays change activity reports from BMLT (Basic Meeting List Toolbox) servers.
+
* Bootstrapped with [Svelte 5 + TypeScript + Vite + Tailwind CSS Bootstrap](https://github.com/pjaudiomv/svelte5-vite-ts-tailwind-eslint)
diff --git a/src/components/CharacterSelector.svelte b/src/components/CharacterSelector.svelte
deleted file mode 100644
index 697a35a..0000000
--- a/src/components/CharacterSelector.svelte
+++ /dev/null
@@ -1,45 +0,0 @@
-
-
-
-
- {#if characterQuote}
-
- "{characterQuote}"
-
- {/if}
-
-
-
diff --git a/src/components/TimeInput.svelte b/src/components/TimeInput.svelte
deleted file mode 100644
index 8a5e6c1..0000000
--- a/src/components/TimeInput.svelte
+++ /dev/null
@@ -1,50 +0,0 @@
-
-
-
-
-
diff --git a/src/tests/components/ActivityReport.test.ts b/src/tests/components/ActivityReport.test.ts
new file mode 100644
index 0000000..d11451c
--- /dev/null
+++ b/src/tests/components/ActivityReport.test.ts
@@ -0,0 +1,22 @@
+import { describe, test, expect, beforeEach } from 'vitest';
+import { render } from '@testing-library/svelte';
+import ActivityReport from '../../components/ActivityReport.svelte';
+
+describe('ActivityReport component', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ test('renders without crashing', () => {
+ const { container } = render(ActivityReport);
+ expect(container).toBeTruthy();
+ });
+
+ test('renders with gradient background', () => {
+ const { container } = render(ActivityReport);
+
+ const wrapper = container.querySelector('.min-h-screen');
+ expect(wrapper).toBeInTheDocument();
+ expect(wrapper?.classList.contains('bg-gradient-to-br')).toBe(true);
+ });
+});
diff --git a/src/tests/components/ActivityTable.test.ts b/src/tests/components/ActivityTable.test.ts
new file mode 100644
index 0000000..c9c28f0
--- /dev/null
+++ b/src/tests/components/ActivityTable.test.ts
@@ -0,0 +1,200 @@
+import { describe, test, expect } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import ActivityTable from '../../components/ActivityTable.svelte';
+import type { BmltChange } from '../../lib/types';
+
+describe('ActivityTable component', () => {
+ const mockChanges: BmltChange[] = [
+ {
+ change_id: '1',
+ user_name: 'Alice',
+ date_string: '2024-01-01 10:00:00',
+ date_int: 20240101,
+ change_type: 'comdef_change_type_new',
+ meeting_name: 'Morning Meeting',
+ service_body_name: 'Central Service'
+ },
+ {
+ change_id: '2',
+ user_name: 'Bob',
+ date_string: '2024-01-02 14:30:00',
+ date_int: 20240102,
+ change_type: 'comdef_change_type_change',
+ meeting_name: 'Evening Meeting',
+ service_body_name: 'Area Service'
+ }
+ ];
+
+ test('shows empty state when no changes', () => {
+ render(ActivityTable, {
+ props: {
+ changes: []
+ }
+ });
+
+ expect(screen.getByText('No changes found for the specified date range.')).toBeInTheDocument();
+ });
+
+ test('renders table with changes', () => {
+ render(ActivityTable, {
+ props: {
+ changes: mockChanges
+ }
+ });
+
+ expect(screen.getAllByText('Morning Meeting').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Evening Meeting').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Alice').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Bob').length).toBeGreaterThan(0);
+ });
+
+ test('displays change type badges', () => {
+ render(ActivityTable, {
+ props: {
+ changes: mockChanges
+ }
+ });
+
+ expect(screen.getAllByText('new').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('change').length).toBeGreaterThan(0);
+ });
+
+ test('displays service body names', () => {
+ render(ActivityTable, {
+ props: {
+ changes: mockChanges
+ }
+ });
+
+ expect(screen.getAllByText('Central Service').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Area Service').length).toBeGreaterThan(0);
+ });
+
+ test('displays date strings', () => {
+ render(ActivityTable, {
+ props: {
+ changes: mockChanges
+ }
+ });
+
+ expect(screen.getAllByText('2024-01-01 10:00:00').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('2024-01-02 14:30:00').length).toBeGreaterThan(0);
+ });
+
+ test('displays change IDs', () => {
+ render(ActivityTable, {
+ props: {
+ changes: mockChanges
+ }
+ });
+
+ expect(screen.getAllByText('1').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('2').length).toBeGreaterThan(0);
+ });
+
+ test('shows sortable column headers', () => {
+ render(ActivityTable, {
+ props: {
+ changes: mockChanges
+ }
+ });
+
+ expect(screen.getByText('Date & Time')).toBeInTheDocument();
+ expect(screen.getByText('User')).toBeInTheDocument();
+ expect(screen.getByText('Change Type')).toBeInTheDocument();
+ expect(screen.getByText('Meeting Name')).toBeInTheDocument();
+ expect(screen.getByText('Service Body')).toBeInTheDocument();
+ expect(screen.getByText('Change ID')).toBeInTheDocument();
+ });
+
+ test('opens modal when row is clicked', async () => {
+ const user = userEvent.setup();
+ render(ActivityTable, {
+ props: {
+ changes: mockChanges
+ }
+ });
+
+ // Click on a table row (clicking the first occurrence of meeting name)
+ const meetingNames = screen.getAllByText('Morning Meeting');
+ await user.click(meetingNames[0]);
+
+ // Modal should open with "Change Details" title
+ expect(await screen.findByText('Change Details')).toBeInTheDocument();
+ });
+
+ test('modal displays change information', async () => {
+ const user = userEvent.setup();
+ const changeWithDetails: BmltChange = {
+ ...mockChanges[0],
+ details: 'Meeting name changed. Location updated.'
+ };
+
+ render(ActivityTable, {
+ props: {
+ changes: [changeWithDetails]
+ }
+ });
+
+ const meetingNames = screen.getAllByText('Morning Meeting');
+ await user.click(meetingNames[0]);
+
+ // Check modal content
+ expect(await screen.findByText('Change Details')).toBeInTheDocument();
+ expect(screen.getAllByText('Morning Meeting').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Alice').length).toBeGreaterThan(0);
+ });
+
+ test('handles missing service body name', () => {
+ const changeWithoutServiceBody: BmltChange = {
+ ...mockChanges[0],
+ service_body_name: undefined
+ };
+
+ render(ActivityTable, {
+ props: {
+ changes: [changeWithoutServiceBody]
+ }
+ });
+
+ expect(screen.getAllByText('N/A').length).toBeGreaterThan(0);
+ });
+
+ test('sorts by date by default (descending)', () => {
+ const { container } = render(ActivityTable, {
+ props: {
+ changes: mockChanges
+ }
+ });
+
+ const rows = container.querySelectorAll('tbody tr');
+ expect(rows.length).toBe(2);
+
+ // First row should be the newer change (Bob's)
+ expect(rows[0].textContent).toContain('Bob');
+ });
+
+ test('handles changes with json_data', async () => {
+ const user = userEvent.setup();
+ const changeWithJsonData: BmltChange = {
+ ...mockChanges[0],
+ json_data: JSON.stringify({
+ before: { meeting_name: 'Old Name' },
+ after: { meeting_name: 'New Name' }
+ })
+ };
+
+ render(ActivityTable, {
+ props: {
+ changes: [changeWithJsonData]
+ }
+ });
+
+ const meetingNames = screen.getAllByText('Morning Meeting');
+ await user.click(meetingNames[0]);
+
+ // Should show the modal
+ expect(await screen.findByText('Change Details')).toBeInTheDocument();
+ });
+});
diff --git a/src/tests/components/ConfigModal.test.ts b/src/tests/components/ConfigModal.test.ts
new file mode 100644
index 0000000..aff431c
--- /dev/null
+++ b/src/tests/components/ConfigModal.test.ts
@@ -0,0 +1,82 @@
+import { describe, test, expect, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import ConfigModal from '../../components/ConfigModal.svelte';
+
+// Mock the services
+vi.mock('../../lib/services/serverList', () => ({
+ fetchServerList: vi.fn().mockResolvedValue([{ id: '1', name: 'Test Server', url: 'https://test.com' }]),
+ fetchServiceBodies: vi.fn().mockResolvedValue([{ id: '1', name: 'Test Service Body', type: 'AS' }])
+}));
+
+describe('ConfigModal component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ });
+
+ test('renders modal when open', () => {
+ render(ConfigModal, {
+ props: {
+ open: true,
+ onsubmit: () => {}
+ }
+ });
+
+ expect(screen.getByText('Configuration')).toBeInTheDocument();
+ });
+
+ test('shows loading spinner initially', () => {
+ render(ConfigModal, {
+ props: {
+ open: true,
+ onsubmit: () => {}
+ }
+ });
+
+ expect(screen.getByText('Loading servers...')).toBeInTheDocument();
+ });
+
+ test('displays form fields after loading', async () => {
+ render(ConfigModal, {
+ props: {
+ open: true,
+ onsubmit: () => {}
+ }
+ });
+
+ // Wait for loading to finish
+ expect(await screen.findByLabelText('BMLT Server')).toBeInTheDocument();
+ });
+
+ test('calls onsubmit when form is submitted', async () => {
+ const onsubmit = vi.fn();
+ render(ConfigModal, {
+ props: {
+ open: true,
+ onsubmit
+ }
+ });
+
+ // Wait for form to load
+ await screen.findByLabelText('BMLT Server');
+
+ // Note: Full form submission testing would require more complex setup
+ // This test validates the basic structure
+ expect(onsubmit).not.toHaveBeenCalled();
+ });
+
+ test('displays service body type groups', async () => {
+ render(ConfigModal, {
+ props: {
+ open: true,
+ onsubmit: () => {}
+ }
+ });
+
+ // Wait for loading
+ await screen.findByLabelText('BMLT Server');
+
+ // The modal should be rendered
+ expect(screen.getByText('Configuration')).toBeInTheDocument();
+ });
+});
diff --git a/src/tests/components/Filters.test.ts b/src/tests/components/Filters.test.ts
new file mode 100644
index 0000000..592f0bc
--- /dev/null
+++ b/src/tests/components/Filters.test.ts
@@ -0,0 +1,164 @@
+import { describe, test, expect } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import Filters from '../../components/Filters.svelte';
+
+describe('Filters component', () => {
+ test('renders all filter controls', () => {
+ render(Filters, {
+ props: {
+ searchTerm: '',
+ selectedType: '',
+ selectedUser: '',
+ users: []
+ }
+ });
+
+ expect(screen.getByLabelText('Search')).toBeInTheDocument();
+ expect(screen.getByLabelText('Change Type')).toBeInTheDocument();
+ expect(screen.getByLabelText('User')).toBeInTheDocument();
+ });
+
+ test('displays search placeholder text', () => {
+ render(Filters, {
+ props: {
+ searchTerm: '',
+ selectedType: '',
+ selectedUser: '',
+ users: []
+ }
+ });
+
+ const searchInput = screen.getByPlaceholderText('Search by meeting name, user, or change type...');
+ expect(searchInput).toBeInTheDocument();
+ });
+
+ test('displays all change type options', () => {
+ render(Filters, {
+ props: {
+ searchTerm: '',
+ selectedType: '',
+ selectedUser: '',
+ users: []
+ }
+ });
+
+ const typeSelect = screen.getByLabelText('Change Type') as HTMLSelectElement;
+ const options = Array.from(typeSelect.options).map((opt) => opt.text);
+
+ expect(options).toContain('All Types');
+ expect(options).toContain('Changes');
+ expect(options).toContain('New');
+ expect(options).toContain('Deleted');
+ expect(options).toContain('Rollback');
+ });
+
+ test('displays user options including "All Users"', () => {
+ render(Filters, {
+ props: {
+ searchTerm: '',
+ selectedType: '',
+ selectedUser: '',
+ users: ['Alice', 'Bob', 'Charlie']
+ }
+ });
+
+ const userSelect = screen.getByLabelText('User') as HTMLSelectElement;
+ const options = Array.from(userSelect.options).map((opt) => opt.text);
+
+ expect(options).toContain('All Users');
+ expect(options).toContain('Alice');
+ expect(options).toContain('Bob');
+ expect(options).toContain('Charlie');
+ });
+
+ test('allows typing in search input', async () => {
+ const user = userEvent.setup();
+ render(Filters, {
+ props: {
+ searchTerm: '',
+ selectedType: '',
+ selectedUser: '',
+ users: []
+ }
+ });
+
+ const searchInput = screen.getByLabelText('Search') as HTMLInputElement;
+
+ await user.type(searchInput, 'test meeting');
+
+ expect(searchInput.value).toBe('test meeting');
+ });
+
+ test('allows selecting a change type', async () => {
+ const user = userEvent.setup();
+ render(Filters, {
+ props: {
+ searchTerm: '',
+ selectedType: '',
+ selectedUser: '',
+ users: []
+ }
+ });
+
+ const typeSelect = screen.getByLabelText('Change Type') as HTMLSelectElement;
+
+ await user.selectOptions(typeSelect, 'new');
+
+ expect(typeSelect.value).toBe('new');
+ });
+
+ test('allows selecting a user', async () => {
+ const user = userEvent.setup();
+ render(Filters, {
+ props: {
+ searchTerm: '',
+ selectedType: '',
+ selectedUser: '',
+ users: ['Alice', 'Bob']
+ }
+ });
+
+ const userSelect = screen.getByLabelText('User') as HTMLSelectElement;
+
+ await user.selectOptions(userSelect, 'Alice');
+
+ expect(userSelect.value).toBe('Alice');
+ });
+
+ test('renders with initial values', () => {
+ render(Filters, {
+ props: {
+ searchTerm: 'initial search',
+ selectedType: 'change',
+ selectedUser: 'Bob',
+ users: ['Alice', 'Bob', 'Charlie']
+ }
+ });
+
+ const searchInput = screen.getByLabelText('Search') as HTMLInputElement;
+ const typeSelect = screen.getByLabelText('Change Type') as HTMLSelectElement;
+ const userSelect = screen.getByLabelText('User') as HTMLSelectElement;
+
+ expect(searchInput.value).toBe('initial search');
+ expect(typeSelect.value).toBe('change');
+ expect(userSelect.value).toBe('Bob');
+ });
+
+ test('handles empty user list', () => {
+ render(Filters, {
+ props: {
+ searchTerm: '',
+ selectedType: '',
+ selectedUser: '',
+ users: []
+ }
+ });
+
+ const userSelect = screen.getByLabelText('User') as HTMLSelectElement;
+ const options = Array.from(userSelect.options).map((opt) => opt.text);
+
+ expect(options).toContain('All Users');
+ expect(options.length).toBeGreaterThanOrEqual(1);
+ });
+});
diff --git a/src/tests/components/Stats.test.ts b/src/tests/components/Stats.test.ts
new file mode 100644
index 0000000..e5f6cf2
--- /dev/null
+++ b/src/tests/components/Stats.test.ts
@@ -0,0 +1,107 @@
+import { describe, test, expect } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import Stats from '../../components/Stats.svelte';
+import type { ChangeTypeStats } from '../../lib/types';
+
+describe('Stats component', () => {
+ test('renders all stat cards', () => {
+ const changeTypes: ChangeTypeStats = {
+ comdef_change_type_change: 10,
+ comdef_change_type_new: 5,
+ comdef_change_type_delete: 2
+ };
+
+ render(Stats, {
+ props: {
+ totalChanges: 17,
+ activeUsers: 8,
+ changeTypes
+ }
+ });
+
+ expect(screen.getByText('Total Changes')).toBeInTheDocument();
+ expect(screen.getByText('Active Users')).toBeInTheDocument();
+ expect(screen.getByText('Edits')).toBeInTheDocument();
+ expect(screen.getByText('New Meetings')).toBeInTheDocument();
+ expect(screen.getByText('Deletions')).toBeInTheDocument();
+ });
+
+ test('displays correct stat values', () => {
+ const changeTypes: ChangeTypeStats = {
+ comdef_change_type_change: 25,
+ comdef_change_type_new: 10,
+ comdef_change_type_delete: 3
+ };
+
+ render(Stats, {
+ props: {
+ totalChanges: 38,
+ activeUsers: 12,
+ changeTypes
+ }
+ });
+
+ expect(screen.getByText('38')).toBeInTheDocument();
+ expect(screen.getByText('12')).toBeInTheDocument();
+ expect(screen.getByText('25')).toBeInTheDocument();
+ expect(screen.getByText('10')).toBeInTheDocument();
+ expect(screen.getByText('3')).toBeInTheDocument();
+ });
+
+ test('formats large numbers with commas', () => {
+ const changeTypes: ChangeTypeStats = {
+ comdef_change_type_change: 500,
+ comdef_change_type_new: 200,
+ comdef_change_type_delete: 50
+ };
+
+ render(Stats, {
+ props: {
+ totalChanges: 1234,
+ activeUsers: 45,
+ changeTypes
+ }
+ });
+
+ expect(screen.getByText('1,234')).toBeInTheDocument();
+ });
+
+ test('handles missing change types with zero', () => {
+ const changeTypes: ChangeTypeStats = {};
+
+ render(Stats, {
+ props: {
+ totalChanges: 0,
+ activeUsers: 0,
+ changeTypes
+ }
+ });
+
+ // Should display 0 for missing change types
+ const zeros = screen.getAllByText('0');
+ expect(zeros.length).toBeGreaterThanOrEqual(3); // At least edits, new, deletions
+ });
+
+ test('handles partial change type data', () => {
+ const changeTypes: ChangeTypeStats = {
+ comdef_change_type_change: 15
+ // Missing new and delete
+ };
+
+ render(Stats, {
+ props: {
+ totalChanges: 15,
+ activeUsers: 5,
+ changeTypes
+ }
+ });
+
+ // Both total changes and edits show 15
+ const fifteens = screen.getAllByText('15');
+ expect(fifteens.length).toBeGreaterThanOrEqual(1);
+
+ // Should show 0 for missing types
+ const zeros = screen.getAllByText('0');
+ expect(zeros.length).toBeGreaterThanOrEqual(2); // new and delete
+ });
+});
diff --git a/src/tests/config.test.ts b/src/tests/config.test.ts
new file mode 100644
index 0000000..12fb818
--- /dev/null
+++ b/src/tests/config.test.ts
@@ -0,0 +1,119 @@
+import { describe, test, expect, beforeEach, vi } from 'vitest';
+
+describe('config store', () => {
+ beforeEach(() => {
+ // Clear localStorage before each test
+ localStorage.clear();
+ vi.clearAllMocks();
+ });
+
+ test('initializes with default values', async () => {
+ const { config } = await import('../lib/stores/config.svelte');
+
+ expect(config.bmltServer).toBe('');
+ expect(config.serviceBodyIds).toEqual([]);
+ expect(config.daysPassed).toBe(180);
+ expect(config.timezone).toBe('America/New_York');
+ });
+
+ test('saves to localStorage when bmltServer is set', async () => {
+ const { config } = await import('../lib/stores/config.svelte');
+
+ config.bmltServer = 'https://example.com';
+
+ const stored = localStorage.getItem('bmlt-activity-report-config');
+ expect(stored).toBeTruthy();
+
+ const parsed = JSON.parse(stored!);
+ expect(parsed.bmltServer).toBe('https://example.com');
+ });
+
+ test('saves to localStorage when serviceBodyIds are set', async () => {
+ const { config } = await import('../lib/stores/config.svelte');
+
+ config.serviceBodyIds = ['1', '2', '3'];
+
+ const stored = localStorage.getItem('bmlt-activity-report-config');
+ const parsed = JSON.parse(stored!);
+ expect(parsed.serviceBodyIds).toEqual(['1', '2', '3']);
+ });
+
+ test('saves to localStorage when daysPassed is set', async () => {
+ const { config } = await import('../lib/stores/config.svelte');
+
+ config.daysPassed = 90;
+
+ const stored = localStorage.getItem('bmlt-activity-report-config');
+ const parsed = JSON.parse(stored!);
+ expect(parsed.daysPassed).toBe(90);
+ });
+
+ test('saves to localStorage when timezone is set', async () => {
+ const { config } = await import('../lib/stores/config.svelte');
+
+ config.timezone = 'America/Los_Angeles';
+
+ const stored = localStorage.getItem('bmlt-activity-report-config');
+ const parsed = JSON.parse(stored!);
+ expect(parsed.timezone).toBe('America/Los_Angeles');
+ });
+
+ test('loads from localStorage on initialization', async () => {
+ // Set up localStorage before importing
+ localStorage.setItem(
+ 'bmlt-activity-report-config',
+ JSON.stringify({
+ bmltServer: 'https://loaded.com',
+ serviceBodyIds: ['10', '20'],
+ daysPassed: 30,
+ timezone: 'Europe/London'
+ })
+ );
+
+ // Clear module cache and reimport to trigger constructor
+ vi.resetModules();
+ const { config } = await import('../lib/stores/config.svelte');
+
+ expect(config.bmltServer).toBe('https://loaded.com');
+ expect(config.serviceBodyIds).toEqual(['10', '20']);
+ expect(config.daysPassed).toBe(30);
+ expect(config.timezone).toBe('Europe/London');
+ });
+
+ test('handles corrupt localStorage data gracefully', async () => {
+ // Set corrupt data
+ localStorage.setItem('bmlt-activity-report-config', 'invalid json{');
+
+ // Mock console.error to suppress expected error output
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ // Should not throw, should use defaults
+ vi.resetModules();
+ const { config } = await import('../lib/stores/config.svelte');
+
+ expect(config.bmltServer).toBe('');
+ expect(config.daysPassed).toBe(180);
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to load config from localStorage:', expect.any(SyntaxError));
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ test('updates all fields and saves atomically', async () => {
+ const { config } = await import('../lib/stores/config.svelte');
+
+ config.bmltServer = 'https://server.com';
+ config.serviceBodyIds = ['5'];
+ config.daysPassed = 60;
+ config.timezone = 'Asia/Tokyo';
+
+ const stored = localStorage.getItem('bmlt-activity-report-config');
+ const parsed = JSON.parse(stored!);
+
+ expect(parsed).toEqual({
+ bmltServer: 'https://server.com',
+ serviceBodyIds: ['5'],
+ daysPassed: 60,
+ timezone: 'Asia/Tokyo'
+ });
+ });
+});
diff --git a/src/tests/detailsFormatter.test.ts b/src/tests/detailsFormatter.test.ts
new file mode 100644
index 0000000..742c7f7
--- /dev/null
+++ b/src/tests/detailsFormatter.test.ts
@@ -0,0 +1,126 @@
+import { describe, test, expect } from 'vitest';
+import { formatDetailsAsList } from '../lib/utils/detailsFormatter';
+
+describe('detailsFormatter utility', () => {
+ describe('formatDetailsAsList', () => {
+ test('returns empty array for null input', () => {
+ expect(formatDetailsAsList(null)).toEqual([]);
+ });
+
+ test('returns empty array for undefined input', () => {
+ expect(formatDetailsAsList(undefined)).toEqual([]);
+ });
+
+ test('returns empty array for empty string', () => {
+ expect(formatDetailsAsList('')).toEqual([]);
+ });
+
+ test('splits on periods to create list items', () => {
+ const details = 'First change. Second change. Third change';
+ const result = formatDetailsAsList(details);
+
+ expect(result).toEqual(['First change', 'Second change', 'Third change']);
+ });
+
+ test('preserves URLs with dots', () => {
+ const details = 'URL changed to https://example.com/path. Other change';
+ const result = formatDetailsAsList(details);
+
+ // When the string doesn't end with a period, the second part is included in the split
+ expect(result.length).toBeGreaterThan(0);
+ expect(result.some((item) => item.includes('https://example.com/path'))).toBe(true);
+ });
+
+ test('preserves email addresses with dots', () => {
+ const details = 'Email changed to user@example.com. Other change';
+ const result = formatDetailsAsList(details);
+
+ expect(result).toEqual(['Email changed to user@example.com', 'Other change']);
+ });
+
+ test('preserves coordinates with decimal points', () => {
+ const details = 'Latitude changed from "42.123456 to "42.654321';
+ const result = formatDetailsAsList(details);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toContain('42.123456');
+ expect(result[0]).toContain('42.654321');
+ });
+
+ test('preserves negative coordinates', () => {
+ const details = 'Longitude changed from "-71.123456 to "-71.654321';
+ const result = formatDetailsAsList(details);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toContain('-71.123456');
+ expect(result[0]).toContain('-71.654321');
+ });
+
+ test('removes unwanted root_server_uri phrase', () => {
+ const details = 'Name changed. root_server_uri was added as "https:" . Other change.';
+ const result = formatDetailsAsList(details);
+
+ expect(result).toHaveLength(2);
+ expect(result.some((item) => item.includes('Name changed'))).toBe(true);
+ expect(result.some((item) => item.includes('Other change'))).toBe(true);
+ });
+
+ test('removes empty email_contact changes', () => {
+ const details = 'Name changed. email_contact was changed from "" to "". Other change';
+ const result = formatDetailsAsList(details);
+
+ expect(result).toEqual(['Name changed', 'Other change']);
+ });
+
+ test('removes #@-@# from custom fields', () => {
+ const details = 'Field changed #@-@# to new value';
+ const result = formatDetailsAsList(details);
+
+ expect(result[0]).not.toContain('#@-@#');
+ expect(result[0]).toContain('Field changed to new value');
+ });
+
+ test('handles complex mixed content', () => {
+ const details = 'Meeting name changed. Contact updated to admin@example.com. Website set to https://meeting.org. Final change made';
+
+ const result = formatDetailsAsList(details);
+
+ expect(result.length).toBeGreaterThan(0);
+ expect(result.some((item) => item.includes('Meeting name changed'))).toBe(true);
+ expect(result.some((item) => item.includes('admin@example.com'))).toBe(true);
+ expect(result.some((item) => item.includes('https://meeting.org'))).toBe(true);
+ });
+
+ test('trims whitespace from items', () => {
+ const details = ' First item . Second item . Third item ';
+ const result = formatDetailsAsList(details);
+
+ expect(result).toEqual(['First item', 'Second item', 'Third item']);
+ });
+
+ test('filters out empty items after splitting', () => {
+ const details = 'First. . . Second. . Third';
+ const result = formatDetailsAsList(details);
+
+ expect(result).toEqual(['First', 'Second', 'Third']);
+ });
+
+ test('handles HTTPS URLs correctly', () => {
+ const details = 'Changed from http://old.com to https://new.com. Done';
+ const result = formatDetailsAsList(details);
+
+ expect(result.length).toBeGreaterThan(0);
+ expect(result.some((item) => item.includes('http://old.com'))).toBe(true);
+ expect(result.some((item) => item.includes('https://new.com'))).toBe(true);
+ });
+
+ test('handles multiple email addresses', () => {
+ const details = 'Email from old@test.com to new@example.org. Completed';
+ const result = formatDetailsAsList(details);
+
+ expect(result).toHaveLength(2);
+ expect(result[0]).toContain('old@test.com');
+ expect(result[0]).toContain('new@example.org');
+ });
+ });
+});
diff --git a/src/tests/services.test.ts b/src/tests/services.test.ts
new file mode 100644
index 0000000..adad6da
--- /dev/null
+++ b/src/tests/services.test.ts
@@ -0,0 +1,211 @@
+import { describe, test, expect, vi, beforeEach } from 'vitest';
+import { fetchBmltChanges } from '../lib/services/bmltApi';
+import { fetchServerList, fetchServiceBodies } from '../lib/services/serverList';
+
+// Mock bmlt-query-client
+vi.mock('bmlt-query-client', () => ({
+ BmltClient: vi.fn(function (this: any) {
+ this.getChanges = vi.fn();
+ this.getServiceBodies = vi.fn();
+ })
+}));
+
+describe('bmltApi service', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('fetchBmltChanges', () => {
+ test('fetches changes for single service body', async () => {
+ const { BmltClient } = await import('bmlt-query-client');
+ const mockGetChanges = vi.fn().mockResolvedValue([
+ {
+ change_id: '1',
+ user_name: 'Test User',
+ date_string: '2024-01-01',
+ date_int: 20240101,
+ change_type: 'comdef_change_type_new'
+ }
+ ]);
+
+ (BmltClient as any).mockImplementation(function (this: any) {
+ this.getChanges = mockGetChanges;
+ });
+
+ const changes = await fetchBmltChanges('https://test.com', ['123'], 7);
+
+ expect(changes).toHaveLength(1);
+ expect(changes[0].change_id).toBe('1');
+ expect(mockGetChanges).toHaveBeenCalledTimes(1);
+ expect(mockGetChanges).toHaveBeenCalledWith(
+ expect.objectContaining({
+ service_body_id: 123
+ })
+ );
+ });
+
+ test('fetches changes for multiple service bodies', async () => {
+ const { BmltClient } = await import('bmlt-query-client');
+ const mockGetChanges = vi
+ .fn()
+ .mockResolvedValueOnce([{ change_id: '1' }])
+ .mockResolvedValueOnce([{ change_id: '2' }]);
+
+ (BmltClient as any).mockImplementation(function (this: any) {
+ this.getChanges = mockGetChanges;
+ });
+
+ const changes = await fetchBmltChanges('https://test.com', ['123', '456'], 7);
+
+ expect(changes).toHaveLength(2);
+ expect(mockGetChanges).toHaveBeenCalledTimes(2);
+ });
+
+ test('uses correct date range based on daysPassed', async () => {
+ const { BmltClient } = await import('bmlt-query-client');
+ const mockGetChanges = vi.fn().mockResolvedValue([]);
+
+ (BmltClient as any).mockImplementation(function (this: any) {
+ this.getChanges = mockGetChanges;
+ });
+
+ await fetchBmltChanges('https://test.com', ['123'], 30);
+
+ const call = mockGetChanges.mock.calls[0][0];
+ expect(call).toHaveProperty('start_date');
+ expect(call).toHaveProperty('end_date');
+
+ // Verify date format is YYYY-MM-DD
+ expect(call.start_date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ expect(call.end_date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ });
+
+ test('throws error on API failure', async () => {
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { BmltClient } = await import('bmlt-query-client');
+ const mockGetChanges = vi.fn().mockRejectedValue(new Error('API Error'));
+
+ (BmltClient as any).mockImplementation(function (this: any) {
+ this.getChanges = mockGetChanges;
+ });
+
+ await expect(fetchBmltChanges('https://test.com', ['123'], 7)).rejects.toThrow('API Error');
+ expect(consoleErrorSpy).toHaveBeenCalled();
+ consoleErrorSpy.mockRestore();
+ });
+
+ test('parses service body IDs as integers', async () => {
+ const { BmltClient } = await import('bmlt-query-client');
+ const mockGetChanges = vi.fn().mockResolvedValue([]);
+
+ (BmltClient as any).mockImplementation(function (this: any) {
+ this.getChanges = mockGetChanges;
+ });
+
+ await fetchBmltChanges('https://test.com', ['123'], 7);
+
+ expect(mockGetChanges).toHaveBeenCalledWith(
+ expect.objectContaining({
+ service_body_id: 123
+ })
+ );
+ });
+ });
+});
+
+describe('serverList service', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.stubGlobal('fetch', vi.fn());
+ });
+
+ describe('fetchServerList', () => {
+ test('fetches and returns server list', async () => {
+ const mockServers = [
+ { id: '1', name: 'Server 1', url: 'https://server1.com' },
+ { id: '2', name: 'Server 2', url: 'https://server2.com' }
+ ];
+
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: async () => mockServers
+ } as Response);
+
+ const servers = await fetchServerList();
+
+ expect(servers).toEqual(mockServers);
+ expect(fetch).toHaveBeenCalledWith('https://raw.githubusercontent.com/bmlt-enabled/aggregator/refs/heads/main/serverList.json');
+ });
+
+ test('throws error on HTTP failure', async () => {
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.mocked(fetch).mockResolvedValue({
+ ok: false,
+ status: 404
+ } as Response);
+
+ await expect(fetchServerList()).rejects.toThrow('HTTP error! status: 404');
+ expect(consoleErrorSpy).toHaveBeenCalled();
+ consoleErrorSpy.mockRestore();
+ });
+
+ test('throws error on network failure', async () => {
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
+
+ await expect(fetchServerList()).rejects.toThrow('Network error');
+ expect(consoleErrorSpy).toHaveBeenCalled();
+ consoleErrorSpy.mockRestore();
+ });
+ });
+
+ describe('fetchServiceBodies', () => {
+ test('fetches service bodies for a server', async () => {
+ const { BmltClient } = await import('bmlt-query-client');
+ const mockServiceBodies = [
+ { id: '1', name: 'Area 1', type: 'AS' },
+ { id: '2', name: 'Region 1', type: 'RS' }
+ ];
+
+ const mockGetServiceBodies = vi.fn().mockResolvedValue(mockServiceBodies);
+
+ (BmltClient as any).mockImplementation(function (this: any) {
+ this.getServiceBodies = mockGetServiceBodies;
+ });
+
+ const bodies = await fetchServiceBodies('https://test.com');
+
+ expect(bodies).toEqual(mockServiceBodies);
+ expect(mockGetServiceBodies).toHaveBeenCalledTimes(1);
+ });
+
+ test('throws error on API failure', async () => {
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { BmltClient } = await import('bmlt-query-client');
+ const mockGetServiceBodies = vi.fn().mockRejectedValue(new Error('Service body fetch failed'));
+
+ (BmltClient as any).mockImplementation(function (this: any) {
+ this.getServiceBodies = mockGetServiceBodies;
+ });
+
+ await expect(fetchServiceBodies('https://test.com')).rejects.toThrow('Service body fetch failed');
+ expect(consoleErrorSpy).toHaveBeenCalled();
+ consoleErrorSpy.mockRestore();
+ });
+
+ test('initializes BmltClient with correct URL', async () => {
+ const { BmltClient } = await import('bmlt-query-client');
+ const mockGetServiceBodies = vi.fn().mockResolvedValue([]);
+
+ (BmltClient as any).mockImplementation(function (this: any) {
+ this.getServiceBodies = mockGetServiceBodies;
+ });
+
+ await fetchServiceBodies('https://custom-server.com');
+
+ expect(BmltClient).toHaveBeenCalledWith({
+ rootServerURL: 'https://custom-server.com'
+ });
+ });
+ });
+});
diff --git a/vite.config.ts b/vite.config.ts
index 8009452..a912c04 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -12,7 +12,12 @@ export default defineConfig({
globals: true,
environment: 'jsdom',
setupFiles: './src/tests/setup.ts',
- include: ['src/**/*.{test,spec}.{js,ts}']
+ include: ['src/**/*.{test,spec}.{js,ts}'],
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'json', 'html', 'lcov'],
+ exclude: ['src/tests/**', '**/*.spec.ts', '**/*.test.ts', '**/node_modules/**', '**/dist/**']
+ }
},
build: {
chunkSizeWarningLimit: 1000