focus-flow/packages/scheduler_persistence/test/repo_conformance.dart

498 lines
17 KiB
Dart

// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Tests Repo Conformance behavior for the Scheduler Persistence package.
library;
import 'package:scheduler_core/scheduler_core.dart' as core;
import 'package:scheduler_persistence/persistence.dart';
import 'package:test/test.dart';
/// Factory set used by adapter packages to run the shared repository contract.
final class RepositoryComplianceFactories {
/// Creates a `RepositoryComplianceFactories` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const RepositoryComplianceFactories({
required this.taskRepository,
required this.projectRepository,
required this.lockedBlockRepository,
required this.settingsRepository,
required this.scheduleSnapshotRepository,
});
/// Stores the `taskRepository` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final TaskRepository Function() taskRepository;
/// Stores the `projectRepository` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final ProjectRepository Function() projectRepository;
/// Stores the `lockedBlockRepository` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final LockedBlockRepository Function() lockedBlockRepository;
/// Stores the `settingsRepository` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final SettingsRepository Function() settingsRepository;
/// Stores the `scheduleSnapshotRepository` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final ScheduleSnapshotRepository Function() scheduleSnapshotRepository;
}
/// Runs the cross-adapter behavioral checks expected of repository packages.
void runRepositoryComplianceTests(RepositoryComplianceFactories factories) {
group('task repository compliance', () {
test('supports CRUD, owner isolation, and JSON-cloned returns', () async {
final repo = factories.taskRepository();
final owner = core.OwnerId('owner-a');
final otherOwner = core.OwnerId('owner-b');
final task = _task('task-a');
final inserted = await repo.insert(ownerId: owner, task: task);
expect(inserted.right, core.Revision.initial);
final found =
(await repo.findById(ownerId: owner, taskId: task.id)).right;
expect(found, isNotNull);
expect(found, isNot(same(task)));
expect(found!.id, task.id);
final hiddenFromOtherOwner =
(await repo.findById(ownerId: otherOwner, taskId: task.id)).right;
expect(hiddenFromOtherOwner, isNull);
final updated = task.copyWith(
title: 'Updated task',
updatedAt: _instant(2),
);
final saved = await repo.save(
ownerId: owner,
task: updated,
expectedRevision: inserted.right,
);
expect(saved.right, const core.Revision(2));
final afterSave =
(await repo.findById(ownerId: owner, taskId: task.id)).right;
expect(afterSave!.title, 'Updated task');
final deleted = await repo.delete(
ownerId: owner,
taskId: task.id,
expectedRevision: saved.right,
);
expect(deleted.right, const core.Revision(3));
expect(
(await repo.findById(ownerId: owner, taskId: task.id)).right, isNull);
});
test('enforces duplicate ids and optimistic revision conflicts', () async {
final repo = factories.taskRepository();
final owner = core.OwnerId('owner-a');
final otherOwner = core.OwnerId('owner-b');
final task = _task('task-a');
final inserted = await repo.insert(ownerId: owner, task: task);
expect(
(await repo.insert(ownerId: owner, task: task)).left,
isA<RepositoryDuplicateId>(),
);
expect(
(await repo.save(
ownerId: owner,
task: task.copyWith(title: 'Invalid'),
expectedRevision: const core.Revision(0),
))
.left,
isA<RepositoryInvalidRevision>(),
);
expect(
(await repo.save(
ownerId: otherOwner,
task: task.copyWith(title: 'Other owner'),
expectedRevision: inserted.right,
))
.left,
isA<RepositoryOwnerMismatch>(),
);
final saved = await repo.save(
ownerId: owner,
task: task.copyWith(title: 'Fresh'),
expectedRevision: inserted.right,
);
final stale = (await repo.save(
ownerId: owner,
task: task.copyWith(title: 'Stale'),
expectedRevision: inserted.right,
))
.left;
expect(stale, isA<RepositoryStaleRevision>());
expect(stale.actualRevision, saved.right);
});
test('returns deterministic pages and filtered task queries', () async {
final repo = factories.taskRepository();
final owner = core.OwnerId('owner-a');
await repo.insert(ownerId: owner, task: _task('task-c'));
await repo.insert(
ownerId: owner,
task: _task(
'task-a',
projectId: 'project-filter',
status: core.TaskStatus.completed,
),
);
await repo.insert(
ownerId: owner,
task: _task('task-b', parentTaskId: 'parent-1'),
);
final firstPage = (await repo.findByOwner(
ownerId: owner,
page: const core.PageRequest(limit: 2),
))
.right;
expect(firstPage.items.map((task) => task.id), ['task-a', 'task-b']);
expect(firstPage.nextCursor, isNotNull);
final secondPage = (await repo.findByOwner(
ownerId: owner,
page: core.PageRequest(cursor: firstPage.nextCursor, limit: 2),
))
.right;
expect(secondPage.items.map((task) => task.id), ['task-c']);
expect(secondPage.nextCursor, isNull);
expect(
(await repo.findByProjectId(
ownerId: owner,
projectId: 'project-filter',
))
.right
.items
.map((task) => task.id),
['task-a'],
);
expect(
(await repo.findByParentTaskId(
ownerId: owner,
parentTaskId: 'parent-1',
))
.right
.items
.map((task) => task.id),
['task-b'],
);
expect(
(await repo.findByStatus(
ownerId: owner,
status: core.TaskStatus.completed,
))
.right
.items
.map((task) => task.id),
['task-a'],
);
});
});
group('project repository compliance', () {
test('supports save, archive filters, and stale conflicts', () async {
final repo = factories.projectRepository();
final owner = core.OwnerId('owner-a');
final project = _project('project-a');
final inserted = await repo.insert(ownerId: owner, project: project);
expect(inserted.right, core.Revision.initial);
expect(
(await repo.insert(ownerId: owner, project: project)).left,
isA<RepositoryDuplicateId>(),
);
final found =
(await repo.findById(ownerId: owner, projectId: project.id)).right;
expect(found, isNot(same(project)));
expect(found!.name, project.name);
final saved = await repo.save(
ownerId: owner,
project: project.copyWith(name: 'Updated project'),
expectedRevision: inserted.right,
);
expect(saved.right, const core.Revision(2));
expect(
(await repo.save(
ownerId: owner,
project: project.copyWith(name: 'Stale project'),
expectedRevision: inserted.right,
))
.left,
isA<RepositoryStaleRevision>(),
);
final archived = await repo.archive(
ownerId: owner,
projectId: project.id,
expectedRevision: saved.right,
archivedAtUtc: _instant(3),
);
expect(archived.right, const core.Revision(3));
expect((await repo.findByOwner(ownerId: owner)).right.items, isEmpty);
expect(
(await repo.findByOwner(ownerId: owner, includeArchived: true))
.right
.items
.single
.isArchived,
isTrue,
);
});
});
group('locked block repository compliance', () {
test('supports block archive and override date lookup', () async {
final repo = factories.lockedBlockRepository();
final owner = core.OwnerId('owner-a');
final block = _lockedBlock('block-a');
final override = _override('override-a');
final insertedBlock =
await repo.insertBlock(ownerId: owner, block: block);
expect(insertedBlock.right, core.Revision.initial);
expect(
(await repo.insertBlock(ownerId: owner, block: block)).left,
isA<RepositoryDuplicateId>(),
);
final foundBlock =
(await repo.findBlockById(ownerId: owner, blockId: block.id)).right;
expect(foundBlock, isNot(same(block)));
expect(foundBlock!.name, block.name);
final savedBlock = await repo.saveBlock(
ownerId: owner,
block: block.copyWith(name: 'Updated block', updatedAt: _instant(2)),
expectedRevision: insertedBlock.right,
);
expect(savedBlock.right, const core.Revision(2));
expect(
(await repo.saveBlock(
ownerId: owner,
block: block,
expectedRevision: insertedBlock.right,
))
.left,
isA<RepositoryStaleRevision>(),
);
final archived = await repo.archiveBlock(
ownerId: owner,
blockId: block.id,
expectedRevision: savedBlock.right,
archivedAtUtc: _instant(3),
);
expect(archived.right, const core.Revision(3));
expect(
(await repo.findBlocksByOwner(ownerId: owner)).right.items, isEmpty);
final insertedOverride =
await repo.insertOverride(ownerId: owner, override: override);
expect(insertedOverride.right, core.Revision.initial);
expect(
(await repo.findOverridesForDate(ownerId: owner, date: override.date))
.right
.items
.map((item) => item.id),
[override.id],
);
final savedOverride = await repo.saveOverride(
ownerId: owner,
override: _override('override-a', name: 'Replacement block'),
expectedRevision: insertedOverride.right,
);
expect(savedOverride.right, const core.Revision(2));
});
});
group('settings repository compliance', () {
test('supports owner singleton save and stale conflict', () async {
final repo = factories.settingsRepository();
final owner = core.OwnerId('owner-a');
final settings = _settings(owner.value);
final inserted = await repo.insert(ownerId: owner, settings: settings);
expect(inserted.right, core.Revision.initial);
expect(
(await repo.insert(ownerId: owner, settings: settings)).left,
isA<RepositoryDuplicateId>(),
);
final found = (await repo.findByOwner(ownerId: owner)).right;
expect(found, isNot(same(settings)));
expect(found!.timeZoneId, 'America/Los_Angeles');
final saved = await repo.save(
ownerId: owner,
settings: settings.copyWith(compactModeEnabled: true),
expectedRevision: inserted.right,
);
expect(saved.right, const core.Revision(2));
expect((await repo.findByOwner(ownerId: owner)).right!.compactModeEnabled,
isTrue);
expect(
(await repo.save(
ownerId: owner,
settings: settings,
expectedRevision: inserted.right,
))
.left,
isA<RepositoryStaleRevision>(),
);
});
});
group('schedule snapshot repository compliance', () {
test('supports window lookup, owner isolation, duplicates, and expiry',
() async {
final repo = factories.scheduleSnapshotRepository();
final owner = core.OwnerId('owner-a');
final otherOwner = core.OwnerId('owner-b');
final snapshot = _snapshot('snapshot-a');
final inserted = await repo.insert(ownerId: owner, snapshot: snapshot);
expect(inserted.right, core.Revision.initial);
expect(
(await repo.insert(ownerId: owner, snapshot: snapshot)).left,
isA<RepositoryDuplicateId>(),
);
final found =
(await repo.findById(ownerId: owner, snapshotId: snapshot.id)).right;
expect(found, isNot(same(snapshot)));
expect(found!.tasks.single.id, 'task-a');
expect(
(await repo.findById(ownerId: otherOwner, snapshotId: snapshot.id))
.right,
isNull,
);
final overlapping = (await repo.findInWindow(
ownerId: owner,
window: core.SchedulingWindow(
start: _instant(0, hour: 12),
end: _instant(0, hour: 13),
),
))
.right;
expect(overlapping.items.map((item) => item.id), [snapshot.id]);
final deleted = await repo.deleteExpired(
ownerId: owner,
nowUtc: snapshot.capturedAt.add(const Duration(days: 31)),
);
expect(deleted.right, 1);
expect(
(await repo.findById(ownerId: owner, snapshotId: snapshot.id)).right,
isNull,
);
});
});
}
/// Top-level helper that performs the `_instant` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
DateTime _instant(int day, {int hour = 9}) {
return DateTime.utc(2026, 1, 1 + day, hour);
}
/// Top-level helper that performs the `_task` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
core.Task _task(
String id, {
String projectId = 'project-a',
String? parentTaskId,
core.TaskStatus status = core.TaskStatus.backlog,
}) {
return core.Task(
id: id,
title: 'Task $id',
projectId: projectId,
type: core.TaskType.flexible,
status: status,
durationMinutes: 30,
parentTaskId: parentTaskId,
createdAt: _instant(0),
updatedAt: _instant(0),
);
}
/// Top-level helper that performs the `_project` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
core.ProjectProfile _project(String id) {
return core.ProjectProfile(
id: id,
name: 'Project $id',
colorKey: 'blue',
defaultDurationMinutes: 30,
);
}
/// Top-level helper that performs the `_lockedBlock` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
core.LockedBlock _lockedBlock(String id) {
return core.LockedBlock(
id: id,
name: 'Block $id',
date: core.CivilDate(2026, 1, 1),
startTime: core.WallTime(hour: 10, minute: 0),
endTime: core.WallTime(hour: 11, minute: 0),
createdAt: _instant(0),
updatedAt: _instant(0),
);
}
/// Top-level helper that performs the `_override` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
core.LockedBlockOverride _override(
String id, {
String name = 'Added block',
}) {
return core.LockedBlockOverride.add(
id: id,
date: core.CivilDate(2026, 1, 2),
name: name,
startTime: core.WallTime(hour: 14, minute: 0),
endTime: core.WallTime(hour: 15, minute: 0),
createdAt: _instant(1),
);
}
/// Top-level helper that performs the `_settings` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
core.OwnerSettings _settings(String ownerId) {
return core.OwnerSettings(
ownerId: ownerId,
timeZoneId: 'America/Los_Angeles',
dayStart: core.WallTime(hour: 8, minute: 0),
dayEnd: core.WallTime(hour: 22, minute: 0),
);
}
/// Top-level helper that performs the `_snapshot` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
core.SchedulingStateSnapshot _snapshot(String id) {
return core.SchedulingStateSnapshot(
id: id,
capturedAt: _instant(0),
window: core.SchedulingWindow(
start: _instant(0, hour: 8),
end: _instant(0, hour: 17),
),
tasks: [_task('task-a')],
operationName: 'conformance',
);
}