forked from eva/focus-flow
feat(data): expand repository contracts
This commit is contained in:
parent
55efa98182
commit
4269f4d9ce
10 changed files with 2332 additions and 74 deletions
|
|
@ -221,7 +221,7 @@ Verification:
|
|||
|
||||
- `dart format lib test`: passed, 0 files changed after final format
|
||||
- `dart analyze`: passed, no issues found
|
||||
- `dart test`: passed, 289 tests
|
||||
- `dart test`: passed, 293 tests
|
||||
- `git diff --check`: passed
|
||||
|
||||
BREAKPOINT: Stop here. Confirm `high` mode before expanding repository/query
|
||||
|
|
@ -231,6 +231,8 @@ contracts and index specifications.
|
|||
|
||||
Recommended Codex level: high
|
||||
|
||||
Status: Complete
|
||||
|
||||
Tasks:
|
||||
|
||||
Expand repository interfaces and in-memory implementations to support:
|
||||
|
|
@ -274,6 +276,40 @@ Acceptance criteria:
|
|||
- Existing in-memory tests are migrated and pass.
|
||||
- Interfaces remain persistence-client-independent.
|
||||
|
||||
Completed implementation:
|
||||
|
||||
- Added adapter-neutral repository mutation/result contracts:
|
||||
`RepositoryFailureCode`, `RepositoryFailure`, `RepositoryMutationResult`,
|
||||
`RepositoryRecord`, `RepositoryPageRequest`, `RepositoryPage`, and
|
||||
`BacklogCandidateQuery`.
|
||||
- Expanded task repository contracts and in-memory implementations for owner,
|
||||
project, parent, backlog-candidate, local-day/window, record/revision,
|
||||
insert, and compare-and-set save behavior.
|
||||
- Expanded project and locked-time repositories with owner-scoped paged queries,
|
||||
revision records, compare-and-set saves, duplicate insert failures, archive
|
||||
behavior, and date-scoped locked override queries.
|
||||
- Expanded application-layer repositories for task activity append/query,
|
||||
project statistics/settings revision saves, notice acknowledgement insert and
|
||||
paging, and idempotent operation lookup/insert.
|
||||
- Updated the in-memory application unit of work to stage/copy owner and
|
||||
revision metadata across transactions.
|
||||
- Shifted Today, project-default, recovery, and planning-state loading to
|
||||
owner/date-scoped repository queries where those paths do not need wider task
|
||||
family context.
|
||||
- Added reusable core repository conformance tests covering query semantics,
|
||||
deterministic pagination, archive behavior, optimistic revisions, duplicate
|
||||
IDs, and immutable result pages.
|
||||
- Added application unit-of-work contract tests for activity append conflicts,
|
||||
owner settings stale revisions, notice duplicate inserts, and duplicate
|
||||
operation IDs.
|
||||
|
||||
Verification:
|
||||
|
||||
- `dart format lib test`: passed, 0 files changed after final format
|
||||
- `dart analyze`: passed, no issues found
|
||||
- `dart test`: passed, 289 tests
|
||||
- `git diff --check`: passed
|
||||
|
||||
## Chunk 15.5 — Index and data-integrity contract tests
|
||||
|
||||
Recommended Codex level: high
|
||||
|
|
|
|||
|
|
@ -370,6 +370,23 @@ Chunk 15.3 intentionally changed the public API:
|
|||
- Legacy backlog tasks now carry explicit provenance when `backlogEnteredAt` is
|
||||
approximated from `createdAt`.
|
||||
|
||||
Chunk 15.4 intentionally changed the public API:
|
||||
|
||||
- Added adapter-neutral repository conflict/paging/record contracts:
|
||||
`RepositoryFailureCode`, `RepositoryFailure`, `RepositoryMutationResult`,
|
||||
`RepositoryRecord`, `RepositoryPageRequest`, `RepositoryPage`, and
|
||||
`BacklogCandidateQuery`.
|
||||
- Expanded `TaskRepository` with owner, project, parent, backlog-candidate,
|
||||
local-day/window, record/revision, insert, and compare-and-set save methods.
|
||||
- Expanded `ProjectRepository` and `LockedBlockRepository` with owner-scoped
|
||||
paged queries, revision records, compare-and-set saves, duplicate insert
|
||||
failures, archive methods, and date-scoped locked override lookup.
|
||||
- Expanded application-layer repository interfaces for task activity owner
|
||||
queries/appends, project statistics/settings revision saves, notice
|
||||
acknowledgement insert/paging, and idempotent operation lookup/insert.
|
||||
- In-memory unit-of-work repositories now track owner/revision metadata in the
|
||||
staged transaction state.
|
||||
|
||||
## Repository contracts
|
||||
|
||||
The current repository surface is pure Dart and in-memory only:
|
||||
|
|
|
|||
|
|
@ -215,7 +215,6 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
action: (repositories) async {
|
||||
final beforeTasks = await repositories.tasks.findAll();
|
||||
final result = quickCaptureService.capture(
|
||||
QuickCaptureRequest(
|
||||
id: context.idGenerator.nextId(),
|
||||
|
|
@ -243,8 +242,8 @@ class V1ApplicationCommandUseCases {
|
|||
repositories: repositories,
|
||||
commandCode: commandCode,
|
||||
context: context,
|
||||
beforeTasks: beforeTasks,
|
||||
afterTasks: [...beforeTasks, result.task],
|
||||
beforeTasks: const <Task>[],
|
||||
afterTasks: [result.task],
|
||||
activities: const <TaskActivity>[],
|
||||
readHint: ApplicationCommandReadHint(
|
||||
affectedTaskIds: [result.task.id],
|
||||
|
|
@ -334,9 +333,14 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
action: (repositories) async {
|
||||
final state =
|
||||
await _loadPlanningState(repositories, context, localDate);
|
||||
final task = _taskById(state.tasks, taskId);
|
||||
var state = await _loadPlanningState(repositories, context, localDate);
|
||||
var task = _taskById(state.tasks, taskId);
|
||||
if (task == null) {
|
||||
task = await repositories.tasks.findById(taskId);
|
||||
if (task != null) {
|
||||
state = state.withTask(task);
|
||||
}
|
||||
}
|
||||
if (task == null) {
|
||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||
}
|
||||
|
|
@ -432,8 +436,7 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
action: (repositories) async {
|
||||
final tasks = await repositories.tasks.findAll();
|
||||
final task = _taskById(tasks, taskId);
|
||||
final task = await repositories.tasks.findById(taskId);
|
||||
if (task == null) {
|
||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||
}
|
||||
|
|
@ -457,13 +460,12 @@ class V1ApplicationCommandUseCases {
|
|||
detailCode: TaskTransitionOutcomeCode.invalidState.name,
|
||||
);
|
||||
}
|
||||
final afterTasks = _replaceTask(tasks, actionResult.task);
|
||||
return _persist(
|
||||
repositories: repositories,
|
||||
commandCode: commandCode,
|
||||
context: context,
|
||||
beforeTasks: tasks,
|
||||
afterTasks: afterTasks,
|
||||
beforeTasks: [task],
|
||||
afterTasks: [actionResult.task],
|
||||
activities: actionResult.activities,
|
||||
readHint: ApplicationCommandReadHint(
|
||||
affectedTaskIds: [taskId],
|
||||
|
|
@ -488,9 +490,14 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
action: (repositories) async {
|
||||
final state =
|
||||
await _loadPlanningState(repositories, context, localDate);
|
||||
final task = _taskById(state.tasks, taskId);
|
||||
var state = await _loadPlanningState(repositories, context, localDate);
|
||||
var task = _taskById(state.tasks, taskId);
|
||||
if (task == null) {
|
||||
task = await repositories.tasks.findById(taskId);
|
||||
if (task != null) {
|
||||
state = state.withTask(task);
|
||||
}
|
||||
}
|
||||
if (task == null) {
|
||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||
}
|
||||
|
|
@ -548,9 +555,14 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
action: (repositories) async {
|
||||
final state =
|
||||
await _loadPlanningState(repositories, context, localDate);
|
||||
final task = _taskById(state.tasks, taskId);
|
||||
var state = await _loadPlanningState(repositories, context, localDate);
|
||||
var task = _taskById(state.tasks, taskId);
|
||||
if (task == null) {
|
||||
task = await repositories.tasks.findById(taskId);
|
||||
if (task != null) {
|
||||
state = state.withTask(task);
|
||||
}
|
||||
}
|
||||
if (task == null) {
|
||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||
}
|
||||
|
|
@ -664,7 +676,6 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
action: (repositories) async {
|
||||
final tasks = await repositories.tasks.findAll();
|
||||
final freeSlot = freeSlotService.create(
|
||||
id: context.idGenerator.nextId(),
|
||||
title: title,
|
||||
|
|
@ -678,8 +689,8 @@ class V1ApplicationCommandUseCases {
|
|||
repositories: repositories,
|
||||
commandCode: commandCode,
|
||||
context: context,
|
||||
beforeTasks: tasks,
|
||||
afterTasks: [...tasks, freeSlot],
|
||||
beforeTasks: const <Task>[],
|
||||
afterTasks: [freeSlot],
|
||||
activities: const <TaskActivity>[],
|
||||
readHint: ApplicationCommandReadHint(
|
||||
localDate: localDate,
|
||||
|
|
@ -706,8 +717,7 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
action: (repositories) async {
|
||||
final tasks = await repositories.tasks.findAll();
|
||||
final task = _taskById(tasks, taskId);
|
||||
final task = await repositories.tasks.findById(taskId);
|
||||
if (task == null) {
|
||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||
}
|
||||
|
|
@ -727,8 +737,8 @@ class V1ApplicationCommandUseCases {
|
|||
repositories: repositories,
|
||||
commandCode: commandCode,
|
||||
context: context,
|
||||
beforeTasks: tasks,
|
||||
afterTasks: _replaceTask(tasks, updated),
|
||||
beforeTasks: [task],
|
||||
afterTasks: [updated],
|
||||
activities: const <TaskActivity>[],
|
||||
readHint: ApplicationCommandReadHint(
|
||||
localDate: localDate,
|
||||
|
|
@ -751,8 +761,7 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
action: (repositories) async {
|
||||
final tasks = await repositories.tasks.findAll();
|
||||
final task = _taskById(tasks, taskId);
|
||||
final task = await repositories.tasks.findById(taskId);
|
||||
if (task == null) {
|
||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||
}
|
||||
|
|
@ -776,8 +785,8 @@ class V1ApplicationCommandUseCases {
|
|||
repositories: repositories,
|
||||
commandCode: commandCode,
|
||||
context: context,
|
||||
beforeTasks: tasks,
|
||||
afterTasks: _replaceTask(tasks, removed),
|
||||
beforeTasks: [task],
|
||||
afterTasks: [removed],
|
||||
activities: const <TaskActivity>[],
|
||||
readHint: ApplicationCommandReadHint(
|
||||
localDate: localDate,
|
||||
|
|
@ -934,9 +943,14 @@ class V1ApplicationCommandUseCases {
|
|||
context: context,
|
||||
operationName: commandCode.name,
|
||||
action: (repositories) async {
|
||||
final state =
|
||||
await _loadPlanningState(repositories, context, localDate);
|
||||
final task = _taskById(state.tasks, taskId);
|
||||
var state = await _loadPlanningState(repositories, context, localDate);
|
||||
var task = _taskById(state.tasks, taskId);
|
||||
if (task == null) {
|
||||
task = await repositories.tasks.findById(taskId);
|
||||
if (task != null) {
|
||||
state = state.withTask(task);
|
||||
}
|
||||
}
|
||||
if (task == null) {
|
||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||
}
|
||||
|
|
@ -1045,10 +1059,22 @@ class V1ApplicationCommandUseCases {
|
|||
timeZoneId: settings.timeZoneId,
|
||||
);
|
||||
final window = SchedulingWindow(start: dayStart, end: dayEnd);
|
||||
final tasks = await repositories.tasks.findAll();
|
||||
final tasks = (await repositories.tasks.findForLocalDay(
|
||||
ownerId: ownerId,
|
||||
localDate: localDate,
|
||||
window: window,
|
||||
))
|
||||
.items;
|
||||
final lockedExpansion = expandLockedBlocksForDay(
|
||||
blocks: await repositories.lockedBlocks.findAllBlocks(),
|
||||
overrides: await repositories.lockedBlocks.findAllOverrides(),
|
||||
blocks: (await repositories.lockedBlocks.findBlocksByOwner(
|
||||
ownerId: ownerId,
|
||||
))
|
||||
.items,
|
||||
overrides: (await repositories.lockedBlocks.findOverridesForDate(
|
||||
ownerId: ownerId,
|
||||
date: localDate,
|
||||
))
|
||||
.items,
|
||||
date: localDate,
|
||||
timeZoneId: settings.timeZoneId,
|
||||
timeZoneResolver: timeZoneResolver,
|
||||
|
|
@ -1168,6 +1194,17 @@ class _PlanningState {
|
|||
lockedIntervals: lockedIntervals,
|
||||
);
|
||||
}
|
||||
|
||||
_PlanningState withTask(Task task) {
|
||||
if (tasks.any((existing) => existing.id == task.id)) {
|
||||
return this;
|
||||
}
|
||||
return _PlanningState(
|
||||
tasks: [task, ...tasks],
|
||||
window: window,
|
||||
lockedIntervals: lockedIntervals,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ApplicationResult<ApplicationCommandResult> _failure(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -312,7 +312,11 @@ class V1ApplicationManagementUseCases {
|
|||
) {
|
||||
return applicationStore.read(
|
||||
action: (repositories) async {
|
||||
final projects = await repositories.projects.findAll();
|
||||
final projects = (await repositories.projects.findByOwner(
|
||||
ownerId: request.context.ownerTimeZone.ownerId,
|
||||
includeArchived: request.includeArchived,
|
||||
))
|
||||
.items;
|
||||
final resolutions = <ProjectDefaultResolution>[];
|
||||
final sortedProjects = [...projects]
|
||||
..sort((a, b) => a.name.compareTo(b.name));
|
||||
|
|
|
|||
|
|
@ -146,8 +146,15 @@ class V1AppOpenRecoveryUseCases {
|
|||
sourceWindow: sourceWindow,
|
||||
targetWindow: targetWindow,
|
||||
);
|
||||
final blocks = await repositories.lockedBlocks.findAllBlocks();
|
||||
final overrides = await repositories.lockedBlocks.findAllOverrides();
|
||||
final blocks = (await repositories.lockedBlocks.findBlocksByOwner(
|
||||
ownerId: context.ownerTimeZone.ownerId,
|
||||
))
|
||||
.items;
|
||||
final overrides = (await repositories.lockedBlocks.findOverridesForDate(
|
||||
ownerId: context.ownerTimeZone.ownerId,
|
||||
date: targetDate,
|
||||
))
|
||||
.items;
|
||||
final lockedExpansion = expandLockedBlocksForDay(
|
||||
blocks: blocks,
|
||||
overrides: overrides,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -327,10 +327,26 @@ class GetTodayStateQuery {
|
|||
);
|
||||
final window = SchedulingWindow(start: dayStart, end: dayEnd);
|
||||
|
||||
final projects = await repositories.projects.findAll();
|
||||
final tasks = await repositories.tasks.findScheduledInWindow(window);
|
||||
final blocks = await repositories.lockedBlocks.findAllBlocks();
|
||||
final overrides = await repositories.lockedBlocks.findAllOverrides();
|
||||
final projects = (await repositories.projects.findByOwner(
|
||||
ownerId: ownerId,
|
||||
includeArchived: true,
|
||||
))
|
||||
.items;
|
||||
final tasks = (await repositories.tasks.findForLocalDay(
|
||||
ownerId: ownerId,
|
||||
localDate: request.date,
|
||||
window: window,
|
||||
))
|
||||
.items;
|
||||
final blocks = (await repositories.lockedBlocks.findBlocksByOwner(
|
||||
ownerId: ownerId,
|
||||
))
|
||||
.items;
|
||||
final overrides = (await repositories.lockedBlocks.findOverridesForDate(
|
||||
ownerId: ownerId,
|
||||
date: request.date,
|
||||
))
|
||||
.items;
|
||||
final snapshots =
|
||||
await repositories.schedulingSnapshots.findInWindow(window);
|
||||
final acknowledgedNoticeIds =
|
||||
|
|
|
|||
|
|
@ -188,6 +188,123 @@ void main() {
|
|||
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', () {
|
||||
|
|
|
|||
|
|
@ -122,6 +122,214 @@ void main() {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
runCoreRepositoryConformance(
|
||||
createTaskRepository: () => InMemoryTaskRepository(
|
||||
[
|
||||
task(
|
||||
id: 'backlog-a',
|
||||
title: 'Backlog A',
|
||||
status: TaskStatus.backlog,
|
||||
createdAt: DateTime.utc(2026, 6, 20),
|
||||
),
|
||||
task(
|
||||
id: 'backlog-b',
|
||||
title: 'Backlog B',
|
||||
status: TaskStatus.backlog,
|
||||
createdAt: DateTime.utc(2026, 6, 21),
|
||||
).copyWith(projectId: 'home', parentTaskId: 'parent'),
|
||||
task(
|
||||
id: 'scheduled',
|
||||
title: 'Scheduled',
|
||||
status: TaskStatus.planned,
|
||||
createdAt: DateTime.utc(2026, 6, 21),
|
||||
scheduledStart: DateTime.utc(2026, 6, 22, 10),
|
||||
scheduledEnd: DateTime.utc(2026, 6, 22, 10, 30),
|
||||
).copyWith(projectId: 'home'),
|
||||
],
|
||||
),
|
||||
createProjectRepository: () => InMemoryProjectRepository([
|
||||
ProjectProfile(id: 'home', name: 'Home', colorKey: 'home-blue'),
|
||||
ProjectProfile(id: 'work', name: 'Work', colorKey: 'work-green'),
|
||||
]),
|
||||
createLockedBlockRepository: () => InMemoryLockedBlockRepository(
|
||||
initialBlocks: [
|
||||
LockedBlock(
|
||||
id: 'work',
|
||||
name: 'Work',
|
||||
startTime: ClockTime(hour: 9, minute: 0),
|
||||
endTime: ClockTime(hour: 17, minute: 0),
|
||||
recurrence: LockedBlockRecurrence.weekly(
|
||||
weekdays: {LockedWeekday.monday},
|
||||
),
|
||||
createdAt: DateTime.utc(2026, 6, 20),
|
||||
updatedAt: DateTime.utc(2026, 6, 20),
|
||||
),
|
||||
],
|
||||
initialOverrides: [
|
||||
LockedBlockOverride.remove(
|
||||
id: 'holiday',
|
||||
lockedBlockId: 'work',
|
||||
date: CivilDate(2026, 6, 22),
|
||||
createdAt: DateTime.utc(2026, 6, 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void runCoreRepositoryConformance({
|
||||
required TaskRepository Function() createTaskRepository,
|
||||
required ProjectRepository Function() createProjectRepository,
|
||||
required LockedBlockRepository Function() createLockedBlockRepository,
|
||||
}) {
|
||||
group('Core repository conformance', () {
|
||||
const ownerId = 'owner-1';
|
||||
|
||||
test('task queries are owner-scoped, ordered, paged, and revisioned',
|
||||
() async {
|
||||
final repository = createTaskRepository();
|
||||
final firstPage = await repository.findByOwner(
|
||||
ownerId: ownerId,
|
||||
page: const RepositoryPageRequest(limit: 2),
|
||||
);
|
||||
final secondPage = await repository.findByOwner(
|
||||
ownerId: ownerId,
|
||||
page: RepositoryPageRequest(cursor: firstPage.nextCursor, limit: 2),
|
||||
);
|
||||
final homeTasks = await repository.findByProjectId(
|
||||
ownerId: ownerId,
|
||||
projectId: 'home',
|
||||
);
|
||||
final children = await repository.findByParentTaskId(
|
||||
ownerId: ownerId,
|
||||
parentTaskId: 'parent',
|
||||
);
|
||||
final backlog = await repository.findBacklogCandidates(
|
||||
const BacklogCandidateQuery(ownerId: ownerId, projectId: 'home'),
|
||||
);
|
||||
final localDay = await repository.findForLocalDay(
|
||||
ownerId: ownerId,
|
||||
localDate: CivilDate(2026, 6, 22),
|
||||
window: SchedulingWindow(
|
||||
start: DateTime.utc(2026, 6, 22),
|
||||
end: DateTime.utc(2026, 6, 23),
|
||||
),
|
||||
);
|
||||
final record = await repository.findRecordById('scheduled');
|
||||
final updated = record!.value.copyWith(
|
||||
title: 'Updated scheduled',
|
||||
updatedAt: DateTime.utc(2026, 6, 22, 12),
|
||||
);
|
||||
|
||||
expect(firstPage.items.map((task) => task.id), [
|
||||
'backlog-a',
|
||||
'backlog-b',
|
||||
]);
|
||||
expect(firstPage.hasMore, isTrue);
|
||||
expect(secondPage.items.map((task) => task.id), ['scheduled']);
|
||||
expect(homeTasks.items.map((task) => task.id), [
|
||||
'backlog-b',
|
||||
'scheduled',
|
||||
]);
|
||||
expect(children.items.map((task) => task.id), ['backlog-b']);
|
||||
expect(backlog.items.map((task) => task.id), ['backlog-b']);
|
||||
expect(localDay.items.map((task) => task.id), ['scheduled']);
|
||||
expect(record.ownerId, ownerId);
|
||||
expect(record.revision, 1);
|
||||
|
||||
final stale = await repository.saveIfRevision(
|
||||
task: updated,
|
||||
ownerId: ownerId,
|
||||
expectedRevision: 99,
|
||||
);
|
||||
expect(stale.failure!.code, RepositoryFailureCode.staleRevision);
|
||||
expect(stale.failure!.actualRevision, 1);
|
||||
|
||||
final saved = await repository.saveIfRevision(
|
||||
task: updated,
|
||||
ownerId: ownerId,
|
||||
expectedRevision: 1,
|
||||
);
|
||||
expect(saved.isSuccess, isTrue);
|
||||
expect(saved.revision, 2);
|
||||
expect((await repository.findRecordById('scheduled'))!.revision, 2);
|
||||
|
||||
expect(
|
||||
() => firstPage.items.add(record.value),
|
||||
throwsUnsupportedError,
|
||||
);
|
||||
});
|
||||
|
||||
test('project queries hide archived records by default and honor revisions',
|
||||
() async {
|
||||
final repository = createProjectRepository();
|
||||
final record = await repository.findRecordById('home');
|
||||
|
||||
final archived = await repository.archive(
|
||||
projectId: 'home',
|
||||
ownerId: ownerId,
|
||||
expectedRevision: record!.revision,
|
||||
archivedAt: DateTime.utc(2026, 6, 25),
|
||||
);
|
||||
final visible = await repository.findByOwner(ownerId: ownerId);
|
||||
final includingArchived = await repository.findByOwner(
|
||||
ownerId: ownerId,
|
||||
includeArchived: true,
|
||||
);
|
||||
|
||||
expect(archived.isSuccess, isTrue);
|
||||
expect(visible.items.map((project) => project.id), ['work']);
|
||||
expect(includingArchived.items.map((project) => project.id), [
|
||||
'home',
|
||||
'work',
|
||||
]);
|
||||
expect(
|
||||
(await repository.findRecordById('home'))!.archivedAt,
|
||||
DateTime.utc(2026, 6, 25),
|
||||
);
|
||||
expect(
|
||||
(await repository.insert(
|
||||
project: ProjectProfile(id: 'home', name: 'Home', colorKey: 'again'),
|
||||
ownerId: ownerId,
|
||||
))
|
||||
.failure!
|
||||
.code,
|
||||
RepositoryFailureCode.duplicateId,
|
||||
);
|
||||
});
|
||||
|
||||
test('locked queries find date-scoped overrides and archive by revision',
|
||||
() async {
|
||||
final repository = createLockedBlockRepository();
|
||||
final blockRecord = await repository.findBlockRecordById('work');
|
||||
final overrides = await repository.findOverridesForDate(
|
||||
ownerId: ownerId,
|
||||
date: CivilDate(2026, 6, 22),
|
||||
);
|
||||
|
||||
final archived = await repository.archiveBlock(
|
||||
blockId: 'work',
|
||||
ownerId: ownerId,
|
||||
expectedRevision: blockRecord!.revision,
|
||||
archivedAt: DateTime.utc(2026, 6, 25),
|
||||
);
|
||||
final visible = await repository.findBlocksByOwner(ownerId: ownerId);
|
||||
final includingArchived = await repository.findBlocksByOwner(
|
||||
ownerId: ownerId,
|
||||
includeArchived: true,
|
||||
);
|
||||
|
||||
expect(overrides.items.map((override) => override.id), ['holiday']);
|
||||
expect(archived.isSuccess, isTrue);
|
||||
expect(visible.items, isEmpty);
|
||||
expect(includingArchived.items.map((block) => block.id), ['work']);
|
||||
expect(
|
||||
(await repository.findBlockRecordById('work'))!.archivedAt,
|
||||
DateTime.utc(2026, 6, 25),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Task task({
|
||||
|
|
|
|||
Loading…
Reference in a new issue