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
This commit is contained in:
Johannes Millan 2025-12-26 16:02:34 +01:00
parent 42b0f4ea1a
commit 6191c782f0
3 changed files with 1101 additions and 0 deletions

View file

@ -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": []
}
}

View file

@ -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<void> => {
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<Download> => {
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<string> => {
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<void> => {
// 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<void> => {
// 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!');
});
});

View file

@ -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<string, TestTaskEntity>;
}
interface TestArchiveYoung {
task: TestArchiveTask;
timeTracking: { project: Record<string, unknown>; tag: Record<string, unknown> };
lastTimeTrackingFlush: number;
}
interface TestLegacyData {
task: {
ids: string[];
entities: Record<string, unknown>;
currentTaskId: null;
selectedTaskId: null;
lastCurrentTaskId: null;
isDataLoaded: boolean;
};
project: {
ids: string[];
entities: Record<string, unknown>;
};
tag: {
ids: string[];
entities: Record<string, unknown>;
};
archiveYoung: TestArchiveYoung;
archiveOld: {
task: { ids: string[]; entities: Record<string, unknown> };
timeTracking: { project: Record<string, unknown>; tag: Record<string, unknown> };
lastTimeTrackingFlush: number;
};
timeTracking: { project: Record<string, unknown>; tag: Record<string, unknown> };
}
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([]);
});
});
});