From 6191c782f03ab4bab002944d8b91fb35f3954dc7 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 26 Dec 2025 16:02:34 +0100 Subject: [PATCH] test(archive): add failing tests for legacy import subtask loss Add E2E and integration tests documenting a bug where tasks with subtasks are lost when: 1. Importing legacy data (pre-operation logs) 2. Archiving via "Finish Day" 3. Exporting again The archiveYoung ends up empty in the export. Tests will pass once the bug is fixed. Files added: - e2e/fixtures/legacy-archive-subtasks-backup.json - e2e/tests/import-export/legacy-archive-subtasks.spec.ts - integration/legacy-archive-subtasks.integration.spec.ts --- .../legacy-archive-subtasks-backup.json | 332 +++++++++++++ .../legacy-archive-subtasks.spec.ts | 324 +++++++++++++ ...egacy-archive-subtasks.integration.spec.ts | 445 ++++++++++++++++++ 3 files changed, 1101 insertions(+) create mode 100644 e2e/fixtures/legacy-archive-subtasks-backup.json create mode 100644 e2e/tests/import-export/legacy-archive-subtasks.spec.ts create mode 100644 src/app/core/persistence/operation-log/integration/legacy-archive-subtasks.integration.spec.ts diff --git a/e2e/fixtures/legacy-archive-subtasks-backup.json b/e2e/fixtures/legacy-archive-subtasks-backup.json new file mode 100644 index 000000000..16d7eb542 --- /dev/null +++ b/e2e/fixtures/legacy-archive-subtasks-backup.json @@ -0,0 +1,332 @@ +{ + "timestamp": 1733000000000, + "lastUpdate": 1733000000000, + "crossModelVersion": 4.5, + "data": { + "project": { + "ids": ["INBOX_PROJECT"], + "entities": { + "INBOX_PROJECT": { + "id": "INBOX_PROJECT", + "title": "Inbox", + "isHiddenFromMenu": false, + "taskIds": ["parent-task-1", "subtask-1", "subtask-2", "normal-task-1"], + "backlogTaskIds": [], + "noteIds": [], + "theme": { + "primary": "#607d8b", + "accent": "#ff4081", + "warn": "#e91e63", + "isAutoContrast": true + }, + "icon": "inbox", + "workStart": {}, + "workEnd": {}, + "breakNr": {}, + "breakTime": {}, + "isArchived": false, + "advancedCfg": { + "worklogExportSettings": { + "cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], + "roundWorkTimeTo": null, + "roundStartTimeTo": null, + "roundEndTimeTo": null, + "groupBy": "DATE", + "separateTasksBy": "" + } + } + } + } + }, + "menuTree": { + "tagTree": [], + "projectTree": [] + }, + "globalConfig": { + "misc": { + "isConfirmBeforeExit": false, + "isConfirmBeforeExitWithoutFinishDay": true, + "isNotifyWhenTimeEstimateExceeded": false, + "isAutMarkParentAsDone": true, + "isAutoStartNextTask": true, + "isTurnOffMarkdown": false, + "isAutoAddWorkedOnToToday": true, + "isDisableInitialDialog": true, + "firstDayOfWeek": 1, + "startOfNextDay": 0, + "daysToShowForPastDates": 14, + "isMinimizeToTray": false, + "isEnableAdvanced": false, + "isTrayShowCurrentTask": true, + "taskNotesTpl": "", + "isDisableAnimations": false + }, + "appFeatures": { + "isTimeTrackingEnabled": true, + "isFocusModeEnabled": true, + "isSchedulerEnabled": true, + "isPlannerEnabled": true, + "isBoardsEnabled": true, + "isScheduleDayPanelEnabled": true, + "isIssuesPanelEnabled": true, + "isProjectNotesEnabled": true, + "isSyncIconEnabled": true, + "isDonatePageEnabled": true, + "isEnableUserProfiles": false + }, + "localization": {}, + "evaluation": { + "isHideEvaluationSheet": false + }, + "idle": { + "isEnableIdleTimeTracking": false, + "isUnTrackedIdleResetsBreakTimer": false, + "minIdleTime": 60000, + "isOnlyOpenIdleWhenCurrentTask": false + }, + "takeABreak": { + "isTakeABreakEnabled": false, + "takeABreakMinWorkingTime": 60, + "takeABreakSnoozeTime": 15, + "motivationalImgs": [], + "isLockScreen": false, + "isTimedFullScreenBlocker": false, + "timedFullScreenBlockerDuration": 8000, + "isFocusWindow": false, + "takeABreakMessage": "Take a break!" + }, + "pomodoro": { + "isEnabled": false, + "duration": 1500000, + "breakDuration": 300000, + "longerBreakDuration": 900000, + "cyclesBeforeLongerBreak": 4, + "isStopTrackingOnBreak": true, + "isStopTrackingOnLongBreak": true, + "isManualContinue": false, + "isPlaySound": true, + "isPlaySoundAfterBreak": false, + "isEnabled2": false + }, + "keyboard": {}, + "localBackup": { + "isEnabled": false + }, + "lang": {}, + "sync": { + "isEnabled": false, + "isCompressionEnabled": true, + "syncInterval": 0, + "isEncryptionEnabled": false, + "syncProvider": null + }, + "timeline": { + "isWorkStartEndEnabled": false, + "workStart": "9:00", + "workEnd": "17:00" + }, + "reminder": { + "isCountdownBannerEnabled": true, + "countdownDuration": 600000 + }, + "focusMode": { + "isAlwaysUseFocusMode": false, + "isSkipPreparation": false + }, + "sound": { + "volume": 70, + "isPlayDoneSound": true, + "doneSound": "done2.mp3", + "isPlayTickSound": false, + "isIncreaseDoneSoundPitch": true, + "breakReminderSound": null + }, + "timeTracking": { + "trackingInterval": 1000, + "defaultEstimate": 0, + "defaultEstimateSubTasks": 0, + "isNotifyWhenTimeEstimateExceeded": true, + "isAutoStartNextTask": false, + "isTrackingReminderEnabled": false, + "isTrackingReminderShowOnMobile": false, + "trackingReminderMinTime": 300000 + }, + "quickNotes": {}, + "dominaMode": { + "isEnabled": false, + "text": "Your current task is: ${currentTaskTitle}", + "interval": 300000, + "volume": 75 + }, + "shortSyntax": { + "isEnableProject": true, + "isEnableDue": true, + "isEnableTag": true + }, + "schedule": { + "isWorkStartEndEnabled": true, + "workStart": "9:00", + "workEnd": "17:00", + "isLunchBreakEnabled": false, + "lunchBreakStart": "13:00", + "lunchBreakEnd": "14:00" + } + }, + "task": { + "ids": ["parent-task-1", "subtask-1", "subtask-2", "normal-task-1"], + "entities": { + "parent-task-1": { + "id": "parent-task-1", + "title": "Legacy Test - Parent Task With Subtasks", + "subTaskIds": ["subtask-1", "subtask-2"], + "timeSpentOnDay": {}, + "timeSpent": 0, + "timeEstimate": 7200000, + "isDone": false, + "dueDay": "2024-12-01", + "notes": "This is a parent task with two subtasks", + "tagIds": [], + "created": 1733000000000, + "attachments": [], + "projectId": "INBOX_PROJECT" + }, + "subtask-1": { + "id": "subtask-1", + "title": "Legacy Test - First Subtask", + "subTaskIds": [], + "parentId": "parent-task-1", + "timeSpentOnDay": {}, + "timeSpent": 0, + "timeEstimate": 3600000, + "isDone": false, + "notes": "First subtask of the parent", + "tagIds": [], + "created": 1733000000001, + "attachments": [], + "projectId": "INBOX_PROJECT" + }, + "subtask-2": { + "id": "subtask-2", + "title": "Legacy Test - Second Subtask", + "subTaskIds": [], + "parentId": "parent-task-1", + "timeSpentOnDay": {}, + "timeSpent": 0, + "timeEstimate": 1800000, + "isDone": false, + "notes": "Second subtask of the parent", + "tagIds": [], + "created": 1733000000002, + "attachments": [], + "projectId": "INBOX_PROJECT" + }, + "normal-task-1": { + "id": "normal-task-1", + "title": "Legacy Test - Normal Task Without Subtasks", + "subTaskIds": [], + "timeSpentOnDay": {}, + "timeSpent": 0, + "timeEstimate": 3600000, + "isDone": false, + "dueDay": "2024-12-01", + "notes": "A standalone task", + "tagIds": [], + "created": 1733000000003, + "attachments": [], + "projectId": "INBOX_PROJECT" + } + }, + "currentTaskId": null, + "selectedTaskId": null, + "lastCurrentTaskId": null, + "isDataLoaded": false + }, + "tag": { + "ids": ["TODAY"], + "entities": { + "TODAY": { + "id": "TODAY", + "title": "Today", + "taskIds": ["parent-task-1", "normal-task-1"], + "icon": "wb_sunny", + "created": 1733000000000, + "theme": { + "primary": "#ffa726", + "accent": "#ff4081", + "warn": "#e91e63", + "isAutoContrast": true + }, + "workStart": {}, + "workEnd": {}, + "breakNr": {}, + "breakTime": {}, + "advancedCfg": { + "worklogExportSettings": { + "cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], + "roundWorkTimeTo": null, + "roundStartTimeTo": null, + "roundEndTimeTo": null, + "groupBy": "DATE", + "separateTasksBy": "" + } + } + } + } + }, + "simpleCounter": { + "ids": [], + "entities": {} + }, + "taskRepeatCfg": { + "ids": [], + "entities": {} + }, + "reminders": [], + "note": { + "ids": [], + "entities": {}, + "todayOrder": [] + }, + "metric": { + "ids": [], + "entities": {} + }, + "planner": { + "days": {} + }, + "issueProvider": { + "ids": [], + "entities": {} + }, + "boards": { + "boardCfgs": [] + }, + "timeTracking": { + "project": {}, + "tag": {} + }, + "archiveYoung": { + "task": { + "ids": [], + "entities": {} + }, + "timeTracking": { + "project": {}, + "tag": {} + }, + "lastTimeTrackingFlush": 0 + }, + "archiveOld": { + "task": { + "ids": [], + "entities": {} + }, + "timeTracking": { + "project": {}, + "tag": {} + } + }, + "pluginMetadata": [], + "pluginUserData": [] + } +} diff --git a/e2e/tests/import-export/legacy-archive-subtasks.spec.ts b/e2e/tests/import-export/legacy-archive-subtasks.spec.ts new file mode 100644 index 000000000..efaab2656 --- /dev/null +++ b/e2e/tests/import-export/legacy-archive-subtasks.spec.ts @@ -0,0 +1,324 @@ +import { test, expect, type Page, type Download } from '@playwright/test'; +import { ImportPage } from '../../pages/import.page'; +import * as fs from 'fs'; + +/** + * E2E Tests for Legacy Data Import/Export with Archive Subtasks + * + * BUG: When importing data from before operation logs (legacy data) that contains + * tasks with subtasks, then archiving via "Finish Day", the subtasks are lost + * when exporting again. + * + * Test Flow: + * 1. Import legacy backup containing active parent task + subtasks + normal task + * 2. Mark all tasks as done + * 3. Click "Finish Day" to archive tasks + * 4. Export the data + * 5. Verify exported data contains all archived tasks including subtasks + * + * Run with: npm run e2e:file e2e/tests/import-export/legacy-archive-subtasks.spec.ts + */ + +// Selectors +const TASK_SEL = 'task'; +const TASK_DONE_BTN = '.task-done-btn'; +const FINISH_DAY_BTN = '.e2e-finish-day'; +const SAVE_AND_GO_HOME_BTN = 'button[mat-flat-button][color="primary"]:last-of-type'; +const WELCOME_DIALOG_CLOSE_BTN = + 'button:has-text("No thanks"), button:has-text("Close Tour")'; + +/** + * Helper to dismiss welcome tour dialog if present + */ +const dismissWelcomeDialog = async (page: Page): Promise => { + try { + const closeBtn = page.locator(WELCOME_DIALOG_CLOSE_BTN).first(); + await closeBtn.waitFor({ state: 'visible', timeout: 3000 }); + await closeBtn.click(); + await page.waitForTimeout(500); + } catch { + // Dialog not present, ignore + } +}; + +/** + * Helper to trigger and capture download + */ +const captureDownload = async (page: Page): Promise => { + const downloadPromise = page.waitForEvent('download'); + + // Click the export button (file_upload icon in file-imex) + const exportBtn = page.locator( + 'file-imex button:has(mat-icon:has-text("file_upload"))', + ); + await exportBtn.click(); + + return downloadPromise; +}; + +/** + * Helper to read downloaded file content + */ +const readDownloadedFile = async (download: Download): Promise => { + const downloadPath = await download.path(); + if (!downloadPath) { + throw new Error('Download path is null'); + } + return fs.readFileSync(downloadPath, 'utf-8'); +}; + +/** + * Helper to mark all visible tasks as done + */ +const markAllTasksDone = async (page: Page): Promise => { + // Wait for tasks to be visible + await page.waitForSelector(TASK_SEL, { state: 'visible', timeout: 10000 }); + + // Keep marking the first task as done until no undone tasks remain + let attempts = 0; + const maxAttempts = 10; + + while (attempts < maxAttempts) { + const undoneCount = await page.locator('task:not(.isDone)').count(); + if (undoneCount === 0) break; + + // Hover and click done on first undone task + const firstUndone = page.locator('task:not(.isDone)').first(); + await firstUndone.hover(); + await page.waitForTimeout(200); + + const doneBtn = firstUndone.locator(TASK_DONE_BTN); + if (await doneBtn.isVisible()) { + await doneBtn.click(); + await page.waitForTimeout(300); + } + attempts++; + } +}; + +/** + * Helper to complete finish day flow + */ +const finishDay = async (page: Page): Promise => { + // Click Finish Day button + await page.waitForSelector(FINISH_DAY_BTN, { state: 'visible', timeout: 10000 }); + await page.click(FINISH_DAY_BTN); + + // Wait for daily summary page + await page.waitForSelector('daily-summary', { state: 'visible', timeout: 10000 }); + + // Click Save and go home + await page.waitForSelector(SAVE_AND_GO_HOME_BTN, { state: 'visible', timeout: 10000 }); + await page.click(SAVE_AND_GO_HOME_BTN); + + // Wait for navigation back to work view + await page.waitForSelector('task-list', { state: 'visible', timeout: 10000 }); + await page.waitForTimeout(1000); +}; + +test.describe('@legacy-archive Legacy Archive Subtasks via Finish Day', () => { + test.beforeEach(async ({ page }) => { + // Start with fresh state + await page.goto('/#/tag/TODAY/tasks'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); + }); + + /** + * Test: Import legacy data, finish day, and verify subtasks are archived + * + * BUG: This test documents the bug where subtasks are lost after finish day. + * Expected: All 4 tasks (parent, 2 subtasks, normal) should be in archiveYoung + * Actual (bug): Only parent and normal tasks are in archiveYoung, subtasks missing + */ + test('should preserve subtasks in archive after finish day', async ({ page }) => { + test.setTimeout(120000); // Long test with multiple steps + + const importPage = new ImportPage(page); + + // Step 1: Import legacy backup with active tasks + console.log('[Legacy Archive Test] Step 1: Importing legacy backup...'); + await importPage.navigateToImportPage(); + const backupPath = ImportPage.getFixturePath('legacy-archive-subtasks-backup.json'); + await importPage.importBackupFile(backupPath); + await expect(page).toHaveURL(/.*tag.*TODAY.*tasks/); + console.log('[Legacy Archive Test] Import completed'); + + // Dismiss welcome dialog if present + await dismissWelcomeDialog(page); + + // Step 2: Navigate to TODAY tag to see tasks + console.log('[Legacy Archive Test] Step 2: Navigating to TODAY tag...'); + await page.goto('/#/tag/TODAY/tasks'); + await page.waitForLoadState('networkidle'); + await dismissWelcomeDialog(page); + await page.waitForTimeout(1000); + + // Verify tasks are visible (parent tasks - subtasks are nested) + await page.waitForSelector(TASK_SEL, { state: 'visible', timeout: 10000 }); + const taskCount = await page.locator(TASK_SEL).count(); + console.log( + `[Legacy Archive Test] Found ${taskCount} tasks (including subtasks when expanded)`, + ); + + // Step 3: Mark all tasks as done (parent tasks first, subtasks auto-done by parent) + console.log('[Legacy Archive Test] Step 3: Marking all tasks as done...'); + await markAllTasksDone(page); + console.log('[Legacy Archive Test] All tasks marked as done'); + + // Step 4: Finish day to archive tasks + console.log('[Legacy Archive Test] Step 4: Finishing day...'); + await finishDay(page); + console.log('[Legacy Archive Test] Finish day completed'); + + // Step 5: Export and verify + console.log('[Legacy Archive Test] Step 5: Exporting data...'); + await page.reload(); + await page.waitForLoadState('networkidle'); + await importPage.navigateToImportPage(); + const download = await captureDownload(page); + const exportedContent = await readDownloadedFile(download); + const exportedData = JSON.parse(exportedContent); + + // Step 6: Verify archived tasks + console.log('[Legacy Archive Test] Step 6: Verifying exported data...'); + expect(exportedData).toHaveProperty('data'); + expect(exportedData.data).toHaveProperty('archiveYoung'); + + const archiveYoungTask = exportedData.data.archiveYoung.task; + console.log( + '[Legacy Archive Test] Exported archiveYoung task IDs:', + archiveYoungTask.ids, + ); + console.log( + '[Legacy Archive Test] Exported archiveYoung entity keys:', + Object.keys(archiveYoungTask.entities), + ); + + // BUG CHECK: All 4 tasks should be in the archive + // This is where the bug manifests - subtasks are missing + expect(archiveYoungTask.ids).toContain('parent-task-1'); + expect(archiveYoungTask.ids).toContain('subtask-1'); + expect(archiveYoungTask.ids).toContain('subtask-2'); + expect(archiveYoungTask.ids).toContain('normal-task-1'); + + // Verify entities are present + expect(archiveYoungTask.entities['parent-task-1']).toBeDefined(); + expect(archiveYoungTask.entities['subtask-1']).toBeDefined(); + expect(archiveYoungTask.entities['subtask-2']).toBeDefined(); + expect(archiveYoungTask.entities['normal-task-1']).toBeDefined(); + + // Verify parent-subtask relationships + const parentTask = archiveYoungTask.entities['parent-task-1']; + expect(parentTask.subTaskIds).toContain('subtask-1'); + expect(parentTask.subTaskIds).toContain('subtask-2'); + + const subtask1 = archiveYoungTask.entities['subtask-1']; + expect(subtask1.parentId).toBe('parent-task-1'); + + const subtask2 = archiveYoungTask.entities['subtask-2']; + expect(subtask2.parentId).toBe('parent-task-1'); + + console.log('[Legacy Archive Test] All subtasks preserved in archive!'); + }); + + /** + * Test: Verify exact count of archived tasks after finish day + */ + test('should have exactly 4 archived tasks after finish day', async ({ page }) => { + test.setTimeout(120000); + + const importPage = new ImportPage(page); + + // Import legacy backup + await importPage.navigateToImportPage(); + const backupPath = ImportPage.getFixturePath('legacy-archive-subtasks-backup.json'); + await importPage.importBackupFile(backupPath); + await expect(page).toHaveURL(/.*tag.*TODAY.*tasks/); + await dismissWelcomeDialog(page); + + // Navigate to TODAY tag and mark all done + await page.goto('/#/tag/TODAY/tasks'); + await page.waitForLoadState('networkidle'); + await dismissWelcomeDialog(page); + await page.waitForTimeout(1000); + await markAllTasksDone(page); + + // Finish day + await finishDay(page); + + // Export + await page.reload(); + await page.waitForLoadState('networkidle'); + await importPage.navigateToImportPage(); + const download = await captureDownload(page); + const exportedContent = await readDownloadedFile(download); + const exportedData = JSON.parse(exportedContent); + + // Count archived tasks + const archiveYoungTaskIds = exportedData.data.archiveYoung.task.ids; + const archiveYoungEntityCount = Object.keys( + exportedData.data.archiveYoung.task.entities, + ).length; + + console.log('[Legacy Archive Test] Archive task counts:', { + idsCount: archiveYoungTaskIds.length, + entitiesCount: archiveYoungEntityCount, + expectedCount: 4, + }); + + // BUG CHECK: Should have 4 tasks (parent, 2 subtasks, normal) + expect(archiveYoungTaskIds.length).toBe(4); + expect(archiveYoungEntityCount).toBe(4); + }); + + /** + * Test: Verify subtask entity data integrity after archive + */ + test('should preserve subtask entity data after finish day', async ({ page }) => { + test.setTimeout(120000); + + const importPage = new ImportPage(page); + + // Import, mark done, finish day + await importPage.navigateToImportPage(); + const backupPath = ImportPage.getFixturePath('legacy-archive-subtasks-backup.json'); + await importPage.importBackupFile(backupPath); + await expect(page).toHaveURL(/.*tag.*TODAY.*tasks/); + await dismissWelcomeDialog(page); + + await page.goto('/#/tag/TODAY/tasks'); + await page.waitForLoadState('networkidle'); + await dismissWelcomeDialog(page); + await page.waitForTimeout(1000); + await markAllTasksDone(page); + await finishDay(page); + + // Export + await page.reload(); + await page.waitForLoadState('networkidle'); + await importPage.navigateToImportPage(); + const download = await captureDownload(page); + const exportedContent = await readDownloadedFile(download); + const exportedData = JSON.parse(exportedContent); + + // Get subtask entities + const subtask1 = exportedData.data.archiveYoung.task.entities['subtask-1']; + const subtask2 = exportedData.data.archiveYoung.task.entities['subtask-2']; + + // BUG CHECK: Subtasks should exist and have correct data + expect(subtask1).toBeDefined(); + expect(subtask1.id).toBe('subtask-1'); + expect(subtask1.title).toBe('Legacy Test - First Subtask'); + expect(subtask1.parentId).toBe('parent-task-1'); + expect(subtask1.isDone).toBe(true); + + expect(subtask2).toBeDefined(); + expect(subtask2.id).toBe('subtask-2'); + expect(subtask2.title).toBe('Legacy Test - Second Subtask'); + expect(subtask2.parentId).toBe('parent-task-1'); + expect(subtask2.isDone).toBe(true); + + console.log('[Legacy Archive Test] Subtask entity data preserved correctly!'); + }); +}); diff --git a/src/app/core/persistence/operation-log/integration/legacy-archive-subtasks.integration.spec.ts b/src/app/core/persistence/operation-log/integration/legacy-archive-subtasks.integration.spec.ts new file mode 100644 index 000000000..fde3757ba --- /dev/null +++ b/src/app/core/persistence/operation-log/integration/legacy-archive-subtasks.integration.spec.ts @@ -0,0 +1,445 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { TestBed } from '@angular/core/testing'; +import { OperationLogStoreService } from '../store/operation-log-store.service'; +import { OpType, Operation } from '../operation.types'; +import { resetTestUuidCounter, TestClient } from './helpers/test-client.helper'; +import { createMinimalTaskPayload } from './helpers/operation-factory.helper'; +import { CURRENT_SCHEMA_VERSION } from '../store/schema-migration.service'; +import { uuidv7 } from '../../../../util/uuid-v7'; + +/** + * Integration tests for legacy data import/export with archive subtasks. + * + * BUG: When importing data from before operation logs (legacy data) that contains + * tasks with subtasks in archiveYoung, the subtasks are lost when exporting again. + * + * These tests verify that: + * 1. Import creates proper operations preserving parent-subtask relationships + * 2. Export includes all archived tasks including subtasks + * 3. Round-trip import->export preserves data integrity + */ + +// Type definitions for test data +interface TestTaskEntity { + id: string; + title: string; + subTaskIds: string[]; + parentId?: string; + isDone: boolean; + doneOn?: number; + timeSpent: number; + projectId: string; +} + +interface TestArchiveTask { + ids: string[]; + entities: Record; +} + +interface TestArchiveYoung { + task: TestArchiveTask; + timeTracking: { project: Record; tag: Record }; + lastTimeTrackingFlush: number; +} + +interface TestLegacyData { + task: { + ids: string[]; + entities: Record; + currentTaskId: null; + selectedTaskId: null; + lastCurrentTaskId: null; + isDataLoaded: boolean; + }; + project: { + ids: string[]; + entities: Record; + }; + tag: { + ids: string[]; + entities: Record; + }; + archiveYoung: TestArchiveYoung; + archiveOld: { + task: { ids: string[]; entities: Record }; + timeTracking: { project: Record; tag: Record }; + lastTimeTrackingFlush: number; + }; + timeTracking: { project: Record; tag: Record }; +} + +describe('Legacy Archive Subtasks Integration', () => { + let storeService: OperationLogStoreService; + + // Test data: parent task with 2 subtasks + 1 normal task in archiveYoung + const createLegacyArchiveData = (): TestLegacyData => ({ + task: { + ids: [], + entities: {}, + currentTaskId: null, + selectedTaskId: null, + lastCurrentTaskId: null, + isDataLoaded: false, + }, + project: { + ids: ['INBOX_PROJECT'], + entities: { + INBOX_PROJECT: { + id: 'INBOX_PROJECT', + title: 'Inbox', + taskIds: [], + backlogTaskIds: [], + }, + }, + }, + tag: { + ids: ['TODAY'], + entities: { + TODAY: { + id: 'TODAY', + title: 'Today', + taskIds: [], + }, + }, + }, + archiveYoung: { + task: { + ids: [ + 'archived-parent-task', + 'archived-subtask-1', + 'archived-subtask-2', + 'archived-normal-task', + ], + entities: { + 'archived-parent-task': createMinimalTaskPayload('archived-parent-task', { + title: 'Legacy Archive Test - Parent Task With Subtasks', + subTaskIds: ['archived-subtask-1', 'archived-subtask-2'], + isDone: true, + doneOn: Date.now() - 86400000, // 1 day ago + timeSpent: 3600000, + projectId: 'INBOX_PROJECT', + }) as unknown as TestTaskEntity, + 'archived-subtask-1': createMinimalTaskPayload('archived-subtask-1', { + title: 'Legacy Archive Test - First Subtask', + parentId: 'archived-parent-task', + subTaskIds: [], + isDone: true, + doneOn: Date.now() - 86400000, + timeSpent: 1800000, + projectId: 'INBOX_PROJECT', + }) as unknown as TestTaskEntity, + 'archived-subtask-2': createMinimalTaskPayload('archived-subtask-2', { + title: 'Legacy Archive Test - Second Subtask', + parentId: 'archived-parent-task', + subTaskIds: [], + isDone: true, + doneOn: Date.now() - 86400000, + timeSpent: 1200000, + projectId: 'INBOX_PROJECT', + }) as unknown as TestTaskEntity, + 'archived-normal-task': createMinimalTaskPayload('archived-normal-task', { + title: 'Legacy Archive Test - Normal Task Without Subtasks', + subTaskIds: [], + isDone: true, + doneOn: Date.now() - 86400000, + timeSpent: 2400000, + projectId: 'INBOX_PROJECT', + }) as unknown as TestTaskEntity, + }, + }, + timeTracking: { project: {}, tag: {} }, + lastTimeTrackingFlush: Date.now(), + }, + archiveOld: { + task: { ids: [], entities: {} }, + timeTracking: { project: {}, tag: {} }, + lastTimeTrackingFlush: 0, + }, + timeTracking: { project: {}, tag: {} }, + }); + + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [OperationLogStoreService], + }); + storeService = TestBed.inject(OperationLogStoreService); + + await storeService.init(); + await storeService._clearAllDataForTesting(); + resetTestUuidCounter(); + }); + + describe('SyncImport with Archive Subtasks', () => { + /** + * Test: Import operation preserves all archived tasks including subtasks + * + * When legacy data with archived parent+subtask structure is imported, + * the SyncImport operation payload should contain all 4 tasks. + */ + it('should preserve archived subtasks in import operation payload', async () => { + const testClient = new TestClient('import-client'); + const legacyData = createLegacyArchiveData(); + + // Create import operation (simulating what PfapiService does) + const importOp: Operation = testClient.createOperation({ + actionType: '[SP_ALL] Load(import) all data', + opType: OpType.SyncImport, + entityType: 'ALL', + entityId: uuidv7(), + payload: { appDataComplete: legacyData }, + }); + + await storeService.append(importOp, 'local'); + + // Retrieve and verify the stored operation + const allOps = await storeService.getOpsAfterSeq(0); + expect(allOps.length).toBe(1); + + const storedOp = allOps[0]; + const payload = storedOp.op.payload as { appDataComplete: TestLegacyData }; + + // Verify archiveYoung contains all 4 tasks + const archiveYoungTask = payload.appDataComplete.archiveYoung.task; + + expect(archiveYoungTask.ids.length).toBe(4); + expect(archiveYoungTask.ids).toContain('archived-parent-task'); + expect(archiveYoungTask.ids).toContain('archived-subtask-1'); + expect(archiveYoungTask.ids).toContain('archived-subtask-2'); + expect(archiveYoungTask.ids).toContain('archived-normal-task'); + + // Verify parent-subtask relationships are preserved + const parentTask = archiveYoungTask.entities['archived-parent-task']; + expect(parentTask.subTaskIds).toContain('archived-subtask-1'); + expect(parentTask.subTaskIds).toContain('archived-subtask-2'); + + const subtask1 = archiveYoungTask.entities['archived-subtask-1']; + expect(subtask1.parentId).toBe('archived-parent-task'); + + const subtask2 = archiveYoungTask.entities['archived-subtask-2']; + expect(subtask2.parentId).toBe('archived-parent-task'); + }); + + /** + * Test: State cache after import contains all archived subtasks + * + * BUG: This test documents the bug where subtasks are lost in the state cache + * after importing legacy data. The state cache is used for export. + */ + it('should include archived subtasks in state cache after import', async () => { + const testClient = new TestClient('import-client'); + const legacyData = createLegacyArchiveData(); + + // Create and store import operation + const importOp: Operation = testClient.createOperation({ + actionType: '[SP_ALL] Load(import) all data', + opType: OpType.SyncImport, + entityType: 'ALL', + entityId: uuidv7(), + payload: { appDataComplete: legacyData }, + }); + + await storeService.append(importOp, 'local'); + const lastSeq = await storeService.getLastSeq(); + + // Save state cache (simulating what PfapiService does after import) + await storeService.saveStateCache({ + state: legacyData, + lastAppliedOpSeq: lastSeq, + vectorClock: importOp.vectorClock, + compactedAt: Date.now(), + schemaVersion: CURRENT_SCHEMA_VERSION, + }); + + // Load and verify state cache (this is what export would use) + const stateCache = await storeService.loadStateCache(); + expect(stateCache).toBeDefined(); + + const cachedState = stateCache!.state as TestLegacyData; + const cachedArchiveYoung = cachedState.archiveYoung; + + // This should pass - verifying all 4 tasks are in cache + expect(cachedArchiveYoung.task.ids.length).toBe(4); + expect(cachedArchiveYoung.task.ids).toContain('archived-parent-task'); + expect(cachedArchiveYoung.task.ids).toContain('archived-subtask-1'); + expect(cachedArchiveYoung.task.ids).toContain('archived-subtask-2'); + expect(cachedArchiveYoung.task.ids).toContain('archived-normal-task'); + + // Verify subtask entities are present + expect(cachedArchiveYoung.task.entities['archived-subtask-1']).toBeDefined(); + expect(cachedArchiveYoung.task.entities['archived-subtask-2']).toBeDefined(); + }); + }); + + describe('Import/Export Round-Trip', () => { + /** + * Test: Round-trip import->export preserves archived subtasks + * + * BUG: This is the main bug scenario. When legacy data is: + * 1. Imported (creating SyncImport operation) + * 2. State cache is created + * 3. Exported (reading from state cache or operations) + * + * The exported data should contain all original archived tasks including subtasks. + * Currently, subtasks are being lost. + */ + it('should preserve archived subtasks through import->export cycle', async () => { + const testClient = new TestClient('import-client'); + const originalData = createLegacyArchiveData(); + + // Step 1: Import (create SyncImport operation) + const importOp: Operation = testClient.createOperation({ + actionType: '[SP_ALL] Load(import) all data', + opType: OpType.SyncImport, + entityType: 'ALL', + entityId: uuidv7(), + payload: { appDataComplete: originalData }, + }); + + await storeService.append(importOp, 'local'); + const lastSeq = await storeService.getLastSeq(); + + // Step 2: Save state cache (simulating post-import state saving) + await storeService.saveStateCache({ + state: originalData, + lastAppliedOpSeq: lastSeq, + vectorClock: importOp.vectorClock, + compactedAt: Date.now(), + schemaVersion: CURRENT_SCHEMA_VERSION, + }); + + // Step 3: Simulate export by reading from stored operation + const allOps = await storeService.getOpsAfterSeq(0); + const exportedFromOp = allOps[0].op.payload as { appDataComplete: TestLegacyData }; + + // Step 4: Also simulate export by reading from state cache + const stateCache = await storeService.loadStateCache(); + const exportedFromCache = stateCache!.state as TestLegacyData; + + // Verify both export sources contain all archived tasks + // From operation payload: + const opArchiveYoung = exportedFromOp.appDataComplete.archiveYoung.task; + expect(opArchiveYoung.ids).toContain('archived-parent-task'); + expect(opArchiveYoung.ids).toContain('archived-subtask-1'); + expect(opArchiveYoung.ids).toContain('archived-subtask-2'); + expect(opArchiveYoung.ids).toContain('archived-normal-task'); + + // From state cache: + const cacheArchiveYoung = exportedFromCache.archiveYoung.task; + expect(cacheArchiveYoung.ids).toContain('archived-parent-task'); + expect(cacheArchiveYoung.ids).toContain('archived-subtask-1'); + expect(cacheArchiveYoung.ids).toContain('archived-subtask-2'); + expect(cacheArchiveYoung.ids).toContain('archived-normal-task'); + + // Verify subtask entities are present in exports + expect(opArchiveYoung.entities['archived-subtask-1']).toBeDefined(); + expect(opArchiveYoung.entities['archived-subtask-2']).toBeDefined(); + expect(cacheArchiveYoung.entities['archived-subtask-1']).toBeDefined(); + expect(cacheArchiveYoung.entities['archived-subtask-2']).toBeDefined(); + }); + + /** + * Test: Multiple imports maintain archive subtask integrity + * + * When legacy data is imported multiple times (simulating user importing + * different backups), archived subtasks should remain intact. + */ + it('should preserve archived subtasks across multiple import cycles', async () => { + const testClient = new TestClient('import-client'); + + // First import with subtasks + const firstData = createLegacyArchiveData(); + const firstImportOp: Operation = testClient.createOperation({ + actionType: '[SP_ALL] Load(import) all data', + opType: OpType.SyncImport, + entityType: 'ALL', + entityId: uuidv7(), + payload: { appDataComplete: firstData }, + }); + + await storeService.append(firstImportOp, 'local'); + + // Verify first import + let allOps = await storeService.getOpsAfterSeq(0); + let lastPayload = allOps[allOps.length - 1].op.payload as { + appDataComplete: TestLegacyData; + }; + let archiveYoung = lastPayload.appDataComplete.archiveYoung.task; + expect(archiveYoung.ids.length).toBe(4); + + // Second import (same data, simulating re-import) + const secondImportOp: Operation = testClient.createOperation({ + actionType: '[SP_ALL] Load(import) all data', + opType: OpType.SyncImport, + entityType: 'ALL', + entityId: uuidv7(), + payload: { appDataComplete: firstData }, + }); + + await storeService.append(secondImportOp, 'local'); + + // Verify second import still has all subtasks + allOps = await storeService.getOpsAfterSeq(0); + lastPayload = allOps[allOps.length - 1].op.payload as { + appDataComplete: TestLegacyData; + }; + archiveYoung = lastPayload.appDataComplete.archiveYoung.task; + + expect(archiveYoung.ids.length).toBe(4); + expect(archiveYoung.ids).toContain('archived-subtask-1'); + expect(archiveYoung.ids).toContain('archived-subtask-2'); + expect(archiveYoung.entities['archived-subtask-1']).toBeDefined(); + expect(archiveYoung.entities['archived-subtask-2']).toBeDefined(); + }); + }); + + describe('Parent-Subtask Relationship Integrity', () => { + /** + * Test: Parent task subTaskIds match actual subtask parentIds + * + * This verifies the bidirectional relationship is maintained. + */ + it('should maintain bidirectional parent-subtask relationships', async () => { + const testClient = new TestClient('import-client'); + const legacyData = createLegacyArchiveData(); + + const importOp: Operation = testClient.createOperation({ + actionType: '[SP_ALL] Load(import) all data', + opType: OpType.SyncImport, + entityType: 'ALL', + entityId: uuidv7(), + payload: { appDataComplete: legacyData }, + }); + + await storeService.append(importOp, 'local'); + + const allOps = await storeService.getOpsAfterSeq(0); + const payload = allOps[0].op.payload as { appDataComplete: TestLegacyData }; + + const archiveYoung = payload.appDataComplete.archiveYoung.task; + + // Get parent and subtasks + const parent = archiveYoung.entities['archived-parent-task']; + const subtask1 = archiveYoung.entities['archived-subtask-1']; + const subtask2 = archiveYoung.entities['archived-subtask-2']; + const normalTask = archiveYoung.entities['archived-normal-task']; + + // Verify parent has correct subTaskIds + expect(parent.subTaskIds).toBeDefined(); + expect(parent.subTaskIds!.length).toBe(2); + expect(parent.subTaskIds).toContain('archived-subtask-1'); + expect(parent.subTaskIds).toContain('archived-subtask-2'); + expect(parent.parentId).toBeUndefined(); + + // Verify subtasks point to correct parent + expect(subtask1.parentId).toBe('archived-parent-task'); + expect(subtask1.subTaskIds).toEqual([]); + + expect(subtask2.parentId).toBe('archived-parent-task'); + expect(subtask2.subTaskIds).toEqual([]); + + // Verify normal task has no parent-child relationships + expect(normalTask.parentId).toBeUndefined(); + expect(normalTask.subTaskIds).toEqual([]); + }); + }); +});