forked from eva/focus-flow
498 lines
17 KiB
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',
|
|
);
|
|
}
|