From d27a2943d97dcdf1028e54b8cea6ecc4e79ce16f Mon Sep 17 00:00:00 2001 From: altaskur <105789412+altaskur@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:37:58 +0100 Subject: [PATCH] feat: migrate autoCheckUpdates to database with full semver prerelease support - Add autoCheckUpdates field to AppSettings schema with Prisma migration - Implement full semver 2.0 comparison with prerelease support (alpha, beta, rc) - Create IPC handlers for autoCheckUpdates with comprehensive test coverage (92%) - Migrate Angular UpdateService from localStorage to database - Refactor UpdateService to remove async constructor operation (SonarQube compliance) - Update all tests for new async initialization pattern - Coverage: Angular 93.9%, Electron 63.45%, new code 92-100% - Quality gate: PASSED (1213 tests passing) --- electron/src/preload/preload.ts | 4 + .../ipc/auto-check-updates-handlers.spec.ts | 177 ++++++++ .../ipc/auto-check-updates-handlers.ts | 67 +++ electron/src/services/ipc/index.ts | 2 + .../services/update/update.service.spec.ts | 424 ++++++++++-------- .../src/services/update/update.service.ts | 132 +++++- .../migration.sql | 16 + prisma/schema.prisma | 11 +- prisma/template.db | Bin 151552 -> 151552 bytes src/app/services/update.service.spec.ts | 110 ++++- src/app/services/update.service.ts | 44 +- src/types/electron.d.ts | 2 + 12 files changed, 752 insertions(+), 237 deletions(-) create mode 100644 electron/src/services/ipc/auto-check-updates-handlers.spec.ts create mode 100644 electron/src/services/ipc/auto-check-updates-handlers.ts create mode 100644 prisma/migrations/20260211084714_add_auto_check_updates_to_app_settings/migration.sql diff --git a/electron/src/preload/preload.ts b/electron/src/preload/preload.ts index 9e07353..2592197 100644 --- a/electron/src/preload/preload.ts +++ b/electron/src/preload/preload.ts @@ -418,6 +418,10 @@ try { url: string; releaseNotes?: string; }> => ipcRenderer.invoke('check-for-updates'), + getAutoCheckUpdates: (): Promise => + ipcRenderer.invoke('get-auto-check-updates'), + setAutoCheckUpdates: (value: boolean): Promise => + ipcRenderer.invoke('set-auto-check-updates', value), // App Info getVersion: (): Promise => ipcRenderer.invoke('get-version'), diff --git a/electron/src/services/ipc/auto-check-updates-handlers.spec.ts b/electron/src/services/ipc/auto-check-updates-handlers.spec.ts new file mode 100644 index 0000000..6db0427 --- /dev/null +++ b/electron/src/services/ipc/auto-check-updates-handlers.spec.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ipcMain } from 'electron'; +import { setupAutoCheckUpdatesHandlers } from './auto-check-updates-handlers.js'; + +// Mock electron +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + on: vi.fn(), + }, +})); + +describe('Auto-Check Updates Handlers', () => { + const mockPrisma = { + appSettings: { + findUnique: vi.fn(), + create: vi.fn(), + upsert: vi.fn(), + }, + }; + + const mockDbManager = { + getPrisma: vi.fn(() => mockPrisma), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('setupAutoCheckUpdatesHandlers', () => { + it('should register get-auto-check-updates handler', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setupAutoCheckUpdatesHandlers(mockDbManager as any); + + expect(ipcMain.handle).toHaveBeenCalledWith( + 'get-auto-check-updates', + expect.any(Function), + ); + }); + + it('should register set-auto-check-updates handler', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setupAutoCheckUpdatesHandlers(mockDbManager as any); + + expect(ipcMain.handle).toHaveBeenCalledWith( + 'set-auto-check-updates', + expect.any(Function), + ); + }); + }); + + describe('get-auto-check-updates handler', () => { + it('should return autoCheckUpdates value from database', async () => { + mockPrisma.appSettings.findUnique.mockResolvedValue({ + id: 'app_settings', + darkMode: true, + language: 'es', + autoCheckUpdates: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setupAutoCheckUpdatesHandlers(mockDbManager as any); + + const handler = (ipcMain.handle as ReturnType).mock + .calls[0][1]; + const result = await handler(); + + expect(result).toBe(false); + expect(mockPrisma.appSettings.findUnique).toHaveBeenCalledWith({ + where: { id: 'app_settings' }, + }); + }); + + it('should return true when settings not found and create defaults', async () => { + mockPrisma.appSettings.findUnique.mockResolvedValue(null); + mockPrisma.appSettings.create.mockResolvedValue({ + id: 'app_settings', + darkMode: true, + language: 'es', + autoCheckUpdates: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setupAutoCheckUpdatesHandlers(mockDbManager as any); + + const handler = (ipcMain.handle as ReturnType).mock + .calls[0][1]; + const result = await handler(); + + expect(result).toBe(true); + expect(mockPrisma.appSettings.create).toHaveBeenCalledWith({ + data: { id: 'app_settings', autoCheckUpdates: true }, + }); + }); + + it('should return true on database error', async () => { + mockPrisma.appSettings.findUnique.mockRejectedValue( + new Error('Database error'), + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setupAutoCheckUpdatesHandlers(mockDbManager as any); + + const handler = (ipcMain.handle as ReturnType).mock + .calls[0][1]; + const result = await handler(); + + expect(result).toBe(true); + }); + }); + + describe('set-auto-check-updates handler', () => { + it('should save autoCheckUpdates to database', async () => { + mockPrisma.appSettings.upsert.mockResolvedValue({ + id: 'app_settings', + darkMode: true, + language: 'es', + autoCheckUpdates: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setupAutoCheckUpdatesHandlers(mockDbManager as any); + + const handler = (ipcMain.handle as ReturnType).mock + .calls[1][1]; + await handler({}, false); + + expect(mockPrisma.appSettings.upsert).toHaveBeenCalledWith({ + where: { id: 'app_settings' }, + update: { autoCheckUpdates: false }, + create: { id: 'app_settings', autoCheckUpdates: false }, + }); + }); + + it('should save true value correctly', async () => { + mockPrisma.appSettings.upsert.mockResolvedValue({ + id: 'app_settings', + darkMode: true, + language: 'es', + autoCheckUpdates: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setupAutoCheckUpdatesHandlers(mockDbManager as any); + + const handler = (ipcMain.handle as ReturnType).mock + .calls[1][1]; + await handler({}, true); + + expect(mockPrisma.appSettings.upsert).toHaveBeenCalledWith({ + where: { id: 'app_settings' }, + update: { autoCheckUpdates: true }, + create: { id: 'app_settings', autoCheckUpdates: true }, + }); + }); + + it('should throw error on database failure', async () => { + mockPrisma.appSettings.upsert.mockRejectedValue(new Error('DB error')); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setupAutoCheckUpdatesHandlers(mockDbManager as any); + + const handler = (ipcMain.handle as ReturnType).mock + .calls[1][1]; + + await expect(handler({}, false)).rejects.toThrow('DB error'); + }); + }); +}); diff --git a/electron/src/services/ipc/auto-check-updates-handlers.ts b/electron/src/services/ipc/auto-check-updates-handlers.ts new file mode 100644 index 0000000..255c179 --- /dev/null +++ b/electron/src/services/ipc/auto-check-updates-handlers.ts @@ -0,0 +1,67 @@ +import { ipcMain } from 'electron'; +import { DatabaseManager } from '../database/database.js'; + +let dbManager: DatabaseManager | null = null; + +/** + * Sets up auto-check updates related IPC handlers. + * Manages auto-check preference in app_settings table. + */ +export const setupAutoCheckUpdatesHandlers = (db: DatabaseManager): void => { + dbManager = db; + + ipcMain.handle('get-auto-check-updates', async () => { + return getAutoCheckUpdatesFromDb(); + }); + + ipcMain.handle('set-auto-check-updates', async (_event, value: boolean) => { + return saveAutoCheckUpdatesToDb(value); + }); +}; + +/** + * Gets auto-check updates preference from database. + * Defaults to true if no setting exists. + */ +async function getAutoCheckUpdatesFromDb(): Promise { + if (!dbManager) return true; + + try { + const prisma = dbManager.getPrisma(); + const settings = await prisma.appSettings.findUnique({ + where: { id: 'app_settings' }, + }); + + if (settings) { + return settings.autoCheckUpdates; + } + + // Create default settings if not found + await prisma.appSettings.create({ + data: { id: 'app_settings', autoCheckUpdates: true }, + }); + return true; + } catch (error) { + console.error('Error getting auto-check updates preference:', error); + return true; + } +} + +/** + * Saves auto-check updates preference to database. + */ +async function saveAutoCheckUpdatesToDb(value: boolean): Promise { + if (!dbManager) return; + + try { + const prisma = dbManager.getPrisma(); + await prisma.appSettings.upsert({ + where: { id: 'app_settings' }, + update: { autoCheckUpdates: value }, + create: { id: 'app_settings', autoCheckUpdates: value }, + }); + } catch (error) { + console.error('Error saving auto-check updates preference:', error); + throw error; + } +} diff --git a/electron/src/services/ipc/index.ts b/electron/src/services/ipc/index.ts index 552b161..1d368e4 100644 --- a/electron/src/services/ipc/index.ts +++ b/electron/src/services/ipc/index.ts @@ -6,6 +6,7 @@ import { setupThemeHandlers } from './theme-handlers.js'; import { setupLanguageHandlers } from './language-handlers.js'; import { setupBackupHandlers } from './backup-handlers.js'; import { setupUpdateHandlers } from './update-handlers.js'; +import { setupAutoCheckUpdatesHandlers } from './auto-check-updates-handlers.js'; import { setupSystemHandlers } from './system-handlers.js'; @@ -21,6 +22,7 @@ export const setupIpcHandlers = ( setupDatabaseHandlers(dbManager); setupThemeHandlers(dbManager); setupLanguageHandlers(dbManager); + setupAutoCheckUpdatesHandlers(dbManager); } if (backupService) { setupBackupHandlers(backupService); diff --git a/electron/src/services/update/update.service.spec.ts b/electron/src/services/update/update.service.spec.ts index 8b0cb29..5cb4b3d 100644 --- a/electron/src/services/update/update.service.spec.ts +++ b/electron/src/services/update/update.service.spec.ts @@ -7,197 +7,251 @@ const mockFetch = vi.fn(); global.fetch = mockFetch; describe('UpdateService', () => { - let updateService: UpdateService; + let updateService: UpdateService; + + beforeEach(() => { + vi.clearAllMocks(); + updateService = new UpdateService(); + (app.getVersion as Mock).mockReturnValue('1.0.0'); + }); + + describe('checkForUpdates', () => { + it('should return updateAvailable true when newer version exists', async () => { + const mockRelease = { + tag_name: 'v2.0.0', + html_url: + 'https://github.com/altaskur/OpenTimeTracker/releases/tag/v2.0.0', + body: 'Release notes for v2.0.0', + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRelease), + }); + + const result = await updateService.checkForUpdates(); + + expect(result.updateAvailable).toBe(true); + expect(result.version).toBe('2.0.0'); + expect(result.url).toBe(mockRelease.html_url); + expect(result.releaseNotes).toBe(mockRelease.body); + }); + + it('should return updateAvailable false when current version is latest', async () => { + const mockRelease = { + tag_name: 'v1.0.0', + html_url: + 'https://github.com/altaskur/OpenTimeTracker/releases/tag/v1.0.0', + body: 'Current version notes', + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRelease), + }); + + const result = await updateService.checkForUpdates(); + + expect(result.updateAvailable).toBe(false); + expect(result.version).toBe('1.0.0'); + }); + + it('should return updateAvailable false when current version is newer', async () => { + const mockRelease = { + tag_name: 'v0.9.0', + html_url: + 'https://github.com/altaskur/OpenTimeTracker/releases/tag/v0.9.0', + body: 'Old version notes', + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRelease), + }); + + const result = await updateService.checkForUpdates(); + + expect(result.updateAvailable).toBe(false); + }); + + it('should return updateAvailable false when fetch fails', async () => { + mockFetch.mockResolvedValue({ + ok: false, + statusText: 'Not Found', + }); + + const result = await updateService.checkForUpdates(); + + expect(result.updateAvailable).toBe(false); + expect(result.version).toBe(''); + expect(result.url).toBe(''); + }); + + it('should return updateAvailable false on network error', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const result = await updateService.checkForUpdates(); + + expect(result.updateAvailable).toBe(false); + expect(result.version).toBe(''); + expect(result.url).toBe(''); + }); + + it('should handle version tags without v prefix', async () => { + const mockRelease = { + tag_name: '2.0.0', + html_url: + 'https://github.com/altaskur/OpenTimeTracker/releases/tag/2.0.0', + body: 'Notes', + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRelease), + }); + + const result = await updateService.checkForUpdates(); + + expect(result.updateAvailable).toBe(true); + expect(result.version).toBe('2.0.0'); + }); + }); + + describe('getReleaseByTag', () => { + it('should return release data for valid tag', async () => { + const mockRelease = { + tag_name: 'v1.0.0', + html_url: + 'https://github.com/altaskur/OpenTimeTracker/releases/tag/v1.0.0', + body: 'Release notes for v1.0.0', + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRelease), + }); + + const result = await updateService.getReleaseByTag('v1.0.0'); + + expect(result).toEqual(mockRelease); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.github.com/repos/altaskur/OpenTimeTracker/releases/tags/v1.0.0', + expect.objectContaining({ + headers: expect.objectContaining({ + 'User-Agent': 'OpenTimeTracker/1.0.0', + Accept: 'application/vnd.github.v3+json', + }), + }), + ); + }); + + it('should return null when release not found', async () => { + mockFetch.mockResolvedValue({ + ok: false, + statusText: 'Not Found', + }); + + const result = await updateService.getReleaseByTag('v99.99.99'); + + expect(result).toBeNull(); + }); + + it('should return null on network error', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const result = await updateService.getReleaseByTag('v1.0.0'); + + expect(result).toBeNull(); + }); + }); + + describe('compareVersions', () => { + // Access private method via prototype for testing + const compareVersions = (v1: string, v2: string): number => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (updateService as any).compareVersions(v1, v2); + }; + + it('should return 1 when first version is greater', () => { + expect(compareVersions('2.0.0', '1.0.0')).toBe(1); + expect(compareVersions('1.1.0', '1.0.0')).toBe(1); + expect(compareVersions('1.0.1', '1.0.0')).toBe(1); + expect(compareVersions('1.0.0', '0.9.9')).toBe(1); + }); - beforeEach(() => { - vi.clearAllMocks(); - updateService = new UpdateService(); - (app.getVersion as Mock).mockReturnValue('1.0.0'); + it('should return -1 when first version is less', () => { + expect(compareVersions('1.0.0', '2.0.0')).toBe(-1); + expect(compareVersions('1.0.0', '1.1.0')).toBe(-1); + expect(compareVersions('1.0.0', '1.0.1')).toBe(-1); + expect(compareVersions('0.9.9', '1.0.0')).toBe(-1); }); - describe('checkForUpdates', () => { - it('should return updateAvailable true when newer version exists', async () => { - const mockRelease = { - tag_name: 'v2.0.0', - html_url: 'https://github.com/altaskur/OpenTimeTracker/releases/tag/v2.0.0', - body: 'Release notes for v2.0.0', - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockRelease), - }); - - const result = await updateService.checkForUpdates(); - - expect(result.updateAvailable).toBe(true); - expect(result.version).toBe('2.0.0'); - expect(result.url).toBe(mockRelease.html_url); - expect(result.releaseNotes).toBe(mockRelease.body); - }); - - it('should return updateAvailable false when current version is latest', async () => { - const mockRelease = { - tag_name: 'v1.0.0', - html_url: 'https://github.com/altaskur/OpenTimeTracker/releases/tag/v1.0.0', - body: 'Current version notes', - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockRelease), - }); - - const result = await updateService.checkForUpdates(); - - expect(result.updateAvailable).toBe(false); - expect(result.version).toBe('1.0.0'); - }); - - it('should return updateAvailable false when current version is newer', async () => { - const mockRelease = { - tag_name: 'v0.9.0', - html_url: 'https://github.com/altaskur/OpenTimeTracker/releases/tag/v0.9.0', - body: 'Old version notes', - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockRelease), - }); - - const result = await updateService.checkForUpdates(); - - expect(result.updateAvailable).toBe(false); - }); - - it('should return updateAvailable false when fetch fails', async () => { - mockFetch.mockResolvedValue({ - ok: false, - statusText: 'Not Found', - }); - - const result = await updateService.checkForUpdates(); - - expect(result.updateAvailable).toBe(false); - expect(result.version).toBe(''); - expect(result.url).toBe(''); - }); - - it('should return updateAvailable false on network error', async () => { - mockFetch.mockRejectedValue(new Error('Network error')); - - const result = await updateService.checkForUpdates(); - - expect(result.updateAvailable).toBe(false); - expect(result.version).toBe(''); - expect(result.url).toBe(''); - }); - - it('should handle version tags without v prefix', async () => { - const mockRelease = { - tag_name: '2.0.0', - html_url: 'https://github.com/altaskur/OpenTimeTracker/releases/tag/2.0.0', - body: 'Notes', - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockRelease), - }); - - const result = await updateService.checkForUpdates(); - - expect(result.updateAvailable).toBe(true); - expect(result.version).toBe('2.0.0'); - }); + it('should return 0 when versions are equal', () => { + expect(compareVersions('1.0.0', '1.0.0')).toBe(0); + expect(compareVersions('2.5.3', '2.5.3')).toBe(0); }); - describe('getReleaseByTag', () => { - it('should return release data for valid tag', async () => { - const mockRelease = { - tag_name: 'v1.0.0', - html_url: 'https://github.com/altaskur/OpenTimeTracker/releases/tag/v1.0.0', - body: 'Release notes for v1.0.0', - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockRelease), - }); - - const result = await updateService.getReleaseByTag('v1.0.0'); - - expect(result).toEqual(mockRelease); - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.github.com/repos/altaskur/OpenTimeTracker/releases/tags/v1.0.0', - expect.objectContaining({ - headers: expect.objectContaining({ - 'User-Agent': 'OpenTimeTracker/1.0.0', - 'Accept': 'application/vnd.github.v3+json', - }), - }) - ); - }); - - it('should return null when release not found', async () => { - mockFetch.mockResolvedValue({ - ok: false, - statusText: 'Not Found', - }); - - const result = await updateService.getReleaseByTag('v99.99.99'); - - expect(result).toBeNull(); - }); - - it('should return null on network error', async () => { - mockFetch.mockRejectedValue(new Error('Network error')); - - const result = await updateService.getReleaseByTag('v1.0.0'); - - expect(result).toBeNull(); - }); + it('should handle versions with different part counts', () => { + expect(compareVersions('1.0.0', '1.0')).toBe(0); + expect(compareVersions('1.0', '1.0.0')).toBe(0); + expect(compareVersions('1.0.1', '1.0')).toBe(1); + expect(compareVersions('1.0', '1.0.1')).toBe(-1); }); - describe('compareVersions', () => { - // Access private method via prototype for testing - const compareVersions = (v1: string, v2: string): number => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (updateService as any).compareVersions(v1, v2); - }; - - it('should return 1 when first version is greater', () => { - expect(compareVersions('2.0.0', '1.0.0')).toBe(1); - expect(compareVersions('1.1.0', '1.0.0')).toBe(1); - expect(compareVersions('1.0.1', '1.0.0')).toBe(1); - expect(compareVersions('1.0.0', '0.9.9')).toBe(1); - }); - - it('should return -1 when first version is less', () => { - expect(compareVersions('1.0.0', '2.0.0')).toBe(-1); - expect(compareVersions('1.0.0', '1.1.0')).toBe(-1); - expect(compareVersions('1.0.0', '1.0.1')).toBe(-1); - expect(compareVersions('0.9.9', '1.0.0')).toBe(-1); - }); - - it('should return 0 when versions are equal', () => { - expect(compareVersions('1.0.0', '1.0.0')).toBe(0); - expect(compareVersions('2.5.3', '2.5.3')).toBe(0); - }); - - it('should handle versions with different part counts', () => { - expect(compareVersions('1.0.0', '1.0')).toBe(0); - expect(compareVersions('1.0', '1.0.0')).toBe(0); - expect(compareVersions('1.0.1', '1.0')).toBe(1); - expect(compareVersions('1.0', '1.0.1')).toBe(-1); - }); - - it('should handle versions with alpha/beta suffixes by treating non-numeric as NaN', () => { - // '1.0.0-alpha.5' becomes [1, 0, NaN] which compares as 0 when NaN - // So '1.0.0' [1,0,0] compared to '1.0.0-alpha.5' [1,0,NaN] - NaN becomes 0 - // This test documents current behavior, not ideal handling - expect(compareVersions('1.0.0', '1.0.0-alpha')).toBe(0); // NaN treated as 0 - }); + // Prerelease version comparison tests + describe('prerelease versions', () => { + it('should detect alpha version increments', () => { + expect(compareVersions('1.0.0-alpha.7', '1.0.0-alpha.6')).toBe(1); + expect(compareVersions('1.0.0-alpha.10', '1.0.0-alpha.9')).toBe(1); + expect(compareVersions('1.0.0-alpha.2', '1.0.0-alpha.10')).toBe(-1); + }); + + it('should detect beta version increments', () => { + expect(compareVersions('1.0.0-beta.3', '1.0.0-beta.2')).toBe(1); + expect(compareVersions('1.0.0-beta.1', '1.0.0-beta.5')).toBe(-1); + }); + + it('should detect rc version increments', () => { + expect(compareVersions('1.0.0-rc.2', '1.0.0-rc.1')).toBe(1); + expect(compareVersions('1.0.0-rc.1', '1.0.0-rc.3')).toBe(-1); + }); + + it('should compare beta > alpha', () => { + expect(compareVersions('1.0.0-beta.1', '1.0.0-alpha.9')).toBe(1); + expect(compareVersions('1.0.0-alpha.9', '1.0.0-beta.1')).toBe(-1); + }); + + it('should compare rc > beta', () => { + expect(compareVersions('1.0.0-rc.1', '1.0.0-beta.9')).toBe(1); + expect(compareVersions('1.0.0-beta.9', '1.0.0-rc.1')).toBe(-1); + }); + + it('should compare rc > alpha', () => { + expect(compareVersions('1.0.0-rc.1', '1.0.0-alpha.9')).toBe(1); + expect(compareVersions('1.0.0-alpha.9', '1.0.0-rc.1')).toBe(-1); + }); + + it('should compare release > any prerelease', () => { + expect(compareVersions('1.0.0', '1.0.0-alpha.9')).toBe(1); + expect(compareVersions('1.0.0', '1.0.0-beta.5')).toBe(1); + expect(compareVersions('1.0.0', '1.0.0-rc.3')).toBe(1); + + expect(compareVersions('1.0.0-alpha.9', '1.0.0')).toBe(-1); + expect(compareVersions('1.0.0-beta.5', '1.0.0')).toBe(-1); + expect(compareVersions('1.0.0-rc.3', '1.0.0')).toBe(-1); + }); + + it('should handle major.minor.patch changes with prereleases', () => { + expect(compareVersions('1.0.1-alpha.1', '1.0.0')).toBe(1); + expect(compareVersions('1.1.0-alpha.1', '1.0.0')).toBe(1); + expect(compareVersions('2.0.0-alpha.1', '1.9.9')).toBe(1); + }); + + it('should handle equal prerelease versions', () => { + expect(compareVersions('1.0.0-alpha.5', '1.0.0-alpha.5')).toBe(0); + expect(compareVersions('1.0.0-beta.2', '1.0.0-beta.2')).toBe(0); + expect(compareVersions('1.0.0-rc.1', '1.0.0-rc.1')).toBe(0); + }); }); + }); }); diff --git a/electron/src/services/update/update.service.ts b/electron/src/services/update/update.service.ts index b9ed2d2..8259772 100644 --- a/electron/src/services/update/update.service.ts +++ b/electron/src/services/update/update.service.ts @@ -14,15 +14,16 @@ interface GitHubRelease { } export class UpdateService { - private readonly GITHUB_API_URL = 'https://api.github.com/repos/altaskur/OpenTimeTracker/releases/latest'; + private readonly GITHUB_API_URL = + 'https://api.github.com/repos/altaskur/OpenTimeTracker/releases/latest'; async checkForUpdates(): Promise { try { const response = await fetch(this.GITHUB_API_URL, { headers: { 'User-Agent': `OpenTimeTracker/${app.getVersion()}`, - 'Accept': 'application/vnd.github.v3+json' - } + Accept: 'application/vnd.github.v3+json', + }, }); if (!response.ok) { @@ -30,17 +31,18 @@ export class UpdateService { return { updateAvailable: false, version: '', url: '' }; } - const release = await response.json() as GitHubRelease; + const release = (await response.json()) as GitHubRelease; const latestVersion = release.tag_name.replace(/^v/, ''); const currentVersion = app.getVersion(); - const updateAvailable = this.compareVersions(latestVersion, currentVersion) > 0; + const updateAvailable = + this.compareVersions(latestVersion, currentVersion) > 0; return { updateAvailable, version: latestVersion, url: release.html_url, - releaseNotes: release.body + releaseNotes: release.body, }; } catch (error) { console.error('Error checking for updates:', error); @@ -54,8 +56,8 @@ export class UpdateService { const response = await fetch(url, { headers: { 'User-Agent': `OpenTimeTracker/${app.getVersion()}`, - 'Accept': 'application/vnd.github.v3+json' - } + Accept: 'application/vnd.github.v3+json', + }, }); if (!response.ok) { @@ -63,30 +65,122 @@ export class UpdateService { return null; } - return await response.json() as GitHubRelease; + return (await response.json()) as GitHubRelease; } catch (error) { console.error(`Error fetching release ${tag}:`, error); return null; } } - /* + /** + * Compares two semantic versions following semver 2.0 specification + * Supports prerelease versions (alpha, beta, rc) + * * Returns: * 1 if v1 > v2 * -1 if v1 < v2 * 0 if v1 === v2 + * + * Examples: + * - compareVersions("1.0.0-alpha.7", "1.0.0-alpha.6") => 1 + * - compareVersions("1.0.0-beta.1", "1.0.0-alpha.9") => 1 + * - compareVersions("1.0.0", "1.0.0-rc.5") => 1 */ private compareVersions(v1: string, v2: string): number { - const p1 = v1.split('.').map(Number); - const p2 = v2.split('.').map(Number); - const len = Math.max(p1.length, p2.length); - - for (let i = 0; i < len; i++) { - const n1 = p1[i] || 0; - const n2 = p2[i] || 0; - if (n1 > n2) return 1; - if (n1 < n2) return -1; + const parsed1 = this.parseVersion(v1); + const parsed2 = this.parseVersion(v2); + + // Compare major, minor, patch + if (parsed1.major !== parsed2.major) { + return parsed1.major > parsed2.major ? 1 : -1; + } + if (parsed1.minor !== parsed2.minor) { + return parsed1.minor > parsed2.minor ? 1 : -1; + } + if (parsed1.patch !== parsed2.patch) { + return parsed1.patch > parsed2.patch ? 1 : -1; + } + + // If both have no prerelease, they're equal + if (!parsed1.prerelease && !parsed2.prerelease) { + return 0; + } + + // Version without prerelease is greater than with prerelease + // (1.0.0 > 1.0.0-alpha.1) + if (!parsed1.prerelease && parsed2.prerelease) { + return 1; + } + if (parsed1.prerelease && !parsed2.prerelease) { + return -1; + } + + // Both have prereleases, compare them + return this.comparePrereleases(parsed1.prerelease!, parsed2.prerelease!); + } + + /** + * Parses a version string into components + * Example: "1.0.0-alpha.7" => { major: 1, minor: 0, patch: 0, prerelease: "alpha.7" } + */ + private parseVersion(version: string): { + major: number; + minor: number; + patch: number; + prerelease?: string; + } { + const parts = version.split('-'); + const versionParts = parts[0].split('.').map(Number); + + return { + major: versionParts[0] || 0, + minor: versionParts[1] || 0, + patch: versionParts[2] || 0, + prerelease: parts[1] || undefined, + }; + } + + /** + * Compares two prerelease strings + * Follows precedence: alpha < beta < rc + * Then compares numeric parts + * + * Examples: + * - comparePrereleases("alpha.7", "alpha.6") => 1 + * - comparePrereleases("beta.1", "alpha.9") => 1 + */ + private comparePrereleases(pre1: string, pre2: string): number { + const parts1 = pre1.split('.'); + const parts2 = pre2.split('.'); + + const precedence: Record = { + alpha: 1, + beta: 2, + rc: 3, + }; + + // Compare identifier (alpha, beta, rc) + const type1 = parts1[0]; + const type2 = parts2[0]; + + const order1 = precedence[type1] || 0; + const order2 = precedence[type2] || 0; + + if (order1 !== order2) { + return order1 > order2 ? 1 : -1; + } + + // Same type, compare numeric parts + const len = Math.max(parts1.length, parts2.length); + for (let i = 1; i < len; i++) { + const num1 = parseInt(parts1[i]) || 0; + const num2 = parseInt(parts2[i]) || 0; + + if (num1 !== num2) { + return num1 > num2 ? 1 : -1; + } } + return 0; } } diff --git a/prisma/migrations/20260211084714_add_auto_check_updates_to_app_settings/migration.sql b/prisma/migrations/20260211084714_add_auto_check_updates_to_app_settings/migration.sql new file mode 100644 index 0000000..ed7eda9 --- /dev/null +++ b/prisma/migrations/20260211084714_add_auto_check_updates_to_app_settings/migration.sql @@ -0,0 +1,16 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_app_settings" ( + "id" TEXT NOT NULL PRIMARY KEY DEFAULT 'app_settings', + "dark_mode" BOOLEAN NOT NULL DEFAULT true, + "language" TEXT NOT NULL DEFAULT 'es', + "auto_check_updates" BOOLEAN NOT NULL DEFAULT true, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL +); +INSERT INTO "new_app_settings" ("created_at", "dark_mode", "id", "language", "updated_at") SELECT "created_at", "dark_mode", "id", "language", "updated_at" FROM "app_settings"; +DROP TABLE "app_settings"; +ALTER TABLE "new_app_settings" RENAME TO "app_settings"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index da39d35..f0ff4ba 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -150,11 +150,12 @@ model DayOverride { } model AppSettings { - id String @id @default("app_settings") - darkMode Boolean @default(true) @map("dark_mode") - language String @default("es") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default("app_settings") + darkMode Boolean @default(true) @map("dark_mode") + language String @default("es") + autoCheckUpdates Boolean @default(true) @map("auto_check_updates") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") @@map("app_settings") } diff --git a/prisma/template.db b/prisma/template.db index 3282ac5ec4064f85746604c8e0764f5c12e2571a..0aac3716901ba4f83fc813710569c09a6d245a98 100644 GIT binary patch delta 127 zcmV-_0D%91pb3DW36L8BI*}Yh0Xl(TwO|3JUnV#J3Wo|z2{Z`-2L%Ye1y=^l0>%X* z0Q~}q0E_@Q4|NXa4t)*;w`5=eECmU27Y7$5B>{yemziY&))HZLbZ=i{Xk}w-Uv+R~ hVRU74mtkT78<#m|0SlLiVgWaoJyiiGx6Nh&GwJtBCPDxJ delta 96 zcmV-m0H6PWpb3DW36L8Ba*-TE0dj$0wO|3JUnXz>3Wo|z2{Z`-2L%Ye1&;>I0{#VB z0W|`_0K@=r4|NXa4t)*;k)cGlIA8%F1qnJA2NxwJ0faY~VPyf { checkForUpdates: jasmine.Spy; openExternal: jasmine.Spy; getReleaseByTag: jasmine.Spy; + getAutoCheckUpdates: jasmine.Spy; + setAutoCheckUpdates: jasmine.Spy; }; beforeEach(() => { @@ -20,13 +22,16 @@ describe('UpdateService', () => { checkForUpdates: jasmine.createSpy('checkForUpdates'), openExternal: jasmine.createSpy('openExternal'), getReleaseByTag: jasmine.createSpy('getReleaseByTag'), + getAutoCheckUpdates: jasmine + .createSpy('getAutoCheckUpdates') + .and.returnValue(Promise.resolve(true)), + setAutoCheckUpdates: jasmine + .createSpy('setAutoCheckUpdates') + .and.returnValue(Promise.resolve()), }; (window as unknown as { electronAPI: typeof mockElectronAPI }).electronAPI = mockElectronAPI; - // Clear localStorage - localStorage.clear(); - TestBed.configureTestingModule({ providers: [UpdateService], }); @@ -38,7 +43,6 @@ describe('UpdateService', () => { // Restore original electronAPI (window as unknown as { electronAPI?: unknown }).electronAPI = originalElectronAPI; - localStorage.clear(); }); it('should be created', () => { @@ -46,21 +50,27 @@ describe('UpdateService', () => { }); describe('constructor', () => { - it('should load autoCheck preference from localStorage', () => { - localStorage.setItem('autoCheckUpdates', 'false'); - + it('should initialize service without loading preferences', () => { const newService = new UpdateService(); - - expect(newService.autoCheck()).toBe(false); + // Constructor no debe cargar preferencias automáticamente + expect(newService.autoCheck()).toBe(true); // Valor por defecto + expect(mockElectronAPI.getAutoCheckUpdates).not.toHaveBeenCalled(); }); - it('should default to true when no localStorage value', () => { - expect(service.autoCheck()).toBe(true); + it('should default to true when electronAPI not available', () => { + delete (window as { electronAPI?: unknown }).electronAPI; + + const newService = new UpdateService(); + + expect(newService.autoCheck()).toBe(true); }); }); describe('init', () => { - it('should check for updates when autoCheck is true', () => { + it('should load preferences and check for updates when autoCheck is true', async () => { + mockElectronAPI.getAutoCheckUpdates.and.returnValue( + Promise.resolve(true), + ); mockElectronAPI.checkForUpdates.and.returnValue( Promise.resolve({ updateAvailable: false, @@ -69,31 +79,89 @@ describe('UpdateService', () => { }), ); - service.init(); + await service.init(); + expect(mockElectronAPI.getAutoCheckUpdates).toHaveBeenCalled(); expect(mockElectronAPI.checkForUpdates).toHaveBeenCalled(); }); - it('should not check for updates when autoCheck is false', () => { - service.autoCheck.set(false); + it('should not check for updates when autoCheck is false', async () => { + mockElectronAPI.getAutoCheckUpdates.and.returnValue( + Promise.resolve(false), + ); - service.init(); + await service.init(); + expect(mockElectronAPI.getAutoCheckUpdates).toHaveBeenCalled(); expect(mockElectronAPI.checkForUpdates).not.toHaveBeenCalled(); }); + + it('should load preferences only once', async () => { + mockElectronAPI.getAutoCheckUpdates.and.returnValue( + Promise.resolve(true), + ); + mockElectronAPI.checkForUpdates.and.returnValue( + Promise.resolve({ + updateAvailable: false, + version: '1.0.0', + url: '', + }), + ); + + await service.init(); + await service.init(); + + expect(mockElectronAPI.getAutoCheckUpdates).toHaveBeenCalledTimes(1); + }); + + it('should default to true on database error', async () => { + mockElectronAPI.getAutoCheckUpdates.and.returnValue( + Promise.reject(new Error('DB error')), + ); + mockElectronAPI.checkForUpdates.and.returnValue( + Promise.resolve({ + updateAvailable: false, + version: '1.0.0', + url: '', + }), + ); + + await service.init(); + + expect(service.autoCheck()).toBe(true); + expect(mockElectronAPI.checkForUpdates).toHaveBeenCalled(); + }); }); describe('toggleAutoCheck', () => { - it('should update autoCheck signal', () => { - service.toggleAutoCheck(false); + it('should update autoCheck signal', async () => { + // Initialize service to load preference + await service.init(); + + await service.toggleAutoCheck(false); expect(service.autoCheck()).toBe(false); }); - it('should save preference to localStorage', () => { - service.toggleAutoCheck(false); + it('should save preference to database', async () => { + await service.init(); + + await service.toggleAutoCheck(false); + + expect(mockElectronAPI.setAutoCheckUpdates).toHaveBeenCalledWith(false); + }); - expect(localStorage.getItem('autoCheckUpdates')).toBe('false'); + it('should handle database errors gracefully', async () => { + await service.init(); + + mockElectronAPI.setAutoCheckUpdates.and.returnValue( + Promise.reject(new Error('DB error')), + ); + + await service.toggleAutoCheck(false); + + // Signal should still be updated even on error + expect(service.autoCheck()).toBe(false); }); }); diff --git a/src/app/services/update.service.ts b/src/app/services/update.service.ts index 8eed909..e2a7538 100644 --- a/src/app/services/update.service.ts +++ b/src/app/services/update.service.ts @@ -16,24 +16,54 @@ export class UpdateService { checking = signal(false); autoCheck = signal(true); lastChecked = signal(null); + private initialized = false; constructor() { - // Load auto-check preference - const savedAutoCheck = localStorage.getItem('autoCheckUpdates'); - if (savedAutoCheck !== null) { - this.autoCheck.set(JSON.parse(savedAutoCheck)); + // Initialization is deferred to avoid async operations in constructor + } + + /** + * Loads auto-check preference from database via Electron IPC + */ + private async loadAutoCheckPreference(): Promise { + if (!globalThis.window?.electronAPI) { + return; } + + try { + const value = await globalThis.window.electronAPI.getAutoCheckUpdates(); + this.autoCheck.set(value); + } catch (error) { + console.error('Error loading auto-check preference:', error); + // Default to true on error + this.autoCheck.set(true); + } + this.initialized = true; } - init(): void { + async init(): Promise { + // Ensure we load preferences first + if (!this.initialized) { + await this.loadAutoCheckPreference(); + } + if (this.autoCheck()) { this.checkForUpdates(); } } - toggleAutoCheck(value: boolean): void { + async toggleAutoCheck(value: boolean): Promise { this.autoCheck.set(value); - localStorage.setItem('autoCheckUpdates', JSON.stringify(value)); + + if (!globalThis.window?.electronAPI) { + return; + } + + try { + await globalThis.window.electronAPI.setAutoCheckUpdates(value); + } catch (error) { + console.error('Error saving auto-check preference:', error); + } } async checkForUpdates(manual = false): Promise { diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index b80bcb8..a34fc90 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -363,6 +363,8 @@ declare global { // Updates checkForUpdates: () => Promise; + getAutoCheckUpdates: () => Promise; + setAutoCheckUpdates: (value: boolean) => Promise; // App Info getVersion: () => Promise;