forked from eva/focus-flow
293 lines
8.9 KiB
Dart
293 lines
8.9 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);
|
|
});
|
|
});
|
|
|
|
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,
|
|
);
|
|
}
|