237 lines
8.4 KiB
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);
|
|
}
|