focus-flow/test/application_layer_test.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,
);
}