410 lines
13 KiB
Dart
410 lines
13 KiB
Dart
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
void main() {
|
|
group('Application unit of work', () {
|
|
final now = DateTime.utc(2026, 6, 25, 16);
|
|
final context = appContext(operationId: 'op-1', now: now);
|
|
|
|
test(
|
|
'commits task, activity, aggregate, settings, and operation atomically',
|
|
() async {
|
|
final original = task(
|
|
id: 'task-1',
|
|
status: TaskStatus.planned,
|
|
createdAt: now,
|
|
);
|
|
final unitOfWork = InMemoryApplicationUnitOfWork(
|
|
initialTasks: [original],
|
|
);
|
|
|
|
final result = await unitOfWork.run<String>(
|
|
context: context,
|
|
operationName: 'complete-task',
|
|
action: (repositories) async {
|
|
final loaded = await repositories.tasks.findById(original.id);
|
|
final completed = loaded!.copyWith(
|
|
status: TaskStatus.completed,
|
|
completedAt: now,
|
|
updatedAt: now,
|
|
);
|
|
final activity = activityFor(
|
|
id: 'activity-1',
|
|
operationId: context.operationId,
|
|
task: completed,
|
|
occurredAt: now,
|
|
);
|
|
|
|
await repositories.tasks.save(completed);
|
|
await repositories.taskActivities.save(activity);
|
|
await repositories.projectStatistics.save(
|
|
ProjectStatistics(projectId: completed.projectId)
|
|
.recordCompletion(completedAt: now, durationMinutes: 30),
|
|
);
|
|
await repositories.ownerSettings.save(
|
|
OwnerSettings(
|
|
ownerId: 'owner-1', timeZoneId: 'America/Los_Angeles'),
|
|
);
|
|
|
|
return ApplicationResult.success(completed.id);
|
|
},
|
|
);
|
|
|
|
expect(result.isSuccess, isTrue);
|
|
expect(result.requireValue, 'task-1');
|
|
expect(unitOfWork.currentTasks.single.status, TaskStatus.completed);
|
|
expect(unitOfWork.currentTaskActivities.single.id, 'activity-1');
|
|
expect(
|
|
unitOfWork.currentProjectStatistics.single.completedTaskCount,
|
|
1,
|
|
);
|
|
expect(unitOfWork.currentOwnerSettings.single.ownerId, 'owner-1');
|
|
expect(unitOfWork.currentOperations.single.operationId, 'op-1');
|
|
expect(
|
|
unitOfWork.currentOperations.single.operationName, 'complete-task');
|
|
});
|
|
|
|
test('rolls back staged changes when the action returns a typed failure',
|
|
() async {
|
|
final original = task(
|
|
id: 'task-1',
|
|
status: TaskStatus.planned,
|
|
createdAt: now,
|
|
);
|
|
final unitOfWork = InMemoryApplicationUnitOfWork(
|
|
initialTasks: [original],
|
|
);
|
|
|
|
final result = await unitOfWork.run<void>(
|
|
context: context,
|
|
operationName: 'complete-task',
|
|
action: (repositories) async {
|
|
await repositories.tasks.save(
|
|
original.copyWith(status: TaskStatus.completed, updatedAt: now),
|
|
);
|
|
await repositories.taskActivities.save(
|
|
activityFor(
|
|
id: 'activity-1',
|
|
operationId: context.operationId,
|
|
task: original,
|
|
occurredAt: now,
|
|
),
|
|
);
|
|
|
|
return ApplicationResult.failure(
|
|
const ApplicationFailure(
|
|
code: ApplicationFailureCode.conflict,
|
|
entityId: 'task-1',
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
expect(result.failure!.code, ApplicationFailureCode.conflict);
|
|
expect(unitOfWork.currentTasks.single.status, TaskStatus.planned);
|
|
expect(unitOfWork.currentTaskActivities, isEmpty);
|
|
expect(unitOfWork.currentOperations, isEmpty);
|
|
});
|
|
|
|
test('rolls back staged changes when commit persistence fails', () async {
|
|
final original = task(
|
|
id: 'task-1',
|
|
status: TaskStatus.planned,
|
|
createdAt: now,
|
|
);
|
|
final unitOfWork = InMemoryApplicationUnitOfWork(
|
|
initialTasks: [original],
|
|
)..failNextCommitForTesting();
|
|
|
|
final result = await unitOfWork.run<void>(
|
|
context: context,
|
|
operationName: 'complete-task',
|
|
action: (repositories) async {
|
|
await repositories.tasks.save(
|
|
original.copyWith(status: TaskStatus.completed, updatedAt: now),
|
|
);
|
|
return ApplicationResult.success(null);
|
|
},
|
|
);
|
|
|
|
expect(result.failure!.code, ApplicationFailureCode.persistenceFailure);
|
|
expect(unitOfWork.currentTasks.single.status, TaskStatus.planned);
|
|
expect(unitOfWork.currentOperations, isEmpty);
|
|
});
|
|
|
|
test('rejects duplicate operation IDs without running the action',
|
|
() async {
|
|
final unitOfWork = InMemoryApplicationUnitOfWork();
|
|
var runCount = 0;
|
|
|
|
final first = await unitOfWork.run<String>(
|
|
context: context,
|
|
operationName: 'capture',
|
|
action: (_) async {
|
|
runCount += 1;
|
|
return ApplicationResult.success('done');
|
|
},
|
|
);
|
|
final duplicate = await unitOfWork.run<String>(
|
|
context: context,
|
|
operationName: 'capture',
|
|
action: (_) async {
|
|
runCount += 1;
|
|
return ApplicationResult.success('duplicate');
|
|
},
|
|
);
|
|
|
|
expect(first.requireValue, 'done');
|
|
expect(
|
|
duplicate.failure!.code, ApplicationFailureCode.duplicateOperation);
|
|
expect(duplicate.failure!.entityId, 'op-1');
|
|
expect(runCount, 1);
|
|
expect(unitOfWork.currentOperations, hasLength(1));
|
|
});
|
|
|
|
test('maps domain validation exceptions to stable validation failures',
|
|
() async {
|
|
final unitOfWork = InMemoryApplicationUnitOfWork();
|
|
|
|
final result = await unitOfWork.run<void>(
|
|
context: context,
|
|
operationName: 'bad-task',
|
|
action: (_) async {
|
|
Task(
|
|
id: '',
|
|
title: 'Bad task',
|
|
projectId: 'inbox',
|
|
type: TaskType.flexible,
|
|
status: TaskStatus.backlog,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
);
|
|
return ApplicationResult.success(null);
|
|
},
|
|
);
|
|
|
|
expect(result.failure!.code, ApplicationFailureCode.validation);
|
|
expect(
|
|
result.failure!.detailCode, DomainValidationCode.blankStableId.name);
|
|
expect(unitOfWork.currentOperations, isEmpty);
|
|
});
|
|
|
|
test(
|
|
'repository contracts expose activity settings notice and operation '
|
|
'conflicts', () async {
|
|
final unitOfWork = InMemoryApplicationUnitOfWork(
|
|
initialOwnerSettings: [
|
|
OwnerSettings(ownerId: 'owner-1', timeZoneId: 'America/Los_Angeles'),
|
|
],
|
|
);
|
|
|
|
final result = await unitOfWork.run<void>(
|
|
context: appContext(operationId: 'repo-contracts', now: now),
|
|
operationName: 'repo-contracts',
|
|
action: (repositories) async {
|
|
final activity = activityFor(
|
|
id: 'activity-1',
|
|
operationId: 'manual-op',
|
|
task: task(
|
|
id: 'task-1',
|
|
status: TaskStatus.completed,
|
|
createdAt: now,
|
|
),
|
|
occurredAt: now,
|
|
);
|
|
final appended = await repositories.taskActivities.append(
|
|
activity: activity,
|
|
ownerId: 'owner-1',
|
|
);
|
|
final duplicateActivity = await repositories.taskActivities.append(
|
|
activity: activity,
|
|
ownerId: 'owner-1',
|
|
);
|
|
final activities = await repositories.taskActivities.findByOwner(
|
|
ownerId: 'owner-1',
|
|
);
|
|
|
|
final settingsRecord =
|
|
await repositories.ownerSettings.findRecordByOwnerId('owner-1');
|
|
final staleSettings = await repositories.ownerSettings.saveIfRevision(
|
|
settings: settingsRecord!.value.copyWith(compactModeEnabled: true),
|
|
expectedRevision: 99,
|
|
);
|
|
final savedSettings = await repositories.ownerSettings.saveIfRevision(
|
|
settings: settingsRecord.value.copyWith(compactModeEnabled: true),
|
|
expectedRevision: settingsRecord.revision,
|
|
);
|
|
|
|
final acknowledgement = NoticeAcknowledgementRecord(
|
|
noticeId: 'snapshot:0',
|
|
ownerId: 'owner-1',
|
|
acknowledgedAt: now,
|
|
);
|
|
final insertedNotice =
|
|
await repositories.noticeAcknowledgements.insert(acknowledgement);
|
|
final duplicateNotice =
|
|
await repositories.noticeAcknowledgements.insert(acknowledgement);
|
|
final notices = await repositories.noticeAcknowledgements.findByOwner(
|
|
ownerId: 'owner-1',
|
|
);
|
|
|
|
final operation = ApplicationOperationRecord(
|
|
operationId: 'manual-op',
|
|
ownerId: 'owner-1',
|
|
operationName: 'manual',
|
|
committedAt: now,
|
|
);
|
|
final insertedOperation =
|
|
await repositories.operations.insertIfAbsent(operation);
|
|
final duplicateOperation =
|
|
await repositories.operations.insertIfAbsent(operation);
|
|
final idempotentOperation =
|
|
await repositories.operations.findByIdempotencyKey(
|
|
ownerId: 'owner-1',
|
|
operationId: 'manual-op',
|
|
);
|
|
|
|
expect(appended.isSuccess, isTrue);
|
|
expect(
|
|
duplicateActivity.failure!.code,
|
|
RepositoryFailureCode.duplicateId,
|
|
);
|
|
expect(activities.items.map((activity) => activity.id), [
|
|
'activity-1',
|
|
]);
|
|
expect(
|
|
staleSettings.failure!.code,
|
|
RepositoryFailureCode.staleRevision,
|
|
);
|
|
expect(savedSettings.revision, 2);
|
|
expect(insertedNotice.isSuccess, isTrue);
|
|
expect(
|
|
duplicateNotice.failure!.code,
|
|
RepositoryFailureCode.duplicateId,
|
|
);
|
|
expect(notices.items.map((notice) => notice.noticeId), [
|
|
'snapshot:0',
|
|
]);
|
|
expect(insertedOperation.isSuccess, isTrue);
|
|
expect(
|
|
duplicateOperation.failure!.code,
|
|
RepositoryFailureCode.duplicateOperation,
|
|
);
|
|
expect(idempotentOperation!.operationName, 'manual');
|
|
return ApplicationResult.success(null);
|
|
},
|
|
);
|
|
|
|
expect(result.isSuccess, isTrue);
|
|
expect(unitOfWork.currentTaskActivities.single.id, 'activity-1');
|
|
expect(unitOfWork.currentOwnerSettings.single.compactModeEnabled, isTrue);
|
|
expect(unitOfWork.currentNoticeAcknowledgements.single.noticeId,
|
|
'snapshot:0');
|
|
expect(
|
|
unitOfWork.currentOperations.map((operation) => operation.operationId),
|
|
containsAll(['manual-op', 'repo-contracts']),
|
|
);
|
|
});
|
|
});
|
|
|
|
group('Application scheduling loader', () {
|
|
test('centralizes repository loading into scheduler input', () async {
|
|
final now = DateTime.utc(2026, 6, 25, 8);
|
|
final inWindow = task(
|
|
id: 'in-window',
|
|
status: TaskStatus.planned,
|
|
createdAt: now,
|
|
scheduledStart: DateTime.utc(2026, 6, 25, 10),
|
|
scheduledEnd: DateTime.utc(2026, 6, 25, 10, 30),
|
|
);
|
|
final outsideWindow = task(
|
|
id: 'outside-window',
|
|
status: TaskStatus.planned,
|
|
createdAt: now,
|
|
scheduledStart: DateTime.utc(2026, 6, 26, 10),
|
|
scheduledEnd: DateTime.utc(2026, 6, 26, 10, 30),
|
|
);
|
|
final unitOfWork = InMemoryApplicationUnitOfWork(
|
|
initialTasks: [inWindow, outsideWindow],
|
|
);
|
|
late SchedulingInput input;
|
|
|
|
final result = await unitOfWork.run<void>(
|
|
context: appContext(operationId: 'load', now: now),
|
|
operationName: 'load-input',
|
|
action: (repositories) async {
|
|
input = await ApplicationSchedulingLoader(repositories)
|
|
.loadInputForWindow(
|
|
window: SchedulingWindow(
|
|
start: DateTime.utc(2026, 6, 25, 0),
|
|
end: DateTime.utc(2026, 6, 26, 0),
|
|
),
|
|
lockedIntervals: [
|
|
TimeInterval(
|
|
start: DateTime.utc(2026, 6, 25, 12),
|
|
end: DateTime.utc(2026, 6, 25, 13),
|
|
),
|
|
],
|
|
);
|
|
return ApplicationResult.success(null);
|
|
},
|
|
);
|
|
|
|
expect(result.isSuccess, isTrue);
|
|
expect(input.tasks.map((task) => task.id), ['in-window']);
|
|
expect(input.lockedIntervals, hasLength(1));
|
|
});
|
|
});
|
|
}
|
|
|
|
ApplicationOperationContext appContext({
|
|
required String operationId,
|
|
required DateTime now,
|
|
}) {
|
|
return ApplicationOperationContext.start(
|
|
operationId: operationId,
|
|
ownerTimeZone: OwnerTimeZoneContext(
|
|
ownerId: 'owner-1',
|
|
timeZoneId: 'America/Los_Angeles',
|
|
),
|
|
clock: FixedClock(now),
|
|
idGenerator: SequentialIdGenerator(prefix: 'generated'),
|
|
);
|
|
}
|
|
|
|
Task task({
|
|
required String id,
|
|
required TaskStatus status,
|
|
required DateTime createdAt,
|
|
DateTime? scheduledStart,
|
|
DateTime? scheduledEnd,
|
|
}) {
|
|
return Task(
|
|
id: id,
|
|
title: 'Task $id',
|
|
projectId: 'inbox',
|
|
type: TaskType.flexible,
|
|
status: status,
|
|
durationMinutes: 30,
|
|
scheduledStart: scheduledStart,
|
|
scheduledEnd: scheduledEnd,
|
|
createdAt: createdAt,
|
|
updatedAt: createdAt,
|
|
);
|
|
}
|
|
|
|
TaskActivity activityFor({
|
|
required String id,
|
|
required String operationId,
|
|
required Task task,
|
|
required DateTime occurredAt,
|
|
}) {
|
|
return TaskActivity(
|
|
id: id,
|
|
operationId: operationId,
|
|
code: TaskActivityCode.completed,
|
|
taskId: task.id,
|
|
projectId: task.projectId,
|
|
occurredAt: occurredAt,
|
|
);
|
|
}
|