focus-flow/test/document_migration_test.dart

237 lines
8.4 KiB
Dart

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<String, Object?>;
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<String, Object?> fixture(String name) {
final file = File('test/fixtures/migration/$name');
final decoded = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
return Map<String, Object?>.from(decoded);
}