From 55efa98182f70ce67712843cc4414e6046f56c81 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Thu, 25 Jun 2026 11:55:17 -0700 Subject: [PATCH] feat(data): add legacy document migrations --- ..._Persistence_Schema_Codecs_Repositories.md | 32 + .../V1_PUBLIC_API_BASELINE.md | 16 + lib/scheduler_core.dart | 1 + lib/src/document_migration.dart | 1138 +++++++++++++++++ test/document_migration_test.dart | 237 ++++ test/fixtures/migration/locked_block_v0.json | 23 + .../migration/locked_override_v0.json | 18 + test/fixtures/migration/project_v0.json | 10 + .../migration/task_future_schema.json | 6 + .../migration/task_v0_ambiguous_datetime.json | 36 + test/fixtures/migration/task_v0_full.json | 38 + .../task_v0_malformed_missing_title.json | 35 + test/fixtures/migration/task_v0_minimum.json | 36 + 13 files changed, 1626 insertions(+) create mode 100644 lib/src/document_migration.dart create mode 100644 test/document_migration_test.dart create mode 100644 test/fixtures/migration/locked_block_v0.json create mode 100644 test/fixtures/migration/locked_override_v0.json create mode 100644 test/fixtures/migration/project_v0.json create mode 100644 test/fixtures/migration/task_future_schema.json create mode 100644 test/fixtures/migration/task_v0_ambiguous_datetime.json create mode 100644 test/fixtures/migration/task_v0_full.json create mode 100644 test/fixtures/migration/task_v0_malformed_missing_title.json create mode 100644 test/fixtures/migration/task_v0_minimum.json diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_15_Persistence_Schema_Codecs_Repositories.md b/Codex Documentation/Current Software Plan/V1_BLOCK_15_Persistence_Schema_Codecs_Repositories.md index cfb5c92..67ed45d 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_15_Persistence_Schema_Codecs_Repositories.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_15_Persistence_Schema_Codecs_Repositories.md @@ -158,6 +158,8 @@ Verification: Recommended Codex level: extra high +Status: Complete + Tasks: - Treat the existing task/task-statistics mapping as legacy schema version 0. @@ -192,6 +194,36 @@ Acceptance criteria: - Date/backlog approximations carry provenance. - Migration fixtures are part of the automated suite. +Completed implementation: + +- Added `document_migration.dart` with a pure Dart `V1DocumentMigrationService` + for V0 task, project, locked block, and locked override documents. +- Treated missing `schemaVersion` as schema V0 and validated already-current V1 + documents through the V1 codecs without rewriting them. +- Added typed migration reports/results, non-fatal notes, and `shouldWrite` + semantics so adapters can dry-run migrations and avoid overwriting unreadable + source documents. +- Migrated legacy enum `.name` values to stable V1 codes for task, project, and + locked-time fields. +- Added conservative backlog provenance: legacy backlog tasks set + `backlogEnteredAt` from `createdAt` with + `approximated_from_created_at`; non-backlog tasks keep the metadata null. +- Preserved date-only locked override values as civil-date strings and rejected + ambiguous legacy date-time values rather than guessing a timezone. +- Added checked-in JSON fixtures for minimum/full V0 task documents, manually + produced project/locked documents, corrupt/malformed V0 documents, and an + unsupported future schema document. +- Added automated migration tests for V0→V1, already-V1 no-op, repeated + migration idempotency, partial corruption, ambiguous date-times, and future + schema failure. + +Verification: + +- `dart format lib test`: passed, 0 files changed after final format +- `dart analyze`: passed, no issues found +- `dart test`: passed, 289 tests +- `git diff --check`: passed + BREAKPOINT: Stop here. Confirm `high` mode before expanding repository/query contracts and index specifications. diff --git a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md index 09aab5c..b5682cc 100644 --- a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md +++ b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md @@ -354,6 +354,22 @@ Chunk 15.2 intentionally changed the public API: - Expanded `persistence_contract.dart` with `v1SchemaVersion`, common document fields, and V1 field sets for every persisted repository entity. +Chunk 15.3 intentionally changed the public API: + +- Added `src/document_migration.dart` and exported it from `scheduler_core.dart`. +- Added `V1DocumentMigrationService` for pure Dart V0-to-V1 migration of task, + project, locked block, and locked override documents. +- Added typed migration surface area: + `DocumentMigrationEntity`, `DocumentMigrationStatus`, + `DocumentMigrationFailureCode`, `DocumentMigrationNoteCode`, + `DocumentMigrationProvenance`, `DocumentMigrationNote`, + `DocumentMigrationReport`, and `DocumentMigrationResult`. +- Migration results expose `document`, `isSuccess`, and `shouldWrite` so + persistence adapters can dry-run migrations and refuse to overwrite unreadable + source documents. +- Legacy backlog tasks now carry explicit provenance when `backlogEnteredAt` is + approximated from `createdAt`. + ## Repository contracts The current repository surface is pure Dart and in-memory only: diff --git a/lib/scheduler_core.dart b/lib/scheduler_core.dart index e11c015..3320c53 100644 --- a/lib/scheduler_core.dart +++ b/lib/scheduler_core.dart @@ -24,6 +24,7 @@ export 'src/application_recovery.dart'; export 'src/backlog.dart'; export 'src/child_tasks.dart'; export 'src/document_mapping.dart'; +export 'src/document_migration.dart'; export 'src/free_slots.dart'; export 'src/locked_time.dart'; export 'src/occupancy_policy.dart'; diff --git a/lib/src/document_migration.dart b/lib/src/document_migration.dart new file mode 100644 index 0000000..ece549c --- /dev/null +++ b/lib/src/document_migration.dart @@ -0,0 +1,1138 @@ +// Legacy document migration helpers for the V1 persistence contract. +// +// The migration layer is intentionally plain Dart and side-effect free. Future +// adapters can use these results for dry-runs before deciding whether to write +// the migrated document back to storage. + +import 'document_mapping.dart'; +import 'persistence_contract.dart'; +import 'time_contracts.dart'; + +/// Entity shape being migrated. +enum DocumentMigrationEntity { + task, + project, + lockedBlock, + lockedBlockOverride, +} + +/// Result state for a migration attempt. +enum DocumentMigrationStatus { + migrated, + alreadyCurrent, + failed, +} + +/// Typed categories for migration failures. +enum DocumentMigrationFailureCode { + invalidSchemaVersion, + unsupportedSchemaVersion, + legacyDocumentFailure, + currentDocumentFailure, +} + +/// Non-fatal migration notes surfaced to callers during dry-runs. +enum DocumentMigrationNoteCode { + approximatedBacklogEnteredAt, + synthesizedMetadataTimestamp, +} + +/// Provenance labels written by migrations. +abstract final class DocumentMigrationProvenance { + static const approximatedFromCreatedAt = 'approximated_from_created_at'; +} + +/// A non-fatal detail about a migrated document. +class DocumentMigrationNote { + const DocumentMigrationNote({ + required this.code, + this.fieldName, + this.detailCode, + }); + + final DocumentMigrationNoteCode code; + final String? fieldName; + final String? detailCode; +} + +/// Typed dry-run report for one document migration attempt. +class DocumentMigrationReport { + const DocumentMigrationReport({ + required this.entity, + required this.status, + required this.documentId, + required this.fromSchemaVersion, + this.targetSchemaVersion = v1SchemaVersion, + this.failureCode, + this.mappingFailureCode, + this.fieldName, + this.detailCode, + this.notes = const [], + }); + + final DocumentMigrationEntity entity; + final DocumentMigrationStatus status; + final String? documentId; + final int? fromSchemaVersion; + final int targetSchemaVersion; + final DocumentMigrationFailureCode? failureCode; + final DocumentMappingFailureCode? mappingFailureCode; + final String? fieldName; + final String? detailCode; + final List notes; + + bool get canWrite => status != DocumentMigrationStatus.failed; +} + +/// Migration output plus report. +class DocumentMigrationResult { + const DocumentMigrationResult({ + required this.report, + required this.document, + }); + + /// Migrated/current document. Null means the source must not be overwritten. + final Map? document; + + final DocumentMigrationReport report; + + bool get isSuccess => report.status != DocumentMigrationStatus.failed; + + /// True only when the caller should persist a transformed replacement. + bool get shouldWrite => report.status == DocumentMigrationStatus.migrated; +} + +/// Pure V0-to-V1 migration service. +/// +/// Version 0 is the pre-Block-15 document shape: no `schemaVersion`, no owner +/// metadata, enum values stored as Dart `.name`, and no exact backlog-entry +/// timestamp. Missing `schemaVersion` is treated as V0; unsupported future +/// versions fail closed. +class V1DocumentMigrationService { + const V1DocumentMigrationService({ + required this.ownerId, + required this.migratedAt, + }); + + final String ownerId; + final DateTime migratedAt; + + DocumentMigrationResult migrateTask(Map document) { + return _migrate( + entity: DocumentMigrationEntity.task, + document: document, + migrateV0: _taskV0ToV1, + validateCurrent: TaskDocumentMapping.fromDocument, + ); + } + + DocumentMigrationResult migrateProject(Map document) { + return _migrate( + entity: DocumentMigrationEntity.project, + document: document, + migrateV0: _projectV0ToV1, + validateCurrent: ProjectDocumentMapping.fromDocument, + ); + } + + DocumentMigrationResult migrateLockedBlock(Map document) { + return _migrate( + entity: DocumentMigrationEntity.lockedBlock, + document: document, + migrateV0: _lockedBlockV0ToV1, + validateCurrent: LockedBlockDocumentMapping.fromDocument, + ); + } + + DocumentMigrationResult migrateLockedBlockOverride( + Map document, + ) { + return _migrate( + entity: DocumentMigrationEntity.lockedBlockOverride, + document: document, + migrateV0: _lockedBlockOverrideV0ToV1, + validateCurrent: LockedBlockOverrideDocumentMapping.fromDocument, + ); + } + + DocumentMigrationResult _migrate({ + required DocumentMigrationEntity entity, + required Map document, + required _V0Migrator migrateV0, + required void Function(Map document) validateCurrent, + }) { + final documentId = _documentId(document); + final schemaRead = _readSchemaVersion(document); + if (schemaRead.failure != null) { + return _failed( + entity: entity, + documentId: documentId, + fromSchemaVersion: null, + failureCode: DocumentMigrationFailureCode.invalidSchemaVersion, + fieldName: DocumentFields.schemaVersion, + detailCode: schemaRead.failure, + ); + } + + final fromVersion = schemaRead.version; + if (fromVersion == v1SchemaVersion) { + try { + final copy = _deepCopyDocument(document); + validateCurrent(copy); + return DocumentMigrationResult( + document: copy, + report: DocumentMigrationReport( + entity: entity, + status: DocumentMigrationStatus.alreadyCurrent, + documentId: documentId, + fromSchemaVersion: fromVersion, + ), + ); + } on DocumentMappingException catch (error) { + return _failedFromMapping( + entity: entity, + documentId: documentId, + fromSchemaVersion: fromVersion, + failureCode: DocumentMigrationFailureCode.currentDocumentFailure, + error: error, + ); + } + } + + if (fromVersion > v1SchemaVersion || fromVersion < 0) { + return _failed( + entity: entity, + documentId: documentId, + fromSchemaVersion: fromVersion, + failureCode: DocumentMigrationFailureCode.unsupportedSchemaVersion, + fieldName: DocumentFields.schemaVersion, + detailCode: fromVersion.toString(), + ); + } + + if (fromVersion != 0) { + return _failed( + entity: entity, + documentId: documentId, + fromSchemaVersion: fromVersion, + failureCode: DocumentMigrationFailureCode.unsupportedSchemaVersion, + fieldName: DocumentFields.schemaVersion, + detailCode: fromVersion.toString(), + ); + } + + final notes = []; + late final Map migrated; + try { + migrated = migrateV0(document, notes); + } on _LegacyDocumentException catch (error) { + return _failed( + entity: entity, + documentId: documentId, + fromSchemaVersion: fromVersion, + failureCode: DocumentMigrationFailureCode.legacyDocumentFailure, + fieldName: error.fieldName, + detailCode: error.detailCode, + ); + } + + try { + validateCurrent(migrated); + } on DocumentMappingException catch (error) { + return _failedFromMapping( + entity: entity, + documentId: documentId, + fromSchemaVersion: fromVersion, + failureCode: DocumentMigrationFailureCode.currentDocumentFailure, + error: error, + ); + } + + return DocumentMigrationResult( + document: migrated, + report: DocumentMigrationReport( + entity: entity, + status: DocumentMigrationStatus.migrated, + documentId: documentId, + fromSchemaVersion: fromVersion, + notes: List.unmodifiable(notes), + ), + ); + } + + Map _taskV0ToV1( + Map document, + List notes, + ) { + final createdAt = _requiredInstant(document, TaskDocumentFields.createdAt); + final status = _requiredLegacyCode( + document, + TaskDocumentFields.status, + _taskStatusCodes, + ); + final isBacklog = status == 'backlog'; + if (isBacklog) { + notes.add( + const DocumentMigrationNote( + code: DocumentMigrationNoteCode.approximatedBacklogEnteredAt, + fieldName: TaskDocumentFields.backlogEnteredAt, + detailCode: DocumentMigrationProvenance.approximatedFromCreatedAt, + ), + ); + } + + return { + ..._commonV1Fields( + id: _requiredString(document, TaskDocumentFields.id), + createdAt: createdAt, + updatedAt: _requiredInstant(document, TaskDocumentFields.updatedAt), + ), + TaskDocumentFields.title: + _requiredString(document, TaskDocumentFields.title), + TaskDocumentFields.projectId: + _requiredString(document, TaskDocumentFields.projectId), + TaskDocumentFields.type: _requiredLegacyCode( + document, + TaskDocumentFields.type, + _taskTypeCodes, + ), + TaskDocumentFields.status: status, + TaskDocumentFields.priority: _nullableLegacyCode( + document, + TaskDocumentFields.priority, + _priorityCodes, + ), + TaskDocumentFields.reward: _requiredLegacyCode( + document, + TaskDocumentFields.reward, + _rewardCodes, + ), + TaskDocumentFields.difficulty: _requiredLegacyCode( + document, + TaskDocumentFields.difficulty, + _difficultyCodes, + ), + TaskDocumentFields.durationMinutes: + _requiredNullableInt(document, TaskDocumentFields.durationMinutes), + TaskDocumentFields.scheduledStart: _nullableInstant( + document, + TaskDocumentFields.scheduledStart, + ), + TaskDocumentFields.scheduledEnd: _nullableInstant( + document, + TaskDocumentFields.scheduledEnd, + ), + TaskDocumentFields.actualStart: + _nullableInstant(document, TaskDocumentFields.actualStart), + TaskDocumentFields.actualEnd: + _nullableInstant(document, TaskDocumentFields.actualEnd), + TaskDocumentFields.completedAt: + _nullableInstant(document, TaskDocumentFields.completedAt), + TaskDocumentFields.parentTaskId: + _requiredNullableString(document, TaskDocumentFields.parentTaskId), + TaskDocumentFields.backlogTags: _requiredLegacyCodeList( + document, + TaskDocumentFields.backlogTags, + _backlogTagCodes, + ), + TaskDocumentFields.reminderOverride: _nullableLegacyCode( + document, + TaskDocumentFields.reminderOverride, + _reminderProfileCodes, + ), + TaskDocumentFields.stats: + _requiredMap(document, TaskDocumentFields.stats), + TaskDocumentFields.backlogEnteredAt: isBacklog ? createdAt : null, + TaskDocumentFields.backlogEnteredAtProvenance: isBacklog + ? DocumentMigrationProvenance.approximatedFromCreatedAt + : null, + }; + } + + Map _projectV0ToV1( + Map document, + List notes, + ) { + final metadata = _metadataInstants(document, notes); + return { + ..._commonV1Fields( + id: _requiredString(document, ProjectDocumentFields.id), + createdAt: metadata.createdAt, + updatedAt: metadata.updatedAt, + ), + ProjectDocumentFields.name: + _requiredString(document, ProjectDocumentFields.name), + ProjectDocumentFields.colorKey: + _requiredString(document, ProjectDocumentFields.colorKey), + ProjectDocumentFields.defaultPriority: _requiredLegacyCode( + document, + ProjectDocumentFields.defaultPriority, + _priorityCodes, + ), + ProjectDocumentFields.defaultReward: _requiredLegacyCode( + document, + ProjectDocumentFields.defaultReward, + _rewardCodes, + ), + ProjectDocumentFields.defaultDifficulty: _requiredLegacyCode( + document, + ProjectDocumentFields.defaultDifficulty, + _difficultyCodes, + ), + ProjectDocumentFields.defaultReminderProfile: _requiredLegacyCode( + document, + ProjectDocumentFields.defaultReminderProfile, + _reminderProfileCodes, + ), + ProjectDocumentFields.defaultDurationMinutes: _requiredNullableInt( + document, + ProjectDocumentFields.defaultDurationMinutes, + ), + ProjectDocumentFields.archivedAt: + _optionalNullableInstant(document, ProjectDocumentFields.archivedAt), + }; + } + + Map _lockedBlockV0ToV1( + Map document, + List notes, + ) { + final metadata = _metadataInstants(document, notes); + return { + ..._commonV1Fields( + id: _requiredString(document, LockedBlockDocumentFields.id), + createdAt: metadata.createdAt, + updatedAt: metadata.updatedAt, + ), + LockedBlockDocumentFields.name: + _requiredString(document, LockedBlockDocumentFields.name), + LockedBlockDocumentFields.startTime: + _requiredWallTime(document, LockedBlockDocumentFields.startTime), + LockedBlockDocumentFields.endTime: + _requiredWallTime(document, LockedBlockDocumentFields.endTime), + LockedBlockDocumentFields.date: + _requiredNullableCivilDate(document, LockedBlockDocumentFields.date), + LockedBlockDocumentFields.recurrence: + _nullableRecurrence(document, LockedBlockDocumentFields.recurrence), + LockedBlockDocumentFields.hiddenByDefault: + _requiredBool(document, LockedBlockDocumentFields.hiddenByDefault), + LockedBlockDocumentFields.projectId: _requiredNullableString( + document, + LockedBlockDocumentFields.projectId, + ), + LockedBlockDocumentFields.archivedAt: _optionalNullableInstant( + document, + LockedBlockDocumentFields.archivedAt, + ), + }; + } + + Map _lockedBlockOverrideV0ToV1( + Map document, + List notes, + ) { + final metadata = _metadataInstants(document, notes); + return { + ..._commonV1Fields( + id: _requiredString(document, LockedBlockOverrideDocumentFields.id), + createdAt: metadata.createdAt, + updatedAt: metadata.updatedAt, + ), + LockedBlockOverrideDocumentFields.lockedBlockId: _requiredNullableString( + document, + LockedBlockOverrideDocumentFields.lockedBlockId, + ), + LockedBlockOverrideDocumentFields.date: _requiredCivilDate( + document, + LockedBlockOverrideDocumentFields.date, + ), + LockedBlockOverrideDocumentFields.type: _requiredLegacyCode( + document, + LockedBlockOverrideDocumentFields.type, + _lockedOverrideTypeCodes, + ), + LockedBlockOverrideDocumentFields.name: _requiredNullableString( + document, + LockedBlockOverrideDocumentFields.name, + ), + LockedBlockOverrideDocumentFields.startTime: _requiredNullableWallTime( + document, + LockedBlockOverrideDocumentFields.startTime, + ), + LockedBlockOverrideDocumentFields.endTime: _requiredNullableWallTime( + document, + LockedBlockOverrideDocumentFields.endTime, + ), + LockedBlockOverrideDocumentFields.hiddenByDefault: _requiredBool( + document, + LockedBlockOverrideDocumentFields.hiddenByDefault, + ), + LockedBlockOverrideDocumentFields.projectId: _requiredNullableString( + document, + LockedBlockOverrideDocumentFields.projectId, + ), + }; + } + + Map _commonV1Fields({ + required String id, + required String createdAt, + required String updatedAt, + }) { + return { + DocumentFields.schemaVersion: v1SchemaVersion, + DocumentFields.id: id, + DocumentFields.ownerId: ownerId, + DocumentFields.revision: 1, + DocumentFields.createdAt: createdAt, + DocumentFields.updatedAt: updatedAt, + }; + } + + _LegacyMetadataInstants _metadataInstants( + Map document, + List notes, + ) { + final createdAt = + _optionalNullableInstant(document, DocumentFields.createdAt); + final updatedAt = + _optionalNullableInstant(document, DocumentFields.updatedAt); + if (createdAt == null || updatedAt == null) { + notes.add( + const DocumentMigrationNote( + code: DocumentMigrationNoteCode.synthesizedMetadataTimestamp, + detailCode: 'migratedAt', + ), + ); + } + final fallback = PersistenceDateTimeConvention.toStoredString(migratedAt); + return _LegacyMetadataInstants( + createdAt: createdAt ?? fallback, + updatedAt: updatedAt ?? createdAt ?? fallback, + ); + } +} + +typedef _V0Migrator = Map Function( + Map document, + List notes, +); + +class _SchemaVersionRead { + const _SchemaVersionRead.version(this.version) : failure = null; + const _SchemaVersionRead.failure(this.failure) : version = 0; + + final int version; + final String? failure; +} + +class _LegacyDocumentException implements Exception { + const _LegacyDocumentException({ + required this.fieldName, + required this.detailCode, + }); + + final String fieldName; + final String detailCode; +} + +class _LegacyMetadataInstants { + const _LegacyMetadataInstants({ + required this.createdAt, + required this.updatedAt, + }); + + final String createdAt; + final String updatedAt; +} + +const _taskTypeCodes = { + 'flexible': 'flexible', + 'inflexible': 'inflexible', + 'critical': 'critical', + 'locked': 'locked', + 'surprise': 'surprise', + 'freeSlot': 'free_slot', + 'free_slot': 'free_slot', +}; + +const _taskStatusCodes = { + 'planned': 'planned', + 'active': 'active', + 'completed': 'completed', + 'missed': 'missed', + 'cancelled': 'cancelled', + 'noLongerRelevant': 'no_longer_relevant', + 'no_longer_relevant': 'no_longer_relevant', + 'backlog': 'backlog', +}; + +const _priorityCodes = { + 'veryLow': 'very_low', + 'very_low': 'very_low', + 'low': 'low', + 'medium': 'medium', + 'high': 'high', + 'veryHigh': 'very_high', + 'very_high': 'very_high', +}; + +const _rewardCodes = { + 'notSet': 'not_set', + 'not_set': 'not_set', + 'veryLow': 'very_low', + 'very_low': 'very_low', + 'low': 'low', + 'medium': 'medium', + 'high': 'high', + 'veryHigh': 'very_high', + 'very_high': 'very_high', +}; + +const _difficultyCodes = { + 'notSet': 'not_set', + 'not_set': 'not_set', + 'veryEasy': 'very_easy', + 'very_easy': 'very_easy', + 'easy': 'easy', + 'medium': 'medium', + 'hard': 'hard', + 'veryHard': 'very_hard', + 'very_hard': 'very_hard', +}; + +const _reminderProfileCodes = { + 'silent': 'silent', + 'gentle': 'gentle', + 'persistent': 'persistent', + 'strict': 'strict', +}; + +const _backlogTagCodes = { + 'wishlist': 'wishlist', +}; + +const _lockedWeekdayCodes = { + 'monday': 'monday', + 'tuesday': 'tuesday', + 'wednesday': 'wednesday', + 'thursday': 'thursday', + 'friday': 'friday', + 'saturday': 'saturday', + 'sunday': 'sunday', +}; + +const _lockedOverrideTypeCodes = { + 'remove': 'remove', + 'replace': 'replace', + 'add': 'add', +}; + +final _explicitOffsetPattern = RegExp(r'(Z|[+-]\d{2}:\d{2})$'); + +_SchemaVersionRead _readSchemaVersion(Map document) { + if (!document.containsKey(DocumentFields.schemaVersion)) { + return const _SchemaVersionRead.version(0); + } + final value = document[DocumentFields.schemaVersion]; + if (value is int) { + return _SchemaVersionRead.version(value); + } + return const _SchemaVersionRead.failure('wrongType'); +} + +String? _documentId(Map document) { + final value = document[DocumentFields.id]; + return value is String ? value : null; +} + +DocumentMigrationResult _failed({ + required DocumentMigrationEntity entity, + required String? documentId, + required int? fromSchemaVersion, + required DocumentMigrationFailureCode failureCode, + String? fieldName, + String? detailCode, +}) { + return DocumentMigrationResult( + document: null, + report: DocumentMigrationReport( + entity: entity, + status: DocumentMigrationStatus.failed, + documentId: documentId, + fromSchemaVersion: fromSchemaVersion, + failureCode: failureCode, + fieldName: fieldName, + detailCode: detailCode, + ), + ); +} + +DocumentMigrationResult _failedFromMapping({ + required DocumentMigrationEntity entity, + required String? documentId, + required int fromSchemaVersion, + required DocumentMigrationFailureCode failureCode, + required DocumentMappingException error, +}) { + return DocumentMigrationResult( + document: null, + report: DocumentMigrationReport( + entity: entity, + status: DocumentMigrationStatus.failed, + documentId: documentId, + fromSchemaVersion: fromSchemaVersion, + failureCode: failureCode, + mappingFailureCode: error.code, + fieldName: error.fieldName, + detailCode: error.detailCode, + ), + ); +} + +String _requiredString(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value is String) { + return value; + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +String? _requiredNullableString( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value == null || value is String) { + return value as String?; + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +int? _requiredNullableInt(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value == null || value is int) { + return value as int?; + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +bool _requiredBool(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value is bool) { + return value; + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +Map _requiredMap( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value is Map) { + return _deepCopyMap(value, fieldName); + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +String _requiredLegacyCode( + Map document, + String fieldName, + Map codes, +) { + final raw = _requiredString(document, fieldName); + final code = codes[raw]; + if (code == null) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'unknownCode:$raw', + ); + } + return code; +} + +String? _nullableLegacyCode( + Map document, + String fieldName, + Map codes, +) { + final raw = _requiredNullableString(document, fieldName); + if (raw == null) { + return null; + } + final code = codes[raw]; + if (code == null) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'unknownCode:$raw', + ); + } + return code; +} + +List _requiredLegacyCodeList( + Map document, + String fieldName, + Map codes, +) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value is! List) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); + } + return value.map((entry) { + if (entry is! String) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); + } + final code = codes[entry]; + if (code == null) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'unknownCode:$entry', + ); + } + return code; + }).toList(growable: false); +} + +String _requiredInstant(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + final stored = _instantToStored(value, fieldName); + if (stored == null) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); + } + return stored; +} + +String? _nullableInstant(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + return _instantToStored(document[fieldName], fieldName); +} + +String? _optionalNullableInstant( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + return null; + } + return _instantToStored(document[fieldName], fieldName); +} + +String? _instantToStored(Object? value, String fieldName) { + if (value == null) { + return null; + } + if (value is DateTime) { + if (!value.isUtc) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'ambiguousDateTime', + ); + } + return PersistenceDateTimeConvention.toStoredString(value); + } + if (value is String) { + if (!_explicitOffsetPattern.hasMatch(value)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'ambiguousDateTime', + ); + } + try { + return PersistenceDateTimeConvention.toStoredString( + DateTime.parse(value), + ); + } on FormatException { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'invalidDateTime', + ); + } + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +String _requiredCivilDate(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = _civilDateToStored(document[fieldName], fieldName); + if (value == null) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); + } + return value; +} + +String? _requiredNullableCivilDate( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + return _civilDateToStored(document[fieldName], fieldName); +} + +String? _civilDateToStored(Object? value, String fieldName) { + if (value == null) { + return null; + } + if (value is String) { + try { + return PersistenceCivilDateConvention.toStoredString( + PersistenceCivilDateConvention.fromStoredString(value), + ); + } on Object { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'invalidCivilDate', + ); + } + } + if (value is DateTime) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'ambiguousCivilDateTime', + ); + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +String _requiredWallTime(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = _wallTimeToStored(document[fieldName], fieldName); + if (value == null) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); + } + return value; +} + +String? _requiredNullableWallTime( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + return _wallTimeToStored(document[fieldName], fieldName); +} + +String? _wallTimeToStored(Object? value, String fieldName) { + if (value == null) { + return null; + } + if (value is String) { + try { + return PersistenceWallTimeConvention.toStoredString( + PersistenceWallTimeConvention.fromStoredString(value), + ); + } on Object { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'invalidWallTime', + ); + } + } + if (value is Map) { + final map = _deepCopyMap(value, fieldName); + final legacyValue = map[ClockTimeDocumentFields.value]; + if (legacyValue is String) { + return _wallTimeToStored(legacyValue, fieldName); + } + final hour = map['hour']; + final minute = map['minute']; + if (hour is int && minute is int) { + try { + return WallTime(hour: hour, minute: minute).toIsoString(); + } on Object { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'invalidWallTime', + ); + } + } + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); +} + +Map? _nullableRecurrence( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'missingField', + ); + } + final value = document[fieldName]; + if (value == null) { + return null; + } + if (value is! Map) { + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'wrongType', + ); + } + final recurrence = _deepCopyMap(value, fieldName); + final weekdays = recurrence[LockedBlockRecurrenceDocumentFields.weekdays]; + if (weekdays is! List) { + throw _LegacyDocumentException( + fieldName: LockedBlockRecurrenceDocumentFields.weekdays, + detailCode: 'wrongType', + ); + } + return { + LockedBlockRecurrenceDocumentFields.type: + recurrence[LockedBlockRecurrenceDocumentFields.type] ?? 'weekly', + LockedBlockRecurrenceDocumentFields.weekdays: weekdays.map((entry) { + if (entry is! String) { + throw const _LegacyDocumentException( + fieldName: LockedBlockRecurrenceDocumentFields.weekdays, + detailCode: 'wrongType', + ); + } + final code = _lockedWeekdayCodes[entry]; + if (code == null) { + throw _LegacyDocumentException( + fieldName: LockedBlockRecurrenceDocumentFields.weekdays, + detailCode: 'unknownCode:$entry', + ); + } + return code; + }).toList(growable: false), + }; +} + +Map _deepCopyDocument(Map document) { + return _deepCopyMap(document, 'document'); +} + +Map _deepCopyMap(Map map, String fieldName) { + return { + for (final entry in map.entries) + _stringKey(entry.key, fieldName): _deepCopyValue(entry.value, fieldName), + }; +} + +String _stringKey(Object? key, String fieldName) { + if (key is String) { + return key; + } + throw _LegacyDocumentException( + fieldName: fieldName, + detailCode: 'nonStringMapKey', + ); +} + +Object? _deepCopyValue(Object? value, String fieldName) { + if (value is Map) { + return _deepCopyMap(value, fieldName); + } + if (value is List) { + return value.map((entry) => _deepCopyValue(entry, fieldName)).toList(); + } + return value; +} diff --git a/test/document_migration_test.dart b/test/document_migration_test.dart new file mode 100644 index 0000000..0a1140a --- /dev/null +++ b/test/document_migration_test.dart @@ -0,0 +1,237 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:adhd_scheduler_core/scheduler_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('V0 to V1 document migrations', () { + final migratedAt = DateTime.utc(2026, 6, 25, 12); + const ownerId = 'owner-1'; + final service = V1DocumentMigrationService( + ownerId: ownerId, + migratedAt: migratedAt, + ); + + test('legacy minimum task migrates with backlog provenance', () { + final legacy = fixture('task_v0_minimum.json'); + + final result = service.migrateTask(legacy); + final document = result.document!; + final task = TaskDocumentMapping.fromDocument(document); + + expect(result.isSuccess, isTrue); + expect(result.shouldWrite, isTrue); + expect(result.report.status, DocumentMigrationStatus.migrated); + expect(result.report.fromSchemaVersion, 0); + expect(result.report.documentId, 'task-v0-minimum'); + expect(result.report.notes.single.code, + DocumentMigrationNoteCode.approximatedBacklogEnteredAt); + expect(legacy.containsKey(DocumentFields.schemaVersion), isFalse); + expect(document, containsPair(DocumentFields.schemaVersion, 1)); + expect(document, containsPair(DocumentFields.ownerId, ownerId)); + expect(document, containsPair(DocumentFields.revision, 1)); + expect(document, containsPair(TaskDocumentFields.reward, 'not_set')); + expect(document, containsPair(TaskDocumentFields.difficulty, 'not_set')); + expect( + document, + containsPair( + TaskDocumentFields.backlogEnteredAt, + '2026-06-20T09:15:00.000Z', + ), + ); + expect( + document, + containsPair( + TaskDocumentFields.backlogEnteredAtProvenance, + DocumentMigrationProvenance.approximatedFromCreatedAt, + ), + ); + expect(task.id, 'task-v0-minimum'); + expect(task.status, TaskStatus.backlog); + expect(task.stats.manuallyPushedCount, 0); + }); + + test('legacy full task migrates enum names to stable codes', () { + final legacy = fixture('task_v0_full.json'); + + final result = service.migrateTask(legacy); + final document = result.document!; + final task = TaskDocumentMapping.fromDocument(document); + + expect(result.isSuccess, isTrue); + expect(document[TaskDocumentFields.type], 'free_slot'); + expect(document[TaskDocumentFields.status], 'no_longer_relevant'); + expect(document[TaskDocumentFields.priority], 'very_high'); + expect(document[TaskDocumentFields.reward], 'very_low'); + expect(document[TaskDocumentFields.difficulty], 'very_hard'); + expect(document[TaskDocumentFields.backlogTags], ['wishlist']); + expect(document[TaskDocumentFields.backlogEnteredAt], isNull); + expect(document[TaskDocumentFields.backlogEnteredAtProvenance], isNull); + expect(task.type, TaskType.freeSlot); + expect(task.status, TaskStatus.noLongerRelevant); + expect(task.priority, PriorityLevel.veryHigh); + expect(task.reward, RewardLevel.veryLow); + expect(task.difficulty, DifficultyLevel.veryHard); + expect(task.stats.totalPushesBeforeCompletion, 13); + }); + + test('legacy project and locked documents migrate to V1 shapes', () { + final projectResult = service.migrateProject(fixture('project_v0.json')); + final blockResult = + service.migrateLockedBlock(fixture('locked_block_v0.json')); + final overrideResult = service.migrateLockedBlockOverride( + fixture('locked_override_v0.json'), + ); + + expect(projectResult.isSuccess, isTrue); + expect( + projectResult.report.notes.single.code, + DocumentMigrationNoteCode.synthesizedMetadataTimestamp, + ); + expect( + projectResult.document![ProjectDocumentFields.defaultPriority], + 'very_high', + ); + expect( + projectResult.document![ProjectDocumentFields.defaultReward], + 'not_set', + ); + expect( + projectResult.document![ProjectDocumentFields.defaultDifficulty], + 'very_easy', + ); + expect( + projectResult.document![DocumentFields.createdAt], + '2026-06-25T12:00:00.000Z', + ); + expect( + ProjectDocumentMapping.fromDocument(projectResult.document!).id, + 'project-v0-home', + ); + + final recurrence = + blockResult.document![LockedBlockDocumentFields.recurrence] + as Map; + expect(blockResult.isSuccess, isTrue); + expect( + blockResult.document![LockedBlockDocumentFields.startTime], '09:00'); + expect(blockResult.document![LockedBlockDocumentFields.endTime], '17:00'); + expect( + recurrence[LockedBlockRecurrenceDocumentFields.type], + 'weekly', + ); + expect( + recurrence[LockedBlockRecurrenceDocumentFields.weekdays], + ['monday', 'thursday'], + ); + expect( + LockedBlockDocumentMapping.fromDocument(blockResult.document!) + .recurrence! + .weekdays, + {LockedWeekday.monday, LockedWeekday.thursday}, + ); + + expect(overrideResult.isSuccess, isTrue); + expect( + overrideResult.document![LockedBlockOverrideDocumentFields.date], + '2026-06-25', + ); + expect( + overrideResult.document![LockedBlockOverrideDocumentFields.startTime], + '10:00', + ); + expect( + overrideResult.document![LockedBlockOverrideDocumentFields.endTime], + '15:00', + ); + expect( + LockedBlockOverrideDocumentMapping.fromDocument( + overrideResult.document!, + ).date, + CivilDate(2026, 6, 25), + ); + }); + + test('already-current V1 documents are validated without rewrite', () { + final migrated = service + .migrateTask( + fixture('task_v0_minimum.json'), + ) + .document!; + + final result = service.migrateTask(migrated); + + expect(result.isSuccess, isTrue); + expect(result.shouldWrite, isFalse); + expect(result.report.status, DocumentMigrationStatus.alreadyCurrent); + expect(result.report.fromSchemaVersion, 1); + expect(result.document, migrated); + }); + + test('repeated migration is idempotent', () { + final first = service.migrateTask(fixture('task_v0_full.json')); + final second = service.migrateTask(first.document!); + + expect(first.isSuccess, isTrue); + expect(second.isSuccess, isTrue); + expect(second.report.status, DocumentMigrationStatus.alreadyCurrent); + expect(second.document, first.document); + }); + + test('partial corruption produces a typed failure report before writes', + () { + final legacy = fixture('task_v0_malformed_missing_title.json'); + + final result = service.migrateTask(legacy); + + expect(result.isSuccess, isFalse); + expect(result.shouldWrite, isFalse); + expect(result.document, isNull); + expect(result.report.status, DocumentMigrationStatus.failed); + expect( + result.report.failureCode, + DocumentMigrationFailureCode.legacyDocumentFailure, + ); + expect(result.report.fieldName, TaskDocumentFields.title); + expect(result.report.detailCode, 'missingField'); + expect(legacy.containsKey(DocumentFields.schemaVersion), isFalse); + }); + + test('ambiguous legacy date-times fail instead of guessing a zone', () { + final result = service.migrateTask( + fixture('task_v0_ambiguous_datetime.json'), + ); + + expect(result.isSuccess, isFalse); + expect(result.shouldWrite, isFalse); + expect(result.document, isNull); + expect( + result.report.failureCode, + DocumentMigrationFailureCode.legacyDocumentFailure, + ); + expect(result.report.fieldName, TaskDocumentFields.createdAt); + expect(result.report.detailCode, 'ambiguousDateTime'); + }); + + test('unsupported future schema versions fail closed', () { + final result = service.migrateTask(fixture('task_future_schema.json')); + + expect(result.isSuccess, isFalse); + expect(result.shouldWrite, isFalse); + expect(result.document, isNull); + expect( + result.report.failureCode, + DocumentMigrationFailureCode.unsupportedSchemaVersion, + ); + expect(result.report.fromSchemaVersion, 99); + expect(result.report.fieldName, DocumentFields.schemaVersion); + }); + }); +} + +Map fixture(String name) { + final file = File('test/fixtures/migration/$name'); + final decoded = jsonDecode(file.readAsStringSync()) as Map; + return Map.from(decoded); +} diff --git a/test/fixtures/migration/locked_block_v0.json b/test/fixtures/migration/locked_block_v0.json new file mode 100644 index 0000000..24ba88e --- /dev/null +++ b/test/fixtures/migration/locked_block_v0.json @@ -0,0 +1,23 @@ +{ + "_id": "locked-work", + "name": "Work", + "startTime": { + "hour": 9, + "minute": 0 + }, + "endTime": { + "hour": 17, + "minute": 0 + }, + "date": null, + "recurrence": { + "weekdays": [ + "monday", + "thursday" + ] + }, + "hiddenByDefault": true, + "projectId": "project-v0-home", + "createdAt": "2026-06-01T12:00:00.000Z", + "updatedAt": "2026-06-02T12:00:00.000Z" +} diff --git a/test/fixtures/migration/locked_override_v0.json b/test/fixtures/migration/locked_override_v0.json new file mode 100644 index 0000000..815677a --- /dev/null +++ b/test/fixtures/migration/locked_override_v0.json @@ -0,0 +1,18 @@ +{ + "_id": "locked-override-short-work", + "lockedBlockId": "locked-work", + "date": "2026-06-25", + "type": "replace", + "name": "Short work", + "startTime": { + "value": "10:00" + }, + "endTime": { + "hour": 15, + "minute": 0 + }, + "hiddenByDefault": true, + "projectId": "project-v0-home", + "createdAt": "2026-06-20T12:00:00.000Z", + "updatedAt": "2026-06-20T12:05:00.000Z" +} diff --git a/test/fixtures/migration/project_v0.json b/test/fixtures/migration/project_v0.json new file mode 100644 index 0000000..66f4530 --- /dev/null +++ b/test/fixtures/migration/project_v0.json @@ -0,0 +1,10 @@ +{ + "_id": "project-v0-home", + "name": "Home", + "colorKey": "home-blue", + "defaultPriority": "veryHigh", + "defaultReward": "notSet", + "defaultDifficulty": "veryEasy", + "defaultReminderProfile": "gentle", + "defaultDurationMinutes": 25 +} diff --git a/test/fixtures/migration/task_future_schema.json b/test/fixtures/migration/task_future_schema.json new file mode 100644 index 0000000..8a1d74a --- /dev/null +++ b/test/fixtures/migration/task_future_schema.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": 99, + "_id": "task-future", + "ownerId": "owner-1", + "revision": 1 +} diff --git a/test/fixtures/migration/task_v0_ambiguous_datetime.json b/test/fixtures/migration/task_v0_ambiguous_datetime.json new file mode 100644 index 0000000..0b61b67 --- /dev/null +++ b/test/fixtures/migration/task_v0_ambiguous_datetime.json @@ -0,0 +1,36 @@ +{ + "_id": "task-v0-ambiguous-datetime", + "title": "Ambiguous datetime task", + "projectId": "inbox", + "type": "flexible", + "status": "backlog", + "priority": null, + "reward": "notSet", + "difficulty": "notSet", + "durationMinutes": null, + "scheduledStart": null, + "scheduledEnd": null, + "actualStart": null, + "actualEnd": null, + "completedAt": null, + "parentTaskId": null, + "backlogTags": [], + "reminderOverride": null, + "createdAt": "2026-06-20T09:15:00", + "updatedAt": "2026-06-20T09:15:00.000Z", + "stats": { + "skippedDuringBurnoutCount": 0, + "manuallyPushedCount": 0, + "autoPushedCount": 0, + "movedToBacklogCount": 0, + "restoredFromBacklogCount": 0, + "missedCount": 0, + "cancelledCount": 0, + "completedLateCount": 0, + "completedDuringLockedHoursCount": 0, + "completedDuringLockedHoursMinutes": 0, + "completedAfterShieldCount": 0, + "completedAfterPushCount": 0, + "totalPushesBeforeCompletion": 0 + } +} diff --git a/test/fixtures/migration/task_v0_full.json b/test/fixtures/migration/task_v0_full.json new file mode 100644 index 0000000..5297671 --- /dev/null +++ b/test/fixtures/migration/task_v0_full.json @@ -0,0 +1,38 @@ +{ + "_id": "task-v0-full", + "title": "Legacy full task", + "projectId": "project-v0-home", + "type": "freeSlot", + "status": "noLongerRelevant", + "priority": "veryHigh", + "reward": "veryLow", + "difficulty": "veryHard", + "durationMinutes": 45, + "scheduledStart": "2026-06-23T16:00:00.000Z", + "scheduledEnd": "2026-06-23T16:45:00.000Z", + "actualStart": "2026-06-23T16:05:00.000Z", + "actualEnd": "2026-06-23T16:35:00.000Z", + "completedAt": "2026-06-23T16:35:00.000Z", + "parentTaskId": "parent-task", + "backlogTags": [ + "wishlist" + ], + "reminderOverride": "persistent", + "createdAt": "2026-06-21T08:00:00.000Z", + "updatedAt": "2026-06-23T17:00:00.000Z", + "stats": { + "skippedDuringBurnoutCount": 1, + "manuallyPushedCount": 2, + "autoPushedCount": 3, + "movedToBacklogCount": 4, + "restoredFromBacklogCount": 5, + "missedCount": 6, + "cancelledCount": 7, + "completedLateCount": 8, + "completedDuringLockedHoursCount": 9, + "completedDuringLockedHoursMinutes": 10, + "completedAfterShieldCount": 11, + "completedAfterPushCount": 12, + "totalPushesBeforeCompletion": 13 + } +} diff --git a/test/fixtures/migration/task_v0_malformed_missing_title.json b/test/fixtures/migration/task_v0_malformed_missing_title.json new file mode 100644 index 0000000..5698346 --- /dev/null +++ b/test/fixtures/migration/task_v0_malformed_missing_title.json @@ -0,0 +1,35 @@ +{ + "_id": "task-v0-missing-title", + "projectId": "inbox", + "type": "flexible", + "status": "backlog", + "priority": null, + "reward": "notSet", + "difficulty": "notSet", + "durationMinutes": null, + "scheduledStart": null, + "scheduledEnd": null, + "actualStart": null, + "actualEnd": null, + "completedAt": null, + "parentTaskId": null, + "backlogTags": [], + "reminderOverride": null, + "createdAt": "2026-06-20T09:15:00.000Z", + "updatedAt": "2026-06-20T09:15:00.000Z", + "stats": { + "skippedDuringBurnoutCount": 0, + "manuallyPushedCount": 0, + "autoPushedCount": 0, + "movedToBacklogCount": 0, + "restoredFromBacklogCount": 0, + "missedCount": 0, + "cancelledCount": 0, + "completedLateCount": 0, + "completedDuringLockedHoursCount": 0, + "completedDuringLockedHoursMinutes": 0, + "completedAfterShieldCount": 0, + "completedAfterPushCount": 0, + "totalPushesBeforeCompletion": 0 + } +} diff --git a/test/fixtures/migration/task_v0_minimum.json b/test/fixtures/migration/task_v0_minimum.json new file mode 100644 index 0000000..adbc4c9 --- /dev/null +++ b/test/fixtures/migration/task_v0_minimum.json @@ -0,0 +1,36 @@ +{ + "_id": "task-v0-minimum", + "title": "Review inbox note", + "projectId": "inbox", + "type": "flexible", + "status": "backlog", + "priority": null, + "reward": "notSet", + "difficulty": "notSet", + "durationMinutes": null, + "scheduledStart": null, + "scheduledEnd": null, + "actualStart": null, + "actualEnd": null, + "completedAt": null, + "parentTaskId": null, + "backlogTags": [], + "reminderOverride": null, + "createdAt": "2026-06-20T09:15:00.000Z", + "updatedAt": "2026-06-20T09:15:00.000Z", + "stats": { + "skippedDuringBurnoutCount": 0, + "manuallyPushedCount": 0, + "autoPushedCount": 0, + "movedToBacklogCount": 0, + "restoredFromBacklogCount": 0, + "missedCount": 0, + "cancelledCount": 0, + "completedLateCount": 0, + "completedDuringLockedHoursCount": 0, + "completedDuringLockedHoursMinutes": 0, + "completedAfterShieldCount": 0, + "completedAfterPushCount": 0, + "totalPushesBeforeCompletion": 0 + } +}