diff --git a/.agents/specs/823-pr-826-review-follow-up.md b/.agents/specs/823-pr-826-review-follow-up.md deleted file mode 100644 index 30e1007f..00000000 --- a/.agents/specs/823-pr-826-review-follow-up.md +++ /dev/null @@ -1,50 +0,0 @@ -# PR #826 Review Follow-up - -- Source: https://github.com/opficdev/DevLog_iOS/pull/826 -- Approved Designer Result: PR #826의 수용된 review thread 4개에 대한 `Designer Result` -- User approval: 네 review thread 전체 수용, 개별 커밋, push, `반영 {커밋번호}` 답글, resolve 승인 - -## Constraints - -- `Designer`와 `Code Reviewer`의 `gpt-5.6-sol`, `xhigh`, 정확한 `task_name` 유지 -- 기존 `Lightweight` 역할의 Spark 우선 및 Luna 대체 정책 유지 -- 앱 코드, Swift 테스트, Tuist, CI 동작, QALenz, app 또는 Simulator 실행 제외 - -## Alternatives and decision - -- 활성 `Primary`가 Sol일 때 SDD Gate 생략 또는 다른 모델 대체 방안 제외 -- Sol 동일 모델 허용을 정확한 `designer`, `code_reviewer` custom agent dispatch로 한정 -- 단순 작업의 SDD 절차 강제 방안 제외 - -## Changed boundaries - -- `.agents/roles.md`의 Sol `Primary`와 Sol 전용 SDD Gate 공존 규칙 -- `.agents/workflows.md`의 비단순 작업 전용 SDD 흐름 -- `.agents/specs/README.md`의 이슈 없는 Spec 이름 -- `README.md`의 SDD 역할 흐름과 모델 안내 - -## Acceptance criteria - -- [ ] Sol `Primary`에서도 정확한 `designer`, `code_reviewer` SDD Gate dispatch 허용 -- [ ] 단순 작업의 `Task Packet` 경로와 비단순 작업의 SDD 흐름 분리 -- [ ] 이슈 없는 요청의 Spec 이름 규칙 정의 -- [ ] `README.md`의 흐름과 역할 표를 현재 SDD 규칙과 동기화 -- [ ] 스레드별 독립 커밋, push, `반영 {커밋번호}` 답글, resolve 완료 - -## Verification - -- Command: `git diff --check -- AGENTS.md .agents .codex/agents README.md` -- Evidence: 역할·모델·정확한 `task_name` 점검, Mermaid 흐름 대조, GraphQL `reviewThreads` 상태 확인 - -## Minimum commit units - -1. Sol `Primary`와 SDD Gate 공존 규칙 -2. 비단순 작업 전용 SDD protocol -3. 이슈 없는 Spec 이름 규칙 -4. `README.md` SDD 흐름과 역할 표 동기화 - -## Execution constraints - -- app or Simulator execution: 금지 -- External writes: git commit, push, 승인된 review reply와 resolve만 허용 -- CI or PR actions: PR 생성 또는 병합 금지 diff --git a/.agents/specs/835-collection-ui-removal.md b/.agents/specs/835-collection-ui-removal.md deleted file mode 100644 index 9b4ff954..00000000 --- a/.agents/specs/835-collection-ui-removal.md +++ /dev/null @@ -1,68 +0,0 @@ -# CollectionUI 제거 Spec - -- Source: https://github.com/opficdev/DevLog_iOS/issues/835 -- Approved Designer Result: 2026-08-27 사용자 승인 -- User approval: `ㄱㄱ` - -## Constraints - -- `TodoListView`의 현재 SwiftUI `List` 구성과 `safeAreaInset`, `onScrollOffsetChange`, `.navigationTitle`, `.refreshable`, 로딩, 빈 목록, 검색, 상세 이동, 페이지네이션, 스와이프 동작을 변경하지 않는다. -- `TodoItemRow`와 `TagList(tags, lineLimit: 1)` 계약을 유지한다. -- `Application/Presentation/Project.swift`와 PresentationShared는 이미 CollectionUI 의존성과 import가 없으므로 변경하지 않는다. -- 새 UIKit 목록 renderer, Domain, Data, Firebase, 관련 이슈 #815, #816, #817, #834는 변경하지 않는다. -- 생성된 `.xcodeproj`, `.xcworkspace`, `DerivedData`, `Package.resolved`는 추적하지 않는다. - -## Alternatives and decision - -- `71b5ac1b7` 전체 되돌리기는 CollectionUI와 무관한 변경을 함께 되돌리므로 사용하지 않는다. -- `Libraries/CollectionUI`만 삭제하면 Workspace와 CI의 끊어진 참조가 남으므로 사용하지 않는다. -- 현재 SwiftUI `TodoListView`와 Presentation 경계는 그대로 두고 Workspace, CI, 구조도 연결을 제거한 뒤 고립된 `Libraries/CollectionUI`를 삭제한다. - -## Changed boundaries - -- Workspace에서 `Libraries/CollectionUI` 프로젝트와 `CollectionUITests`를 제거한다. -- CI Libraries 묶음은 `MarkdownRenderer`만 유지하고 `CollectionUITests` 전용 테스트 경로 분기를 제거한다. -- Todo 목록의 렌더링과 스크롤 계약은 PresentationShared의 SwiftUI `List`가 계속 소유한다. -- `docs/graph.png`에서 제거된 `CollectionUI` 노드를 없앤다. - -## Acceptance criteria - -- [ ] `Libraries/CollectionUI`의 추적 파일과 생성물이 모두 제거된다. -- [ ] `Workspace.swift`에 `Libraries/CollectionUI` 등록이 없다. -- [ ] `.github/workflows/ci.yml`에 `CollectionUITests` scheme과 `Libraries/CollectionUI/Tests` 분기가 없다. -- [ ] Spec을 제외한 추적 파일 전체에서 `CollectionUI`, `CollectionUITests`, `TodoListCollectionView`, `TodoListCollectionCell`, `CollectionRenderingSnapshot` 참조가 없다. -- [ ] `docs/graph.png`에 `CollectionUI` 노드가 없고 나머지 모듈 관계가 유지된다. -- [ ] `Application/Presentation/Project.swift`에 CollectionUI 의존성이 없으며 불필요한 변경이 없다. -- [ ] `TodoListView.swift`의 기존 SwiftUI `List`와 화면 계약이 유지된다. -- [ ] `TodoItemRow`와 `TagList(tags, lineLimit: 1)` 계약이 유지된다. -- [ ] Domain, Data, Firebase와 관련 이슈 #815, #816, #817, #834에 변경이 없다. -- [ ] `tuist generate --no-open`과 `App` scheme build가 성공하고 생성물 변경이 추적되지 않는다. - -## Verification - -- Command: `git grep -n -E 'CollectionUI|CollectionUITests|TodoListCollection(View|Cell)|CollectionRenderingSnapshot' -- ':!.agents/specs/835-collection-ui-removal.md'` -- Evidence: 결과 없음 -- Command: `git ls-files Libraries/CollectionUI` -- Evidence: 결과 없음 -- Command: `ruby -e 'require "yaml"; YAML.load_file(".github/workflows/ci.yml", aliases: true)'` -- Evidence: 정상 종료 -- Command: `mise exec -- tuist generate --no-open` -- Evidence: 정상 종료 -- Command: `xcodebuild -workspace DevLog.xcworkspace -scheme App -configuration Debug -destination 'generic/platform=iOS Simulator' -skipPackagePluginValidation -skipMacroValidation -showBuildTimingSummary build` -- Evidence: 정상 종료 -- Command: `git diff --check` -- Evidence: 정상 종료 -- 실기기 헤더와 `navigationTitle` 동작은 현재 실행 권한으로 검증하지 않는다. - -## Minimum commit units - -1. `chore: CollectionUI 빌드 그래프와 CI 연결 제거` - - `.agents/specs/835-collection-ui-removal.md`, `Workspace.swift`, `.github/workflows/ci.yml`, `docs/graph.png` -2. `refactor: CollectionUI 라이브러리 제거` - - `Libraries/CollectionUI/**` - -## Execution constraints - -- app or Simulator execution: 금지 -- External writes: 로컬 커밋만 허용 -- CI or PR actions: 금지 diff --git a/.gitignore b/.gitignore index 9d415a0f..41e69d89 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ ## AI docs/superpowers +.agents/specs/* +!.agents/specs/README.md +.agents/plans/ # MACOS .DS_Store diff --git a/Application/Domain/Sources/DomainAssembler.swift b/Application/Domain/Sources/DomainAssembler.swift index bb5f654f..ffa9465a 100644 --- a/Application/Domain/Sources/DomainAssembler.swift +++ b/Application/Domain/Sources/DomainAssembler.swift @@ -16,6 +16,8 @@ public final class DomainAssembler: Assembler { registerAuthUseCases(container) registerConnectivityUseCases(container) registerAuthProviderUseCases(container) + registerDevelopmentGoalUseCases(container) + registerDevelopmentRecordUseCases(container) registerTodoUseCases(container) registerTodoCategoryUseCases(container) registerUserDataUseCases(container) @@ -26,6 +28,66 @@ public final class DomainAssembler: Assembler { } private extension DomainAssembler { + func registerDevelopmentGoalUseCases(_ container: any DIContainer) { + container.register(CreateDevelopmentGoalUseCase.self) { + CreateDevelopmentGoalUseCaseImpl(container.resolve(DevelopmentGoalRepository.self)) + } + + container.register(FetchDevelopmentGoalUseCase.self) { + FetchDevelopmentGoalUseCaseImpl(container.resolve(DevelopmentGoalRepository.self)) + } + + container.register(FetchDevelopmentGoalsUseCase.self) { + FetchDevelopmentGoalsUseCaseImpl(container.resolve(DevelopmentGoalRepository.self)) + } + + container.register(UpdateDevelopmentGoalStatusUseCase.self) { + UpdateDevelopmentGoalStatusUseCaseImpl( + container.resolve(DevelopmentGoalRepository.self) + ) + } + } + + func registerDevelopmentRecordUseCases(_ container: any DIContainer) { + container.register(CreateDevelopmentRecordUseCase.self) { + CreateDevelopmentRecordUseCaseImpl( + container.resolve(DevelopmentRecordRepository.self), + container.resolve(DevelopmentGoalRepository.self) + ) + } + + container.register(FetchDevelopmentRecordsUseCase.self) { + FetchDevelopmentRecordsUseCaseImpl(container.resolve(DevelopmentRecordRepository.self)) + } + + container.register(FetchDevelopmentRecordHistoryUseCase.self) { + FetchDevelopmentRecordHistoryUseCaseImpl( + container.resolve(DevelopmentRecordRepository.self) + ) + } + + container.register(SaveDevelopmentRecordDraftUseCase.self) { + SaveDevelopmentRecordDraftUseCaseImpl( + container.resolve(DevelopmentRecordRepository.self), + container.resolve(DevelopmentGoalRepository.self) + ) + } + + container.register(ConfirmDevelopmentRecordUseCase.self) { + ConfirmDevelopmentRecordUseCaseImpl( + container.resolve(DevelopmentRecordRepository.self), + container.resolve(DevelopmentGoalRepository.self) + ) + } + + container.register(RestoreDevelopmentRecordUseCase.self) { + RestoreDevelopmentRecordUseCaseImpl( + container.resolve(DevelopmentRecordRepository.self), + container.resolve(DevelopmentGoalRepository.self) + ) + } + } + func registerAppUpdateUseCases(_ container: any DIContainer) { container.register(CheckAppUpdateUseCase.self) { CheckAppUpdateUseCaseImpl(container.resolve(AppVersionRepository.self)) @@ -102,6 +164,13 @@ private extension DomainAssembler { container.register(UndoDeleteTodoUseCase.self) { UndoDeleteTodoUseCaseImpl(container.resolve(TodoRepository.self)) } + + container.register(UpdateTodoGoalUseCase.self) { + UpdateTodoGoalUseCaseImpl( + container.resolve(TodoRepository.self), + container.resolve(DevelopmentGoalRepository.self) + ) + } } func registerTodoCategoryUseCases(_ container: any DIContainer) { diff --git a/Application/Domain/Sources/Entity/DevelopmentGoal.swift b/Application/Domain/Sources/Entity/DevelopmentGoal.swift new file mode 100644 index 00000000..1dba3623 --- /dev/null +++ b/Application/Domain/Sources/Entity/DevelopmentGoal.swift @@ -0,0 +1,87 @@ +// +// DevelopmentGoal.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +import Foundation + +public struct DevelopmentGoal: Hashable { + public enum Status: Hashable { + case inProgress + case completed + case archived + } + + public struct Query: Hashable { + public let status: Status? + + public init(status: Status? = nil) { + self.status = status + } + } + + public struct CompletionSnapshot: Hashable { + public let goal: DevelopmentGoal + public let records: [DevelopmentRecord] + + public init(goal: DevelopmentGoal, records: [DevelopmentRecord]) { + self.goal = goal + self.records = records + } + } + + public let id: String + public let title: String + public let description: String + public let status: Status + public let createdAt: Date + public let updatedAt: Date + public let completedAt: Date? + + public init( + id: String, + title: String, + description: String, + status: Status, + createdAt: Date, + updatedAt: Date, + completedAt: Date? + ) throws { + guard title.containsMeaningfulCharacter else { + throw DomainLayerError.invalidDevelopmentGoalTitle + } + + self.id = id + self.title = title + self.description = description + self.status = status + self.createdAt = createdAt + self.updatedAt = updatedAt + self.completedAt = completedAt + } + + public func validateTransition(to status: Status) throws { + let isAllowed: Bool + switch (self.status, status) { + case (.inProgress, .completed), + (.inProgress, .archived), + (.completed, .inProgress), + (.archived, .inProgress): + isAllowed = true + default: + isAllowed = false + } + + guard isAllowed else { + throw DomainLayerError.invalidDevelopmentGoalTransition + } + } +} + +private extension String { + var containsMeaningfulCharacter: Bool { + !trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/Application/Domain/Sources/Entity/DevelopmentRecord.swift b/Application/Domain/Sources/Entity/DevelopmentRecord.swift new file mode 100644 index 00000000..50246cc1 --- /dev/null +++ b/Application/Domain/Sources/Entity/DevelopmentRecord.swift @@ -0,0 +1,151 @@ +// +// DevelopmentRecord.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +import Foundation + +public struct DevelopmentRecord: Hashable { + public struct Draft: Hashable { + public let title: String + public let markdownContent: String + public let baseVersionId: String? + public let updatedAt: Date + + public init( + title: String, + markdownContent: String, + baseVersionId: String?, + updatedAt: Date + ) throws { + guard title.containsMeaningfulCharacter else { + throw DomainLayerError.invalidDevelopmentRecordTitle + } + guard baseVersionId?.containsMeaningfulCharacter != false else { + throw DomainLayerError.invalidDevelopmentRecordVersion + } + + self.title = title + self.markdownContent = markdownContent + self.baseVersionId = baseVersionId + self.updatedAt = updatedAt + } + } + + public struct CurrentVersion: Hashable { + public let id: String + public let number: Int + + public init(id: String, number: Int) throws { + guard id.containsMeaningfulCharacter, 0 < number else { + throw DomainLayerError.invalidDevelopmentRecordCurrentVersion + } + + self.id = id + self.number = number + } + } + + public struct Version: Hashable { + public enum Kind: Hashable { + case initial + case correction + case rollback + } + + public let id: String + public let recordId: String + public let number: Int + public let title: String + public let markdownContent: String + public let kind: Kind + public let sourceVersionId: String? + public let confirmedAt: Date + + public init( + id: String, + recordId: String, + number: Int, + title: String, + markdownContent: String, + kind: Kind, + sourceVersionId: String?, + confirmedAt: Date + ) throws { + guard id.containsMeaningfulCharacter, + recordId.containsMeaningfulCharacter, + title.containsMeaningfulCharacter, + 0 < number else { + throw DomainLayerError.invalidDevelopmentRecordVersion + } + + switch kind { + case .initial: + guard number == 1, sourceVersionId == nil else { + throw DomainLayerError.invalidDevelopmentRecordVersion + } + case .correction, .rollback: + guard 1 < number, + let sourceVersionId, + sourceVersionId.containsMeaningfulCharacter, + sourceVersionId != id else { + throw DomainLayerError.invalidDevelopmentRecordVersion + } + } + + self.id = id + self.recordId = recordId + self.number = number + self.title = title + self.markdownContent = markdownContent + self.kind = kind + self.sourceVersionId = sourceVersionId + self.confirmedAt = confirmedAt + } + } + + public let id: String + public let goalId: String + public let currentVersion: CurrentVersion? + public let draft: Draft? + public let createdAt: Date + + public init( + id: String, + goalId: String, + currentVersion: CurrentVersion?, + draft: Draft?, + createdAt: Date + ) throws { + guard id.containsMeaningfulCharacter, goalId.containsMeaningfulCharacter else { + throw DomainLayerError.invalidData(context: "developmentRecord") + } + guard currentVersion != nil || draft != nil else { + throw DomainLayerError.invalidDevelopmentRecordCurrentVersion + } + + if let currentVersion, let draft { + guard draft.baseVersionId == currentVersion.id else { + throw DomainLayerError.invalidDevelopmentRecordCurrentVersion + } + } else if currentVersion == nil { + guard draft?.baseVersionId == nil else { + throw DomainLayerError.invalidDevelopmentRecordCurrentVersion + } + } + + self.id = id + self.goalId = goalId + self.currentVersion = currentVersion + self.draft = draft + self.createdAt = createdAt + } +} + +private extension String { + var containsMeaningfulCharacter: Bool { + !trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/Application/Domain/Sources/Entity/DomainLayerError.swift b/Application/Domain/Sources/Entity/DomainLayerError.swift index af35c33d..b3f630da 100644 --- a/Application/Domain/Sources/Entity/DomainLayerError.swift +++ b/Application/Domain/Sources/Entity/DomainLayerError.swift @@ -5,6 +5,18 @@ // Created by opfic on 7/10/26. // -public enum DomainLayerError: Error { +public enum DomainLayerError: Error, Equatable { case invalidData(context: String) + case invalidDevelopmentGoalTitle + case invalidDevelopmentRecordTitle + case invalidDevelopmentGoalTransition + case developmentGoalIsNotInProgress + case developmentGoalCompletionRequiresRecord + case developmentGoalCompletionNeedsVersion + case developmentGoalCompletionHasDraft + case invalidDevelopmentRecordCurrentVersion + case developmentRecordDraftNotFound + case developmentRecordDraftConflict + case invalidDevelopmentRecordVersion + case developmentRecordVersionNotFound } diff --git a/Application/Domain/Sources/Entity/Todo.swift b/Application/Domain/Sources/Entity/Todo.swift index 188052f2..4844db4e 100644 --- a/Application/Domain/Sources/Entity/Todo.swift +++ b/Application/Domain/Sources/Entity/Todo.swift @@ -9,19 +9,20 @@ import Foundation public struct Todo: Hashable { public let id: String - public var isPinned: Bool // 해당 할 일이 상단에 고정되어 있는지 여부 - public var isCompleted: Bool // 해당 할 일의 완료 여부 - public var isChecked: Bool // 해당 할 일의 체크 여부 - public var number: Int // 사용자에게 노출되는 Todo 번호 - public var title: String // 할 일의 제목 - public var content: String // 할 일의 설명 - public var createdAt: Date // 할 일 생성 날짜 - public var updatedAt: Date // 할 일 수정 날짜 - public var completedAt: Date? // 할 일 완료 날짜 - public var deletedAt: Date? // 할 일 삭제 날짜 - public var dueDate: Date? // 할 일의 마감 날짜 (선택 사항) - public var tags: [String] // 할 일에 연결된 태그들 - public var category: TodoCategory // 할 일의 종류 + public var goalId: String? // 연결된 개발 목표 ID + public var isPinned: Bool // 상단에 고정되어 있는지 여부 + public var isCompleted: Bool // 완료 여부 + public var isChecked: Bool // 체크 여부 + public var number: Int // 사용자에게 노출되는 Todo 번호 + public var title: String // 제목 + public var content: String // 설명 + public var createdAt: Date // 생성 날짜 + public var updatedAt: Date // 최근 수정 날짜 + public var completedAt: Date? // 완료 날짜 + public var deletedAt: Date? // 삭제 날짜 + public var dueDate: Date? // 마감 날짜 (선택 사항) + public var tags: [String] // 연결된 태그들 + public var category: TodoCategory // 종류 public init( id: String, @@ -37,9 +38,11 @@ public struct Todo: Hashable { deletedAt: Date?, dueDate: Date?, tags: [String], - category: TodoCategory + category: TodoCategory, + goalId: String? = nil ) { self.id = id + self.goalId = goalId self.isPinned = isPinned self.isCompleted = isCompleted self.isChecked = isChecked diff --git a/Application/Domain/Sources/Entity/TodoDraft.swift b/Application/Domain/Sources/Entity/TodoDraft.swift index ee005197..0ecf7bba 100644 --- a/Application/Domain/Sources/Entity/TodoDraft.swift +++ b/Application/Domain/Sources/Entity/TodoDraft.swift @@ -9,6 +9,7 @@ import Foundation public struct TodoDraft: Equatable { public var id: String + public var goalId: String? public var isPinned: Bool public var isCompleted: Bool public var isChecked: Bool @@ -33,9 +34,11 @@ public struct TodoDraft: Equatable { completedAt: Date?, dueDate: Date?, tags: [String], - category: TodoCategory + category: TodoCategory, + goalId: String? = nil ) { self.id = id + self.goalId = goalId self.isPinned = isPinned self.isCompleted = isCompleted self.isChecked = isChecked @@ -51,6 +54,7 @@ public struct TodoDraft: Equatable { public init(todo: Todo) { self.id = todo.id + self.goalId = todo.goalId self.isPinned = todo.isPinned self.isCompleted = todo.isCompleted self.isChecked = todo.isChecked @@ -66,6 +70,7 @@ public struct TodoDraft: Equatable { public static func == (lhs: TodoDraft, rhs: TodoDraft) -> Bool { lhs.id == rhs.id && + lhs.goalId == rhs.goalId && lhs.isPinned == rhs.isPinned && lhs.isCompleted == rhs.isCompleted && lhs.isChecked == rhs.isChecked && diff --git a/Application/Domain/Sources/Protocol/DevelopmentGoalRepository.swift b/Application/Domain/Sources/Protocol/DevelopmentGoalRepository.swift new file mode 100644 index 00000000..34cef57f --- /dev/null +++ b/Application/Domain/Sources/Protocol/DevelopmentGoalRepository.swift @@ -0,0 +1,22 @@ +// +// DevelopmentGoalRepository.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +public protocol DevelopmentGoalRepository { + func createGoal( + id: String, + title: String, + description: String + ) async throws -> DevelopmentGoal + func fetchGoal(_ goalId: String) async throws -> DevelopmentGoal + func fetchGoals(_ query: DevelopmentGoal.Query) async throws -> [DevelopmentGoal] + func fetchCompletionSnapshot(for goalId: String) async throws -> DevelopmentGoal.CompletionSnapshot + func transitionGoalStatus( + _ goalId: String, + to status: DevelopmentGoal.Status, + completionSnapshot: DevelopmentGoal.CompletionSnapshot? + ) async throws +} diff --git a/Application/Domain/Sources/Protocol/DevelopmentRecordRepository.swift b/Application/Domain/Sources/Protocol/DevelopmentRecordRepository.swift new file mode 100644 index 00000000..11d23b7a --- /dev/null +++ b/Application/Domain/Sources/Protocol/DevelopmentRecordRepository.swift @@ -0,0 +1,35 @@ +// +// DevelopmentRecordRepository.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +public protocol DevelopmentRecordRepository { + func createRecord( + id: String, + goalId: String, + draft: DevelopmentRecord.Draft + ) async throws -> DevelopmentRecord + func fetchRecords(goalId: String) async throws -> [DevelopmentRecord] + func fetchRecord(goalId: String, recordId: String) async throws -> DevelopmentRecord + func fetchVersions(goalId: String, recordId: String) async throws -> [DevelopmentRecord.Version] + func saveDraft( + goalId: String, + recordId: String, + draft: DevelopmentRecord.Draft + ) async throws -> DevelopmentRecord + func confirmDraft( + goalId: String, + recordId: String, + versionId: String, + kind: DevelopmentRecord.Version.Kind, + sourceVersionId: String? + ) async throws -> DevelopmentRecord.Version + func restoreVersion( + goalId: String, + recordId: String, + versionId: String, + sourceVersionId: String + ) async throws -> DevelopmentRecord.Version +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentGoal/Create/CreateDevelopmentGoalUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentGoal/Create/CreateDevelopmentGoalUseCase.swift new file mode 100644 index 00000000..e1bda59f --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentGoal/Create/CreateDevelopmentGoalUseCase.swift @@ -0,0 +1,10 @@ +// +// CreateDevelopmentGoalUseCase.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +public protocol CreateDevelopmentGoalUseCase { + func execute(title: String, description: String) async throws -> DevelopmentGoal +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentGoal/Create/CreateDevelopmentGoalUseCaseImpl.swift b/Application/Domain/Sources/UseCase/DevelopmentGoal/Create/CreateDevelopmentGoalUseCaseImpl.swift new file mode 100644 index 00000000..ab1358f5 --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentGoal/Create/CreateDevelopmentGoalUseCaseImpl.swift @@ -0,0 +1,29 @@ +// +// CreateDevelopmentGoalUseCaseImpl.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +import Foundation + +public final class CreateDevelopmentGoalUseCaseImpl: CreateDevelopmentGoalUseCase { + private let repository: DevelopmentGoalRepository + private let idProvider: () -> String + + init( + _ repository: DevelopmentGoalRepository, + idProvider: @escaping () -> String = { UUID().uuidString } + ) { + self.repository = repository + self.idProvider = idProvider + } + + public func execute(title: String, description: String) async throws -> DevelopmentGoal { + try await repository.createGoal( + id: idProvider(), + title: title, + description: description + ) + } +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalUseCase.swift new file mode 100644 index 00000000..a06c85b5 --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalUseCase.swift @@ -0,0 +1,10 @@ +// +// FetchDevelopmentGoalUseCase.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +public protocol FetchDevelopmentGoalUseCase { + func execute(_ goalId: String) async throws -> DevelopmentGoal +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalUseCaseImpl.swift b/Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalUseCaseImpl.swift new file mode 100644 index 00000000..2fc5d002 --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// FetchDevelopmentGoalUseCaseImpl.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +public final class FetchDevelopmentGoalUseCaseImpl: FetchDevelopmentGoalUseCase { + private let repository: DevelopmentGoalRepository + + init(_ repository: DevelopmentGoalRepository) { + self.repository = repository + } + + public func execute(_ goalId: String) async throws -> DevelopmentGoal { + try await repository.fetchGoal(goalId) + } +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalsUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalsUseCase.swift new file mode 100644 index 00000000..2c113ca1 --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalsUseCase.swift @@ -0,0 +1,10 @@ +// +// FetchDevelopmentGoalsUseCase.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +public protocol FetchDevelopmentGoalsUseCase { + func execute(_ query: DevelopmentGoal.Query) async throws -> [DevelopmentGoal] +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalsUseCaseImpl.swift b/Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalsUseCaseImpl.swift new file mode 100644 index 00000000..d7edfea6 --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalsUseCaseImpl.swift @@ -0,0 +1,27 @@ +// +// FetchDevelopmentGoalsUseCaseImpl.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +public final class FetchDevelopmentGoalsUseCaseImpl: FetchDevelopmentGoalsUseCase { + private let repository: DevelopmentGoalRepository + + init(_ repository: DevelopmentGoalRepository) { + self.repository = repository + } + + public func execute(_ query: DevelopmentGoal.Query) async throws -> [DevelopmentGoal] { + let goals = try await repository.fetchGoals(query) + + return goals + .filter { query.status == nil || $0.status == query.status } + .sorted { lhs, rhs in + if lhs.createdAt == rhs.createdAt { + return lhs.id < rhs.id + } + return lhs.createdAt < rhs.createdAt + } + } +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentGoal/Transition/UpdateDevelopmentGoalStatusUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentGoal/Transition/UpdateDevelopmentGoalStatusUseCase.swift new file mode 100644 index 00000000..40478378 --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentGoal/Transition/UpdateDevelopmentGoalStatusUseCase.swift @@ -0,0 +1,10 @@ +// +// UpdateDevelopmentGoalStatusUseCase.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +public protocol UpdateDevelopmentGoalStatusUseCase { + func execute(_ goalId: String, to status: DevelopmentGoal.Status) async throws +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentGoal/Transition/UpdateDevelopmentGoalStatusUseCaseImpl.swift b/Application/Domain/Sources/UseCase/DevelopmentGoal/Transition/UpdateDevelopmentGoalStatusUseCaseImpl.swift new file mode 100644 index 00000000..5e5702fe --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentGoal/Transition/UpdateDevelopmentGoalStatusUseCaseImpl.swift @@ -0,0 +1,63 @@ +// +// UpdateDevelopmentGoalStatusUseCaseImpl.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +public final class UpdateDevelopmentGoalStatusUseCaseImpl: UpdateDevelopmentGoalStatusUseCase { + private let repository: DevelopmentGoalRepository + + init(_ repository: DevelopmentGoalRepository) { + self.repository = repository + } + + public func execute(_ goalId: String, to status: DevelopmentGoal.Status) async throws { + let goal = try await repository.fetchGoal(goalId) + try goal.validateTransition(to: status) + + let snapshot: DevelopmentGoal.CompletionSnapshot? + if status == .completed { + let completionSnapshot = try await repository.fetchCompletionSnapshot(for: goalId) + try completionSnapshot.goal.validateTransition(to: status) + try validate(completionSnapshot, for: goal) + snapshot = completionSnapshot + } else { + snapshot = nil + } + + try await repository.transitionGoalStatus( + goalId, + to: status, + completionSnapshot: snapshot + ) + } +} + +private extension UpdateDevelopmentGoalStatusUseCaseImpl { + func validate( + _ snapshot: DevelopmentGoal.CompletionSnapshot, + for goal: DevelopmentGoal + ) throws { + guard snapshot.goal.id == goal.id, + snapshot.records.allSatisfy({ $0.goalId == goal.id }) else { + throw DomainLayerError.invalidData(context: "developmentGoalCompletionSnapshot") + } + guard !snapshot.records.isEmpty else { + throw DomainLayerError.developmentGoalCompletionRequiresRecord + } + + let lastRecord = snapshot.records.max { lhs, rhs in + if lhs.createdAt == rhs.createdAt { + return lhs.id < rhs.id + } + return lhs.createdAt < rhs.createdAt + } + guard lastRecord?.currentVersion != nil else { + throw DomainLayerError.developmentGoalCompletionNeedsVersion + } + guard !snapshot.records.contains(where: { $0.draft != nil }) else { + throw DomainLayerError.developmentGoalCompletionHasDraft + } + } +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift new file mode 100644 index 00000000..288b789e --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift @@ -0,0 +1,10 @@ +// +// ConfirmDevelopmentRecordUseCase.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +public protocol ConfirmDevelopmentRecordUseCase { + func execute(goalId: String, recordId: String) async throws -> DevelopmentRecord.Version +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift new file mode 100644 index 00000000..8b664d31 --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift @@ -0,0 +1,57 @@ +// +// ConfirmDevelopmentRecordUseCaseImpl.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +import Foundation + +public final class ConfirmDevelopmentRecordUseCaseImpl: ConfirmDevelopmentRecordUseCase { + private let repository: DevelopmentRecordRepository + private let goalRepository: DevelopmentGoalRepository + private let idProvider: () -> String + + init( + _ repository: DevelopmentRecordRepository, + _ goalRepository: DevelopmentGoalRepository, + idProvider: @escaping () -> String = { UUID().uuidString } + ) { + self.repository = repository + self.goalRepository = goalRepository + self.idProvider = idProvider + } + + public func execute(goalId: String, recordId: String) async throws -> DevelopmentRecord.Version { + let goal = try await goalRepository.fetchGoal(goalId) + guard goal.status == .inProgress else { + throw DomainLayerError.developmentGoalIsNotInProgress + } + + let record = try await repository.fetchRecord(goalId: goalId, recordId: recordId) + guard record.id == recordId, record.goalId == goalId else { + throw DomainLayerError.invalidData(context: "developmentRecord") + } + guard record.draft != nil else { + throw DomainLayerError.developmentRecordDraftNotFound + } + + if let currentVersion = record.currentVersion { + return try await repository.confirmDraft( + goalId: goalId, + recordId: recordId, + versionId: idProvider(), + kind: .correction, + sourceVersionId: currentVersion.id + ) + } + + return try await repository.confirmDraft( + goalId: goalId, + recordId: recordId, + versionId: idProvider(), + kind: .initial, + sourceVersionId: nil + ) + } +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCase.swift new file mode 100644 index 00000000..c8e4d0e1 --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCase.swift @@ -0,0 +1,14 @@ +// +// CreateDevelopmentRecordUseCase.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +public protocol CreateDevelopmentRecordUseCase { + func execute( + goalId: String, + title: String, + markdownContent: String + ) async throws -> DevelopmentRecord +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCaseImpl.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCaseImpl.swift new file mode 100644 index 00000000..08345081 --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCaseImpl.swift @@ -0,0 +1,50 @@ +// +// CreateDevelopmentRecordUseCaseImpl.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +import Foundation + +public final class CreateDevelopmentRecordUseCaseImpl: CreateDevelopmentRecordUseCase { + private let repository: DevelopmentRecordRepository + private let goalRepository: DevelopmentGoalRepository + private let idProvider: () -> String + private let now: () -> Date + + init( + _ repository: DevelopmentRecordRepository, + _ goalRepository: DevelopmentGoalRepository, + idProvider: @escaping () -> String = { UUID().uuidString }, + now: @escaping () -> Date = Date.init + ) { + self.repository = repository + self.goalRepository = goalRepository + self.idProvider = idProvider + self.now = now + } + + public func execute( + goalId: String, + title: String, + markdownContent: String + ) async throws -> DevelopmentRecord { + let goal = try await goalRepository.fetchGoal(goalId) + guard goal.status == .inProgress else { + throw DomainLayerError.developmentGoalIsNotInProgress + } + + let draft = try DevelopmentRecord.Draft( + title: title, + markdownContent: markdownContent, + baseVersionId: nil, + updatedAt: now() + ) + return try await repository.createRecord( + id: idProvider(), + goalId: goalId, + draft: draft + ) + } +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCase.swift new file mode 100644 index 00000000..a3f59842 --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCase.swift @@ -0,0 +1,15 @@ +// +// SaveDevelopmentRecordDraftUseCase.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +public protocol SaveDevelopmentRecordDraftUseCase { + func execute( + goalId: String, + recordId: String, + title: String, + markdownContent: String + ) async throws -> DevelopmentRecord +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCaseImpl.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCaseImpl.swift new file mode 100644 index 00000000..a77a3c42 --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCaseImpl.swift @@ -0,0 +1,52 @@ +// +// SaveDevelopmentRecordDraftUseCaseImpl.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +import Foundation + +public final class SaveDevelopmentRecordDraftUseCaseImpl: SaveDevelopmentRecordDraftUseCase { + private let repository: DevelopmentRecordRepository + private let goalRepository: DevelopmentGoalRepository + private let now: () -> Date + + init( + _ repository: DevelopmentRecordRepository, + _ goalRepository: DevelopmentGoalRepository, + now: @escaping () -> Date = Date.init + ) { + self.repository = repository + self.goalRepository = goalRepository + self.now = now + } + + public func execute( + goalId: String, + recordId: String, + title: String, + markdownContent: String + ) async throws -> DevelopmentRecord { + let goal = try await goalRepository.fetchGoal(goalId) + guard goal.status == .inProgress else { + throw DomainLayerError.developmentGoalIsNotInProgress + } + + let record = try await repository.fetchRecord(goalId: goalId, recordId: recordId) + guard record.id == recordId, record.goalId == goalId else { + throw DomainLayerError.invalidData(context: "developmentRecord") + } + let updatedDraft = try DevelopmentRecord.Draft( + title: title, + markdownContent: markdownContent, + baseVersionId: record.currentVersion?.id, + updatedAt: now() + ) + return try await repository.saveDraft( + goalId: goalId, + recordId: recordId, + draft: updatedDraft + ) + } +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordHistoryUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordHistoryUseCase.swift new file mode 100644 index 00000000..44106ad2 --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordHistoryUseCase.swift @@ -0,0 +1,10 @@ +// +// FetchDevelopmentRecordHistoryUseCase.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +public protocol FetchDevelopmentRecordHistoryUseCase { + func execute(goalId: String, recordId: String) async throws -> [DevelopmentRecord.Version] +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordHistoryUseCaseImpl.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordHistoryUseCaseImpl.swift new file mode 100644 index 00000000..8f1237b4 --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordHistoryUseCaseImpl.swift @@ -0,0 +1,21 @@ +// +// FetchDevelopmentRecordHistoryUseCaseImpl.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +public final class FetchDevelopmentRecordHistoryUseCaseImpl: FetchDevelopmentRecordHistoryUseCase { + private let repository: DevelopmentRecordRepository + + init(_ repository: DevelopmentRecordRepository) { + self.repository = repository + } + + public func execute( + goalId: String, + recordId: String + ) async throws -> [DevelopmentRecord.Version] { + try await repository.fetchVersions(goalId: goalId, recordId: recordId) + } +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordsUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordsUseCase.swift new file mode 100644 index 00000000..2c99d560 --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordsUseCase.swift @@ -0,0 +1,10 @@ +// +// FetchDevelopmentRecordsUseCase.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +public protocol FetchDevelopmentRecordsUseCase { + func execute(goalId: String) async throws -> [DevelopmentRecord] +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordsUseCaseImpl.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordsUseCaseImpl.swift new file mode 100644 index 00000000..3ad632d9 --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordsUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// FetchDevelopmentRecordsUseCaseImpl.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +public final class FetchDevelopmentRecordsUseCaseImpl: FetchDevelopmentRecordsUseCase { + private let repository: DevelopmentRecordRepository + + init(_ repository: DevelopmentRecordRepository) { + self.repository = repository + } + + public func execute(goalId: String) async throws -> [DevelopmentRecord] { + try await repository.fetchRecords(goalId: goalId) + } +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Restore/RestoreDevelopmentRecordUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Restore/RestoreDevelopmentRecordUseCase.swift new file mode 100644 index 00000000..324828f1 --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Restore/RestoreDevelopmentRecordUseCase.swift @@ -0,0 +1,14 @@ +// +// RestoreDevelopmentRecordUseCase.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +public protocol RestoreDevelopmentRecordUseCase { + func execute( + goalId: String, + recordId: String, + sourceVersionId: String + ) async throws -> DevelopmentRecord.Version +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Restore/RestoreDevelopmentRecordUseCaseImpl.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Restore/RestoreDevelopmentRecordUseCaseImpl.swift new file mode 100644 index 00000000..c3fc8c91 --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Restore/RestoreDevelopmentRecordUseCaseImpl.swift @@ -0,0 +1,62 @@ +// +// RestoreDevelopmentRecordUseCaseImpl.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +import Foundation + +public final class RestoreDevelopmentRecordUseCaseImpl: RestoreDevelopmentRecordUseCase { + private let repository: DevelopmentRecordRepository + private let goalRepository: DevelopmentGoalRepository + private let idProvider: () -> String + + init( + _ repository: DevelopmentRecordRepository, + _ goalRepository: DevelopmentGoalRepository, + idProvider: @escaping () -> String = { UUID().uuidString } + ) { + self.repository = repository + self.goalRepository = goalRepository + self.idProvider = idProvider + } + + public func execute( + goalId: String, + recordId: String, + sourceVersionId: String + ) async throws -> DevelopmentRecord.Version { + let goal = try await goalRepository.fetchGoal(goalId) + guard goal.status == .inProgress else { + throw DomainLayerError.developmentGoalIsNotInProgress + } + + let record = try await repository.fetchRecord(goalId: goalId, recordId: recordId) + guard record.id == recordId, record.goalId == goalId else { + throw DomainLayerError.invalidData(context: "developmentRecord") + } + guard record.draft == nil else { + throw DomainLayerError.developmentRecordDraftConflict + } + guard let currentVersion = record.currentVersion else { + throw DomainLayerError.developmentRecordVersionNotFound + } + + let versions = try await repository.fetchVersions(goalId: goalId, recordId: recordId) + guard versions.contains(where: { + $0.id == sourceVersionId + && $0.recordId == recordId + && $0.number < currentVersion.number + }) else { + throw DomainLayerError.developmentRecordVersionNotFound + } + + return try await repository.restoreVersion( + goalId: goalId, + recordId: recordId, + versionId: idProvider(), + sourceVersionId: sourceVersionId + ) + } +} diff --git a/Application/Domain/Sources/UseCase/Todo/Update/UpdateTodoGoalUseCase.swift b/Application/Domain/Sources/UseCase/Todo/Update/UpdateTodoGoalUseCase.swift new file mode 100644 index 00000000..e5267bf0 --- /dev/null +++ b/Application/Domain/Sources/UseCase/Todo/Update/UpdateTodoGoalUseCase.swift @@ -0,0 +1,10 @@ +// +// UpdateTodoGoalUseCase.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +public protocol UpdateTodoGoalUseCase { + func execute(todoId: String, goalId: String?) async throws +} diff --git a/Application/Domain/Sources/UseCase/Todo/Update/UpdateTodoGoalUseCaseImpl.swift b/Application/Domain/Sources/UseCase/Todo/Update/UpdateTodoGoalUseCaseImpl.swift new file mode 100644 index 00000000..ec21e519 --- /dev/null +++ b/Application/Domain/Sources/UseCase/Todo/Update/UpdateTodoGoalUseCaseImpl.swift @@ -0,0 +1,29 @@ +// +// UpdateTodoGoalUseCaseImpl.swift +// Domain +// +// Created by opfic on 8/27/26. +// + +public final class UpdateTodoGoalUseCaseImpl: UpdateTodoGoalUseCase { + private let todoRepository: TodoRepository + private let goalRepository: DevelopmentGoalRepository + + init( + _ todoRepository: TodoRepository, + _ goalRepository: DevelopmentGoalRepository + ) { + self.todoRepository = todoRepository + self.goalRepository = goalRepository + } + + public func execute(todoId: String, goalId: String?) async throws { + if let goalId { + _ = try await goalRepository.fetchGoal(goalId) + } + + var todo = try await todoRepository.fetchTodo(todoId) + todo.goalId = goalId + try await todoRepository.upsertTodo(todo) + } +} diff --git a/Application/Domain/Tests/Entity/DevelopmentGoalTests.swift b/Application/Domain/Tests/Entity/DevelopmentGoalTests.swift new file mode 100644 index 00000000..9d8f7339 --- /dev/null +++ b/Application/Domain/Tests/Entity/DevelopmentGoalTests.swift @@ -0,0 +1,107 @@ +// +// DevelopmentGoalTests.swift +// DomainTests +// +// Created by opfic on 8/27/26. +// + +import Foundation +import Testing +@testable import Domain + +struct DevelopmentGoalTests { + @Test("공백과 줄바꿈만 있는 목표 제목은 거부한다") + func 공백과_줄바꿈만_있는_목표_제목은_거부한다() { + expectDomainError(.invalidDevelopmentGoalTitle) { + _ = try makeGoal(title: " \n ") + } + } + + @Test("진행 중 목표는 완료와 보관으로 전환할 수 있다") + func 진행_중_목표는_완료와_보관으로_전환할_수_있다() throws { + let goal = try makeGoal() + + try goal.validateTransition(to: .completed) + try goal.validateTransition(to: .archived) + } + + @Test("완료와 보관 목표는 진행 중으로만 전환할 수 있다") + func 완료와_보관_목표는_진행_중으로만_전환할_수_있다() throws { + let completedGoal = try makeGoal(status: .completed) + let archivedGoal = try makeGoal(status: .archived) + + try completedGoal.validateTransition(to: .inProgress) + try archivedGoal.validateTransition(to: .inProgress) + } + + @Test("정의하지 않은 목표 상태 전환은 거부한다") + func 정의하지_않은_목표_상태_전환은_거부한다() throws { + let completedGoal = try makeGoal(status: .completed) + + expectDomainError(.invalidDevelopmentGoalTransition) { + try completedGoal.validateTransition(to: .archived) + } + } + + @Test("목표 조회 조건은 상태 필터만 보유한다") + func 목표_조회_조건은_상태_필터만_보유한다() { + let query = DevelopmentGoal.Query(status: .inProgress) + + #expect(query.status == .inProgress) + } + + @Test("완료 검증 스냅샷은 목표와 전체 기록을 함께 보유한다") + func 완료_검증_스냅샷은_목표와_전체_기록을_함께_보유한다() throws { + let goal = try makeGoal() + let record = try makeInitialDraftRecord() + let snapshot = DevelopmentGoal.CompletionSnapshot(goal: goal, records: [record]) + + #expect(snapshot.goal == goal) + #expect(snapshot.records == [record]) + } +} + +private func makeGoal( + title: String = "개발 목표", + status: DevelopmentGoal.Status = .inProgress +) throws -> DevelopmentGoal { + try DevelopmentGoal( + id: "goal-1", + title: title, + description: "설명", + status: status, + createdAt: .distantPast, + updatedAt: .distantPast, + completedAt: status == .completed ? .distantFuture : nil + ) +} + +private func makeInitialDraftRecord() throws -> DevelopmentRecord { + let draft = try DevelopmentRecord.Draft( + title: "초안", + markdownContent: "본문", + baseVersionId: nil, + updatedAt: .distantPast + ) + return try DevelopmentRecord( + id: "record-1", + goalId: "goal-1", + currentVersion: nil, + draft: draft, + createdAt: .distantPast + ) +} + +private func expectDomainError( + _ expected: DomainLayerError, + operation: () throws -> Void +) { + do { + try operation() + Issue.record("DomainLayerError가 필요") + } catch let error as DomainLayerError { + #expect(error == expected) + } catch { + Issue.record("예상하지 않은 오류: \(error)") + } +} diff --git a/Application/Domain/Tests/Entity/DevelopmentRecordTests.swift b/Application/Domain/Tests/Entity/DevelopmentRecordTests.swift new file mode 100644 index 00000000..bdf8948d --- /dev/null +++ b/Application/Domain/Tests/Entity/DevelopmentRecordTests.swift @@ -0,0 +1,190 @@ +// +// DevelopmentRecordTests.swift +// DomainTests +// +// Created by opfic on 8/27/26. +// + +import Foundation +import Testing +@testable import Domain + +struct DevelopmentRecordTests { + @Test("공백과 줄바꿈만 있는 기록 제목은 거부한다") + func 공백과_줄바꿈만_있는_기록_제목은_거부한다() { + expectDomainError(.invalidDevelopmentRecordTitle) { + _ = try makeDraft(title: "\n ") + } + } + + @Test("현재 버전은 ID와 1 이상의 번호를 함께 가진다") + func 현재_버전은_ID와_1_이상의_번호를_함께_가진다() throws { + let version = try DevelopmentRecord.CurrentVersion(id: "version-1", number: 1) + + #expect(version.id == "version-1") + #expect(version.number == 1) + } + + @Test("현재 버전의 빈 ID와 0 이하 번호는 거부한다") + func 현재_버전의_빈_ID와_0_이하_번호는_거부한다() { + expectDomainError(.invalidDevelopmentRecordCurrentVersion) { + _ = try DevelopmentRecord.CurrentVersion(id: "", number: 1) + } + expectDomainError(.invalidDevelopmentRecordCurrentVersion) { + _ = try DevelopmentRecord.CurrentVersion(id: "version-1", number: 0) + } + } + + @Test("최초 초안 기록은 현재 버전 없이 생성한다") + func 최초_초안_기록은_현재_버전_없이_생성한다() throws { + let draft = try makeDraft() + let record = try makeRecord(currentVersion: nil, draft: draft) + + #expect(record.currentVersion == nil) + #expect(record.draft == draft) + } + + @Test("현재 버전이 없는 기록은 기준 버전을 가진 초안을 가질 수 없다") + func 현재_버전이_없는_기록은_기준_버전을_가진_초안을_가질_수_없다() throws { + let draft = try makeDraft(baseVersionId: "version-1") + + expectDomainError(.invalidDevelopmentRecordCurrentVersion) { + _ = try makeRecord(currentVersion: nil, draft: draft) + } + } + + @Test("현재 버전 기반 초안은 같은 현재 버전을 기준으로 가진다") + func 현재_버전_기반_초안은_같은_현재_버전을_기준으로_가진다() throws { + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-2", number: 2) + let draft = try makeDraft(baseVersionId: "version-2") + let record = try makeRecord(currentVersion: currentVersion, draft: draft) + + #expect(record.currentVersion == currentVersion) + #expect(record.draft == draft) + } + + @Test("확정된 기록은 초안 없이 현재 버전을 가진다") + func 확정된_기록은_초안_없이_현재_버전을_가진다() throws { + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-2", number: 2) + let record = try makeRecord(currentVersion: currentVersion, draft: nil) + + #expect(record.currentVersion == currentVersion) + #expect(record.draft == nil) + } + + @Test("현재 버전과 다른 기준 버전을 가진 초안은 거부한다") + func 현재_버전과_다른_기준_버전을_가진_초안은_거부한다() throws { + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-2", number: 2) + let draft = try makeDraft(baseVersionId: "version-1") + + expectDomainError(.invalidDevelopmentRecordCurrentVersion) { + _ = try makeRecord(currentVersion: currentVersion, draft: draft) + } + } + + @Test("현재 버전이 있는 기록은 기준 버전 없는 초안을 가질 수 없다") + func 현재_버전이_있는_기록은_기준_버전_없는_초안을_가질_수_없다() throws { + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-2", number: 2) + let draft = try makeDraft() + + expectDomainError(.invalidDevelopmentRecordCurrentVersion) { + _ = try makeRecord(currentVersion: currentVersion, draft: draft) + } + } + + @Test("최초 확정 버전은 출처를 가지지 않는다") + func 최초_확정_버전은_출처를_가지지_않는다() throws { + let version = try makeVersion(kind: .initial, sourceVersionId: nil) + + #expect(version.sourceVersionId == nil) + } + + @Test("정정과 되돌리기 버전은 출처를 가진다") + func 정정과_되돌리기_버전은_출처를_가진다() throws { + let correction = try makeVersion(kind: .correction, sourceVersionId: "version-1") + let rollback = try makeVersion(kind: .rollback, sourceVersionId: "version-1") + + #expect(correction.sourceVersionId == "version-1") + #expect(rollback.sourceVersionId == "version-1") + } + + @Test("버전 종류와 출처 관계가 맞지 않으면 거부한다") + func 버전_종류와_출처_관계가_맞지_않으면_거부한다() { + expectDomainError(.invalidDevelopmentRecordVersion) { + _ = try makeVersion(kind: .initial, sourceVersionId: "version-1") + } + expectDomainError(.invalidDevelopmentRecordVersion) { + _ = try makeVersion(kind: .correction, sourceVersionId: nil) + } + } + + @Test("최초 확정 버전은 1번이어야 한다") + func 최초_확정_버전은_1번이어야_한다() { + expectDomainError(.invalidDevelopmentRecordVersion) { + _ = try makeVersion(kind: .initial, sourceVersionId: nil, number: 2) + } + } + + @Test("정정과 되돌리기 버전은 2번부터 시작한다") + func 정정과_되돌리기_버전은_2번부터_시작한다() { + expectDomainError(.invalidDevelopmentRecordVersion) { + _ = try makeVersion(kind: .correction, sourceVersionId: "version-1", number: 1) + } + } +} + +private func makeDraft( + title: String = "기록 제목", + baseVersionId: String? = nil +) throws -> DevelopmentRecord.Draft { + try DevelopmentRecord.Draft( + title: title, + markdownContent: "기록 본문", + baseVersionId: baseVersionId, + updatedAt: .distantPast + ) +} + +private func makeRecord( + currentVersion: DevelopmentRecord.CurrentVersion?, + draft: DevelopmentRecord.Draft? +) throws -> DevelopmentRecord { + try DevelopmentRecord( + id: "record-1", + goalId: "goal-1", + currentVersion: currentVersion, + draft: draft, + createdAt: .distantPast + ) +} + +private func makeVersion( + kind: DevelopmentRecord.Version.Kind, + sourceVersionId: String?, + number: Int? = nil +) throws -> DevelopmentRecord.Version { + try DevelopmentRecord.Version( + id: "version-2", + recordId: "record-1", + number: number ?? (kind == .initial ? 1 : 2), + title: "확정 제목", + markdownContent: "확정 본문", + kind: kind, + sourceVersionId: sourceVersionId, + confirmedAt: .distantFuture + ) +} + +private func expectDomainError( + _ expected: DomainLayerError, + operation: () throws -> Void +) { + do { + try operation() + Issue.record("DomainLayerError가 필요") + } catch let error as DomainLayerError { + #expect(error == expected) + } catch { + Issue.record("예상하지 않은 오류: \(error)") + } +} diff --git a/Application/Domain/Tests/Entity/TodoGoalTests.swift b/Application/Domain/Tests/Entity/TodoGoalTests.swift new file mode 100644 index 00000000..a5ce67d4 --- /dev/null +++ b/Application/Domain/Tests/Entity/TodoGoalTests.swift @@ -0,0 +1,93 @@ +// +// TodoGoalTests.swift +// DomainTests +// +// Created by opfic on 8/27/26. +// + +import Foundation +import Testing +@testable import Domain + +struct TodoGoalTests { + @Test("Todo 생성자의 goalId 기본값은 nil이다") + func Todo_생성자의_goalId_기본값은_nil이다() { + let todo = Todo( + id: "todo-1", + isPinned: false, + isCompleted: false, + isChecked: false, + number: 1, + title: "할 일", + content: "내용", + createdAt: .distantPast, + updatedAt: .distantPast, + completedAt: nil, + deletedAt: nil, + dueDate: nil, + tags: [], + category: .system(.feature) + ) + + #expect(todo.goalId == nil) + } + + @Test("TodoDraft 생성자의 goalId 기본값은 nil이다") + func TodoDraft_생성자의_goalId_기본값은_nil이다() { + let draft = TodoDraft( + id: "todo-1", + isPinned: false, + isCompleted: false, + isChecked: false, + title: "할 일", + content: "내용", + createdAt: .distantPast, + updatedAt: .distantPast, + completedAt: nil, + dueDate: nil, + tags: [], + category: .system(.feature) + ) + + #expect(draft.goalId == nil) + } + + @Test("goalId 인자와 TodoDraft 복사는 연결 대상을 보존한다") + func goalId_인자와_TodoDraft_복사는_연결_대상을_보존한다() { + let todo = makeTodo(goalId: "goal-1") + let draft = TodoDraft(todo: todo) + + #expect(todo.goalId == "goal-1") + #expect(draft.goalId == "goal-1") + } + + @Test("TodoDraft 동등성 비교는 goalId 변경을 감지한다") + func TodoDraft_동등성_비교는_goalId_변경을_감지한다() { + let todo = makeTodo(goalId: "goal-1") + let linkedDraft = TodoDraft(todo: todo) + var unlinkedDraft = linkedDraft + unlinkedDraft.goalId = nil + + #expect(linkedDraft != unlinkedDraft) + } +} + +private func makeTodo(goalId: String? = nil) -> Todo { + Todo( + id: "todo-1", + isPinned: false, + isCompleted: false, + isChecked: false, + number: 1, + title: "할 일", + content: "내용", + createdAt: .distantPast, + updatedAt: .distantPast, + completedAt: nil, + deletedAt: nil, + dueDate: nil, + tags: [], + category: .system(.feature), + goalId: goalId + ) +} diff --git a/Application/Domain/Tests/UseCase/DevelopmentGoal/DevelopmentGoalUseCaseTests.swift b/Application/Domain/Tests/UseCase/DevelopmentGoal/DevelopmentGoalUseCaseTests.swift new file mode 100644 index 00000000..1d433888 --- /dev/null +++ b/Application/Domain/Tests/UseCase/DevelopmentGoal/DevelopmentGoalUseCaseTests.swift @@ -0,0 +1,316 @@ +// +// DevelopmentGoalUseCaseTests.swift +// DomainTests +// +// Created by opfic on 8/27/26. +// + +import Foundation +import Testing +@testable import Domain + +struct DevelopmentGoalUseCaseTests { + @Test("목표 생성은 주입한 ID와 입력값을 Repository에 전달한다") + func 목표_생성은_주입한_ID와_입력값을_Repository에_전달한다() async throws { + let goal = try makeGoal(id: "goal-1") + let repository = DevelopmentGoalRepositorySpy(goal: goal) + let useCase = CreateDevelopmentGoalUseCaseImpl(repository, idProvider: { "goal-1" }) + + let result = try await useCase.execute(title: "목표", description: "설명") + + #expect(result == goal) + #expect(await repository.createRequests() == [ + .init(id: "goal-1", title: "목표", description: "설명") + ]) + } + + @Test("단건 목표 조회는 Repository 결과를 반환한다") + func 단건_목표_조회는_Repository_결과를_반환한다() async throws { + let goal = try makeGoal(id: "goal-1") + let repository = DevelopmentGoalRepositorySpy(goal: goal) + let useCase = FetchDevelopmentGoalUseCaseImpl(repository) + + let result = try await useCase.execute("goal-1") + + #expect(result == goal) + #expect(await repository.fetchedGoalIds() == ["goal-1"]) + } + + @Test("목표 목록 조회는 상태를 필터링하고 생성 시각과 ID 오름차순으로 정렬한다") + func 목표_목록_조회는_상태를_필터링하고_생성_시각과_ID_오름차순으로_정렬한다() async throws { + let laterGoal = try makeGoal(id: "goal-3", createdAt: .distantFuture) + let archivedGoal = try makeGoal(id: "goal-2", status: .archived) + let earlierGoal = try makeGoal(id: "goal-1") + let repository = DevelopmentGoalRepositorySpy( + goal: earlierGoal, + goals: [laterGoal, archivedGoal, earlierGoal] + ) + let useCase = FetchDevelopmentGoalsUseCaseImpl(repository) + + let result = try await useCase.execute(.init(status: .inProgress)) + + #expect(result == [earlierGoal, laterGoal]) + #expect(await repository.queries() == [.init(status: .inProgress)]) + } + + @Test("진행 중 목표의 보관 전환은 완료 검증 문맥 없이 요청한다") + func 진행_중_목표의_보관_전환은_완료_검증_문맥_없이_요청한다() async throws { + let goal = try makeGoal(id: "goal-1") + let repository = DevelopmentGoalRepositorySpy(goal: goal) + let useCase = UpdateDevelopmentGoalStatusUseCaseImpl(repository) + + try await useCase.execute("goal-1", to: .archived) + + #expect(await repository.transitions() == [ + .init(goalId: "goal-1", status: .archived, completionSnapshot: nil) + ]) + } + + @Test("완료 전환은 유효한 전체 기록 문맥을 Repository에 전달한다") + func 완료_전환은_유효한_전체_기록_문맥을_Repository에_전달한다() async throws { + let goal = try makeGoal(id: "goal-1") + let record = try makeConfirmedRecord(id: "record-1", createdAt: .distantPast) + let snapshot = DevelopmentGoal.CompletionSnapshot(goal: goal, records: [record]) + let repository = DevelopmentGoalRepositorySpy(goal: goal, snapshot: snapshot) + let useCase = UpdateDevelopmentGoalStatusUseCaseImpl(repository) + + try await useCase.execute("goal-1", to: .completed) + + #expect(await repository.completionSnapshotGoalIds() == ["goal-1"]) + #expect(await repository.transitions() == [ + .init(goalId: "goal-1", status: .completed, completionSnapshot: snapshot) + ]) + } + + @Test("완료 스냅샷의 목표 상태가 전환을 허용하지 않으면 완료를 거부한다") + func 완료_스냅샷의_목표_상태가_전환을_허용하지_않으면_완료를_거부한다() async throws { + let goal = try makeGoal(id: "goal-1") + let archivedGoal = try makeGoal(id: "goal-1", status: .archived) + let record = try makeConfirmedRecord(id: "record-1", createdAt: .distantPast) + let snapshot = DevelopmentGoal.CompletionSnapshot(goal: archivedGoal, records: [record]) + let repository = DevelopmentGoalRepositorySpy(goal: goal, snapshot: snapshot) + let useCase = UpdateDevelopmentGoalStatusUseCaseImpl(repository) + + await expectDomainError(.invalidDevelopmentGoalTransition) { + try await useCase.execute("goal-1", to: .completed) + } + #expect(await repository.transitions().isEmpty) + } + + @Test("기록이 없으면 완료 전환을 거부한다") + func 기록이_없으면_완료_전환을_거부한다() async throws { + let goal = try makeGoal(id: "goal-1") + let snapshot = DevelopmentGoal.CompletionSnapshot(goal: goal, records: []) + let repository = DevelopmentGoalRepositorySpy(goal: goal, snapshot: snapshot) + let useCase = UpdateDevelopmentGoalStatusUseCaseImpl(repository) + + await expectDomainError(.developmentGoalCompletionRequiresRecord) { + try await useCase.execute("goal-1", to: .completed) + } + #expect(await repository.transitions().isEmpty) + } + + @Test("마지막 기록에 현재 버전이 없으면 완료 전환을 거부한다") + func 마지막_기록에_현재_버전이_없으면_완료_전환을_거부한다() async throws { + let goal = try makeGoal(id: "goal-1") + let record = try makeInitialDraftRecord(id: "record-1", createdAt: .distantPast) + let snapshot = DevelopmentGoal.CompletionSnapshot(goal: goal, records: [record]) + let repository = DevelopmentGoalRepositorySpy(goal: goal, snapshot: snapshot) + let useCase = UpdateDevelopmentGoalStatusUseCaseImpl(repository) + + await expectDomainError(.developmentGoalCompletionNeedsVersion) { + try await useCase.execute("goal-1", to: .completed) + } + #expect(await repository.transitions().isEmpty) + } + + @Test("남은 초안이 있으면 완료 전환을 거부한다") + func 남은_초안이_있으면_완료_전환을_거부한다() async throws { + let goal = try makeGoal(id: "goal-1") + let draftRecord = try makeDraftRecord(id: "record-1", createdAt: .distantPast) + let confirmedRecord = try makeConfirmedRecord(id: "record-2", createdAt: .distantFuture) + let snapshot = DevelopmentGoal.CompletionSnapshot( + goal: goal, + records: [confirmedRecord, draftRecord] + ) + let repository = DevelopmentGoalRepositorySpy(goal: goal, snapshot: snapshot) + let useCase = UpdateDevelopmentGoalStatusUseCaseImpl(repository) + + await expectDomainError(.developmentGoalCompletionHasDraft) { + try await useCase.execute("goal-1", to: .completed) + } + #expect(await repository.transitions().isEmpty) + } +} + +private actor DevelopmentGoalRepositorySpy: DevelopmentGoalRepository { + struct CreateRequest: Equatable { + let id: String + let title: String + let description: String + } + + struct TransitionRequest: Equatable { + let goalId: String + let status: DevelopmentGoal.Status + let completionSnapshot: DevelopmentGoal.CompletionSnapshot? + } + + private let goal: DevelopmentGoal + private let goals: [DevelopmentGoal] + private let snapshot: DevelopmentGoal.CompletionSnapshot? + private var recordedCreateRequests = [CreateRequest]() + private var recordedFetchedGoalIds = [String]() + private var recordedQueries = [DevelopmentGoal.Query]() + private var recordedCompletionSnapshotGoalIds = [String]() + private var recordedTransitions = [TransitionRequest]() + + init( + goal: DevelopmentGoal, + goals: [DevelopmentGoal]? = nil, + snapshot: DevelopmentGoal.CompletionSnapshot? = nil + ) { + self.goal = goal + self.goals = goals ?? [goal] + self.snapshot = snapshot + } + + func createGoal( + id: String, + title: String, + description: String + ) async throws -> DevelopmentGoal { + recordedCreateRequests.append(.init(id: id, title: title, description: description)) + return goal + } + + func fetchGoal(_ goalId: String) async throws -> DevelopmentGoal { + recordedFetchedGoalIds.append(goalId) + return goal + } + + func fetchGoals(_ query: DevelopmentGoal.Query) async throws -> [DevelopmentGoal] { + recordedQueries.append(query) + return goals + } + + func fetchCompletionSnapshot(for goalId: String) async throws -> DevelopmentGoal.CompletionSnapshot { + recordedCompletionSnapshotGoalIds.append(goalId) + guard let snapshot else { + throw DevelopmentGoalRepositorySpyError.snapshotNotFound + } + return snapshot + } + + func transitionGoalStatus( + _ goalId: String, + to status: DevelopmentGoal.Status, + completionSnapshot: DevelopmentGoal.CompletionSnapshot? + ) async throws { + recordedTransitions.append( + .init( + goalId: goalId, + status: status, + completionSnapshot: completionSnapshot + ) + ) + } + + func createRequests() -> [CreateRequest] { + recordedCreateRequests + } + + func fetchedGoalIds() -> [String] { + recordedFetchedGoalIds + } + + func queries() -> [DevelopmentGoal.Query] { + recordedQueries + } + + func completionSnapshotGoalIds() -> [String] { + recordedCompletionSnapshotGoalIds + } + + func transitions() -> [TransitionRequest] { + recordedTransitions + } +} + +private enum DevelopmentGoalRepositorySpyError: Error { + case snapshotNotFound +} + +private func makeGoal( + id: String, + status: DevelopmentGoal.Status = .inProgress, + createdAt: Date = .distantPast +) throws -> DevelopmentGoal { + try DevelopmentGoal( + id: id, + title: "개발 목표", + description: "설명", + status: status, + createdAt: createdAt, + updatedAt: createdAt, + completedAt: status == .completed ? createdAt : nil + ) +} + +private func makeInitialDraftRecord(id: String, createdAt: Date) throws -> DevelopmentRecord { + let draft = try DevelopmentRecord.Draft( + title: "기록", + markdownContent: "본문", + baseVersionId: nil, + updatedAt: createdAt + ) + return try DevelopmentRecord( + id: id, + goalId: "goal-1", + currentVersion: nil, + draft: draft, + createdAt: createdAt + ) +} + +private func makeConfirmedRecord(id: String, createdAt: Date) throws -> DevelopmentRecord { + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-\(id)", number: 1) + return try DevelopmentRecord( + id: id, + goalId: "goal-1", + currentVersion: currentVersion, + draft: nil, + createdAt: createdAt + ) +} + +private func makeDraftRecord(id: String, createdAt: Date) throws -> DevelopmentRecord { + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-\(id)", number: 1) + let draft = try DevelopmentRecord.Draft( + title: "정정 초안", + markdownContent: "본문", + baseVersionId: currentVersion.id, + updatedAt: createdAt + ) + return try DevelopmentRecord( + id: id, + goalId: "goal-1", + currentVersion: currentVersion, + draft: draft, + createdAt: createdAt + ) +} + +private func expectDomainError( + _ expected: DomainLayerError, + operation: () async throws -> Void +) async { + do { + try await operation() + Issue.record("DomainLayerError가 필요") + } catch let error as DomainLayerError { + #expect(error == expected) + } catch { + Issue.record("예상하지 않은 오류: \(error)") + } +} diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift new file mode 100644 index 00000000..0ef8827a --- /dev/null +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift @@ -0,0 +1,301 @@ +// +// DevelopmentRecordUseCaseTestSupport.swift +// DomainTests +// +// Created by opfic on 8/27/26. +// + +import Foundation +import Testing +@testable import Domain + +actor DevelopmentRecordGoalRepositorySpy: DevelopmentGoalRepository { + private let goal: DevelopmentGoal + + init(goal: DevelopmentGoal) { + self.goal = goal + } + + func createGoal( + id: String, + title: String, + description: String + ) async throws -> DevelopmentGoal { + goal + } + + func fetchGoal(_ goalId: String) async throws -> DevelopmentGoal { + goal + } + + func fetchGoals(_ query: DevelopmentGoal.Query) async throws -> [DevelopmentGoal] { + [goal] + } + + func fetchCompletionSnapshot(for goalId: String) async throws -> DevelopmentGoal.CompletionSnapshot { + .init(goal: goal, records: []) + } + + func transitionGoalStatus( + _ goalId: String, + to status: DevelopmentGoal.Status, + completionSnapshot: DevelopmentGoal.CompletionSnapshot? + ) async throws { } +} + +actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { + struct CreateRequest: Equatable { + let id: String + let goalId: String + let draft: DevelopmentRecord.Draft + } + + struct RecordQuery: Equatable { + let goalId: String + let recordId: String + } + + struct DraftRequest: Equatable { + let goalId: String + let recordId: String + let draft: DevelopmentRecord.Draft + } + + struct ConfirmRequest: Equatable { + let goalId: String + let recordId: String + let versionId: String + let kind: DevelopmentRecord.Version.Kind + let sourceVersionId: String? + } + + struct RestoreRequest: Equatable { + let goalId: String + let recordId: String + let versionId: String + let sourceVersionId: String + } + + private let createResult: DevelopmentRecord? + private let records: [DevelopmentRecord] + private let record: DevelopmentRecord? + private let versions: [DevelopmentRecord.Version] + private let savedRecord: DevelopmentRecord? + private let confirmedVersion: DevelopmentRecord.Version? + private let restoredVersion: DevelopmentRecord.Version? + private var recordedCreateRequests = [CreateRequest]() + private var recordedRecordQueries = [String]() + private var recordedVersionQueries = [RecordQuery]() + private var recordedDraftRequests = [DraftRequest]() + private var recordedConfirmRequests = [ConfirmRequest]() + private var recordedRestoreRequests = [RestoreRequest]() + + init( + createResult: DevelopmentRecord? = nil, + records: [DevelopmentRecord] = [], + record: DevelopmentRecord? = nil, + versions: [DevelopmentRecord.Version] = [], + savedRecord: DevelopmentRecord? = nil, + confirmedVersion: DevelopmentRecord.Version? = nil, + restoredVersion: DevelopmentRecord.Version? = nil + ) { + self.createResult = createResult + self.records = records + self.record = record + self.versions = versions + self.savedRecord = savedRecord + self.confirmedVersion = confirmedVersion + self.restoredVersion = restoredVersion + } + + func createRecord( + id: String, + goalId: String, + draft: DevelopmentRecord.Draft + ) async throws -> DevelopmentRecord { + recordedCreateRequests.append(.init(id: id, goalId: goalId, draft: draft)) + return try requiredDevelopmentRecordRepositoryResult(createResult) + } + + func fetchRecords(goalId: String) async throws -> [DevelopmentRecord] { + recordedRecordQueries.append(goalId) + return records + } + + func fetchRecord(goalId: String, recordId: String) async throws -> DevelopmentRecord { + try requiredDevelopmentRecordRepositoryResult(record) + } + + func fetchVersions(goalId: String, recordId: String) async throws -> [DevelopmentRecord.Version] { + recordedVersionQueries.append(.init(goalId: goalId, recordId: recordId)) + return versions + } + + func saveDraft( + goalId: String, + recordId: String, + draft: DevelopmentRecord.Draft + ) async throws -> DevelopmentRecord { + recordedDraftRequests.append(.init(goalId: goalId, recordId: recordId, draft: draft)) + return try requiredDevelopmentRecordRepositoryResult(savedRecord) + } + + func confirmDraft( + goalId: String, + recordId: String, + versionId: String, + kind: DevelopmentRecord.Version.Kind, + sourceVersionId: String? + ) async throws -> DevelopmentRecord.Version { + recordedConfirmRequests.append( + .init( + goalId: goalId, + recordId: recordId, + versionId: versionId, + kind: kind, + sourceVersionId: sourceVersionId + ) + ) + return try requiredDevelopmentRecordRepositoryResult(confirmedVersion) + } + + func restoreVersion( + goalId: String, + recordId: String, + versionId: String, + sourceVersionId: String + ) async throws -> DevelopmentRecord.Version { + recordedRestoreRequests.append( + .init( + goalId: goalId, recordId: recordId, versionId: versionId, sourceVersionId: sourceVersionId) + ) + return try requiredDevelopmentRecordRepositoryResult(restoredVersion) + } + + func createRequests() -> [CreateRequest] { + recordedCreateRequests + } + + func recordQueries() -> [String] { + recordedRecordQueries + } + + func versionQueries() -> [RecordQuery] { + recordedVersionQueries + } + + func draftRequests() -> [DraftRequest] { + recordedDraftRequests + } + + func confirmRequests() -> [ConfirmRequest] { + recordedConfirmRequests + } + + func restoreRequests() -> [RestoreRequest] { + recordedRestoreRequests + } +} + +enum DevelopmentRecordRepositorySpyError: Error { + case unconfigured +} + +func requiredDevelopmentRecordRepositoryResult(_ value: Value?) throws -> Value { + guard let value else { + throw DevelopmentRecordRepositorySpyError.unconfigured + } + return value +} + +func makeDevelopmentRecordGoal( + status: DevelopmentGoal.Status = .inProgress +) throws -> DevelopmentGoal { + try DevelopmentGoal( + id: "goal-1", + title: "개발 목표", + description: "설명", + status: status, + createdAt: .distantPast, + updatedAt: .distantPast, + completedAt: status == .completed ? .distantPast : nil + ) +} + +func makeDevelopmentRecordInitialDraft( + updatedAt: Date = .distantPast +) throws -> DevelopmentRecord { + try makeDevelopmentRecord( + currentVersion: nil, + draft: DevelopmentRecord.Draft( + title: "기록", + markdownContent: "본문", + baseVersionId: nil, + updatedAt: updatedAt + ) + ) +} + +func makeDevelopmentRecordConfirmed() throws -> DevelopmentRecord { + try makeDevelopmentRecord( + currentVersion: DevelopmentRecord.CurrentVersion(id: "version-1", number: 1), + draft: nil + ) +} + +func makeDevelopmentRecord( + currentVersion: DevelopmentRecord.CurrentVersion?, + draft: DevelopmentRecord.Draft? +) throws -> DevelopmentRecord { + try DevelopmentRecord( + id: "record-1", + goalId: "goal-1", + currentVersion: currentVersion, + draft: draft, + createdAt: .distantPast + ) +} + +func makeDevelopmentRecordInitialVersion() throws -> DevelopmentRecord.Version { + try DevelopmentRecord.Version( + id: "version-1", + recordId: "record-1", + number: 1, + title: "기록", + markdownContent: "본문", + kind: .initial, + sourceVersionId: nil, + confirmedAt: .distantPast + ) +} + +func makeDevelopmentRecordCorrectionVersion( + id: String, + number: Int = 2, + sourceVersionId: String +) throws -> DevelopmentRecord.Version { + try DevelopmentRecord.Version( + id: id, + recordId: "record-1", + number: number, + title: "정정 기록", + markdownContent: "본문", + kind: .correction, + sourceVersionId: sourceVersionId, + confirmedAt: .distantPast + ) +} + +func expectDevelopmentRecordDomainError( + _ expected: DomainLayerError, + operation: () async throws -> Value +) async { + do { + _ = try await operation() + Issue.record("DomainLayerError가 필요") + } catch let error as DomainLayerError { + #expect(error == expected) + } catch { + Issue.record("예상하지 않은 오류: \(error)") + } +} diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift new file mode 100644 index 00000000..56a1d324 --- /dev/null +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift @@ -0,0 +1,400 @@ +// +// DevelopmentRecordUseCaseTests.swift +// DomainTests +// +// Created by opfic on 8/27/26. +// + +import Foundation +import Testing +@testable import Domain + +struct DevelopmentRecordUseCaseTests { + @Test("진행 중 목표에서 최초 Draft와 함께 기록을 생성한다") + func 진행_중_목표에서_최초_Draft와_함께_기록을_생성한다() async throws { + let goal = try makeDevelopmentRecordGoal() + let createdAt = Date(timeIntervalSinceReferenceDate: 10) + let record = try makeDevelopmentRecordInitialDraft(updatedAt: createdAt) + let repository = DevelopmentRecordRepositorySpy(createResult: record) + let useCase = CreateDevelopmentRecordUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal), + idProvider: { "record-1" }, + now: { createdAt } + ) + let result = try await useCase.execute( + goalId: "goal-1", + title: "기록", + markdownContent: "본문" + ) + + #expect(result == record) + #expect(await repository.createRequests() == [ + .init( + id: "record-1", + goalId: "goal-1", + draft: try DevelopmentRecord.Draft( + title: "기록", + markdownContent: "본문", + baseVersionId: nil, + updatedAt: createdAt + ) + ) + ]) + } + + @Test("완료 또는 보관 목표에서 기록 생성을 거부한다") + func 완료_또는_보관_목표에서_기록_생성을_거부한다() async throws { + for status in [DevelopmentGoal.Status.completed, .archived] { + let repository = DevelopmentRecordRepositorySpy() + let useCase = CreateDevelopmentRecordUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: try makeDevelopmentRecordGoal(status: status)) + ) + await expectDevelopmentRecordDomainError(.developmentGoalIsNotInProgress) { + try await useCase.execute( + goalId: "goal-1", + title: "기록", + markdownContent: "본문" + ) + } + #expect(await repository.createRequests().isEmpty) + } + } + + @Test("기록 목록 조회는 Repository 결과를 반환한다") + func 기록_목록_조회는_Repository_결과를_반환한다() async throws { + let record = try makeDevelopmentRecordInitialDraft() + let repository = DevelopmentRecordRepositorySpy(records: [record]) + let useCase = FetchDevelopmentRecordsUseCaseImpl(repository) + let result = try await useCase.execute(goalId: "goal-1") + #expect(result == [record]) + #expect(await repository.recordQueries() == ["goal-1"]) + } + + @Test("기록 이력 조회는 Repository 결과를 반환한다") + func 기록_이력_조회는_Repository_결과를_반환한다() async throws { + let initialVersion = try makeDevelopmentRecordInitialVersion() + let repository = DevelopmentRecordRepositorySpy(versions: [initialVersion]) + let useCase = FetchDevelopmentRecordHistoryUseCaseImpl(repository) + let result = try await useCase.execute(goalId: "goal-1", recordId: "record-1") + #expect(result == [initialVersion]) + #expect(await repository.versionQueries() == [ + .init(goalId: "goal-1", recordId: "record-1") + ]) + } + + @Test("최초 Draft 저장은 기준 버전 없이 갱신한다") + func 최초_Draft_저장은_기준_버전_없이_갱신한다() async throws { + let goal = try makeDevelopmentRecordGoal() + let record = try makeDevelopmentRecordInitialDraft() + let savedRecord = try makeDevelopmentRecordInitialDraft() + let updatedAt = Date(timeIntervalSinceReferenceDate: 20) + let repository = DevelopmentRecordRepositorySpy(record: record, savedRecord: savedRecord) + let useCase = SaveDevelopmentRecordDraftUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal), + now: { updatedAt } + ) + let result = try await useCase.execute( + goalId: "goal-1", + recordId: "record-1", + title: "수정 기록", + markdownContent: "수정 본문" + ) + + #expect(result == savedRecord) + #expect(await repository.draftRequests() == [ + .init( + goalId: "goal-1", + recordId: "record-1", + draft: try DevelopmentRecord.Draft( + title: "수정 기록", + markdownContent: "수정 본문", + baseVersionId: nil, + updatedAt: updatedAt + ) + ) + ]) + } + + @Test("확정 버전 기반 Draft 저장은 기준 버전을 유지한다") + func 확정_버전_기반_Draft_저장은_기준_버전을_유지한다() async throws { + let goal = try makeDevelopmentRecordGoal() + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-1", number: 1) + let record = try makeDevelopmentRecord( + currentVersion: currentVersion, + draft: DevelopmentRecord.Draft( + title: "정정 초안", + markdownContent: "본문", + baseVersionId: currentVersion.id, + updatedAt: .distantPast + ) + ) + let savedRecord = try makeDevelopmentRecord( + currentVersion: currentVersion, + draft: DevelopmentRecord.Draft( + title: "수정 정정 초안", + markdownContent: "수정 본문", + baseVersionId: currentVersion.id, + updatedAt: .distantFuture + ) + ) + let updatedAt = Date(timeIntervalSinceReferenceDate: 30) + let repository = DevelopmentRecordRepositorySpy(record: record, savedRecord: savedRecord) + let useCase = SaveDevelopmentRecordDraftUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal), + now: { updatedAt } + ) + _ = try await useCase.execute( + goalId: "goal-1", + recordId: "record-1", + title: "수정 정정 초안", + markdownContent: "수정 본문" + ) + + let requests = await repository.draftRequests() + #expect(requests.first?.draft.baseVersionId == currentVersion.id) + #expect(requests.first?.draft.updatedAt == updatedAt) + } + + @Test("확정 기록의 저장은 현재 버전 기준 정정 Draft를 시작한다") + func 확정_기록의_저장은_현재_버전_기준_정정_Draft를_시작한다() async throws { + let goal = try makeDevelopmentRecordGoal() + let record = try makeDevelopmentRecordConfirmed() + let currentVersion = try #require(record.currentVersion) + let savedRecord = try makeDevelopmentRecord( + currentVersion: currentVersion, + draft: DevelopmentRecord.Draft( + title: "정정 초안", + markdownContent: "본문", + baseVersionId: currentVersion.id, + updatedAt: .distantFuture + ) + ) + let updatedAt = Date(timeIntervalSinceReferenceDate: 40) + let repository = DevelopmentRecordRepositorySpy(record: record, savedRecord: savedRecord) + let useCase = SaveDevelopmentRecordDraftUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal), + now: { updatedAt } + ) + let result = try await useCase.execute( + goalId: "goal-1", + recordId: "record-1", + title: "정정 초안", + markdownContent: "본문" + ) + + #expect(result == savedRecord) + #expect(await repository.draftRequests() == [ + .init( + goalId: "goal-1", + recordId: "record-1", + draft: try DevelopmentRecord.Draft( + title: "정정 초안", + markdownContent: "본문", + baseVersionId: currentVersion.id, + updatedAt: updatedAt + ) + ) + ]) + } + + @Test("최초 Draft 확정은 initial 버전으로 요청한다") + func 최초_Draft_확정은_initial_버전으로_요청한다() async throws { + let goal = try makeDevelopmentRecordGoal() + let record = try makeDevelopmentRecordInitialDraft() + let version = try makeDevelopmentRecordInitialVersion() + let repository = DevelopmentRecordRepositorySpy(record: record, confirmedVersion: version) + let useCase = ConfirmDevelopmentRecordUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal), + idProvider: { "version-1" } + ) + + let result = try await useCase.execute(goalId: "goal-1", recordId: "record-1") + + #expect(result == version) + #expect(await repository.confirmRequests() == [ + .init( + goalId: "goal-1", + recordId: "record-1", + versionId: "version-1", + kind: .initial, + sourceVersionId: nil + ) + ]) + } + + @Test("확정 버전 기반 Draft 확정은 correction 버전으로 요청한다") + func 확정_버전_기반_Draft_확정은_correction_버전으로_요청한다() async throws { + let goal = try makeDevelopmentRecordGoal() + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-1", number: 1) + let record = try makeDevelopmentRecord( + currentVersion: currentVersion, + draft: DevelopmentRecord.Draft( + title: "정정 초안", + markdownContent: "본문", + baseVersionId: currentVersion.id, + updatedAt: .distantPast + ) + ) + let version = try makeDevelopmentRecordCorrectionVersion(id: "version-2", sourceVersionId: currentVersion.id) + let repository = DevelopmentRecordRepositorySpy(record: record, confirmedVersion: version) + let useCase = ConfirmDevelopmentRecordUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal), + idProvider: { "version-2" } + ) + + _ = try await useCase.execute(goalId: "goal-1", recordId: "record-1") + + #expect(await repository.confirmRequests() == [ + .init( + goalId: "goal-1", + recordId: "record-1", + versionId: "version-2", + kind: .correction, + sourceVersionId: currentVersion.id + ) + ]) + } + + @Test("Draft가 없는 기록의 확정을 거부한다") + func Draft가_없는_기록의_확정을_거부한다() async throws { + let goal = try makeDevelopmentRecordGoal() + let repository = DevelopmentRecordRepositorySpy(record: try makeDevelopmentRecordConfirmed()) + let useCase = ConfirmDevelopmentRecordUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal) + ) + + await expectDevelopmentRecordDomainError(.developmentRecordDraftNotFound) { + try await useCase.execute(goalId: "goal-1", recordId: "record-1") + } + #expect(await repository.confirmRequests().isEmpty) + } + + @Test("같은 기록의 과거 버전은 rollback 버전으로 되돌린다") + func 같은_기록의_과거_버전은_rollback_버전으로_되돌린다() async throws { + let goal = try makeDevelopmentRecordGoal() + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-3", number: 3) + let record = try makeDevelopmentRecord(currentVersion: currentVersion, draft: nil) + let initialVersion = try makeDevelopmentRecordInitialVersion() + let correctionVersion = try makeDevelopmentRecordCorrectionVersion( + id: "version-2", + sourceVersionId: "version-1" + ) + let currentRecordVersion = try makeDevelopmentRecordCorrectionVersion( + id: "version-3", + number: 3, + sourceVersionId: "version-2" + ) + let rollbackVersion = try DevelopmentRecord.Version( + id: "version-4", + recordId: "record-1", + number: 4, + title: "되돌린 기록", + markdownContent: "본문", + kind: .rollback, + sourceVersionId: "version-1", + confirmedAt: .distantFuture + ) + let repository = DevelopmentRecordRepositorySpy( + record: record, + versions: [initialVersion, correctionVersion, currentRecordVersion], + restoredVersion: rollbackVersion + ) + let useCase = RestoreDevelopmentRecordUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal), + idProvider: { "version-4" } + ) + let result = try await useCase.execute( + goalId: "goal-1", + recordId: "record-1", + sourceVersionId: "version-1" + ) + #expect(result == rollbackVersion) + #expect(await repository.restoreRequests() == [ + .init( + goalId: "goal-1", + recordId: "record-1", + versionId: "version-4", + sourceVersionId: "version-1" + ) + ]) + } + + @Test("현재 또는 다른 기록의 버전은 되돌리기 대상으로 거부한다") + func 현재_또는_다른_기록의_버전은_되돌리기_대상으로_거부한다() async throws { + let goal = try makeDevelopmentRecordGoal() + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-2", number: 2) + let record = try makeDevelopmentRecord(currentVersion: currentVersion, draft: nil) + let currentRecordVersion = try makeDevelopmentRecordCorrectionVersion( + id: "version-2", + number: 2, + sourceVersionId: "version-1" + ) + let otherRecordVersion = try DevelopmentRecord.Version( + id: "other-version-1", + recordId: "record-2", + number: 1, + title: "다른 기록", + markdownContent: "본문", + kind: .initial, + sourceVersionId: nil, + confirmedAt: .distantPast + ) + for sourceVersionId in [currentRecordVersion.id, otherRecordVersion.id] { + let repository = DevelopmentRecordRepositorySpy( + record: record, + versions: [currentRecordVersion, otherRecordVersion] + ) + let useCase = RestoreDevelopmentRecordUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal) + ) + + await expectDevelopmentRecordDomainError(.developmentRecordVersionNotFound) { + try await useCase.execute( + goalId: "goal-1", + recordId: "record-1", + sourceVersionId: sourceVersionId + ) + } + #expect(await repository.restoreRequests().isEmpty) + } + } + + @Test("Draft가 있으면 버전 되돌리기를 거부한다") + func Draft가_있으면_버전_되돌리기를_거부한다() async throws { + let goal = try makeDevelopmentRecordGoal() + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-1", number: 1) + let record = try makeDevelopmentRecord( + currentVersion: currentVersion, + draft: DevelopmentRecord.Draft( + title: "정정 초안", + markdownContent: "본문", + baseVersionId: currentVersion.id, + updatedAt: .distantPast + ) + ) + let repository = DevelopmentRecordRepositorySpy(record: record) + let useCase = RestoreDevelopmentRecordUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal) + ) + + await expectDevelopmentRecordDomainError(.developmentRecordDraftConflict) { + try await useCase.execute( + goalId: "goal-1", + recordId: "record-1", + sourceVersionId: "version-1" + ) + } + #expect(await repository.restoreRequests().isEmpty) + } +} diff --git a/Application/Domain/Tests/UseCase/Todo/UpdateTodoGoalUseCaseImplTests.swift b/Application/Domain/Tests/UseCase/Todo/UpdateTodoGoalUseCaseImplTests.swift new file mode 100644 index 00000000..47ce1194 --- /dev/null +++ b/Application/Domain/Tests/UseCase/Todo/UpdateTodoGoalUseCaseImplTests.swift @@ -0,0 +1,181 @@ +// +// UpdateTodoGoalUseCaseImplTests.swift +// DomainTests +// +// Created by opfic on 8/27/26. +// + +import Foundation +import Testing +import Core +@testable import Domain + +struct UpdateTodoGoalUseCaseImplTests { + @Test("목표 연결은 목표를 확인한 뒤 Todo를 저장한다") + func 목표_연결은_목표를_확인한_뒤_Todo를_저장한다() async throws { + let todoRepository = TodoRepositorySpy(todo: makeTodo()) + let goalRepository = DevelopmentGoalRepositorySpy(goal: try makeGoal(id: "goal-1")) + let useCase = UpdateTodoGoalUseCaseImpl(todoRepository, goalRepository) + + try await useCase.execute(todoId: "todo-1", goalId: "goal-1") + + #expect(await goalRepository.fetchedGoalIds() == ["goal-1"]) + #expect(await todoRepository.savedTodos().map(\.goalId) == ["goal-1"]) + } + + @Test("목표 변경은 기존 Todo의 연결 대상을 대체한다") + func 목표_변경은_기존_Todo의_연결_대상을_대체한다() async throws { + let todoRepository = TodoRepositorySpy(todo: makeTodo(goalId: "goal-1")) + let goalRepository = DevelopmentGoalRepositorySpy(goal: try makeGoal(id: "goal-2")) + let useCase = UpdateTodoGoalUseCaseImpl(todoRepository, goalRepository) + + try await useCase.execute(todoId: "todo-1", goalId: "goal-2") + + #expect(await todoRepository.savedTodos().map(\.goalId) == ["goal-2"]) + } + + @Test("목표 해제는 목표 조회 없이 Todo를 저장한다") + func 목표_해제는_목표_조회_없이_Todo를_저장한다() async throws { + let todoRepository = TodoRepositorySpy(todo: makeTodo(goalId: "goal-1")) + let goalRepository = DevelopmentGoalRepositorySpy(goal: try makeGoal(id: "goal-1")) + let useCase = UpdateTodoGoalUseCaseImpl(todoRepository, goalRepository) + + try await useCase.execute(todoId: "todo-1", goalId: nil) + + #expect(await goalRepository.fetchedGoalIds().isEmpty) + #expect(await todoRepository.savedTodos().map(\.goalId) == [nil]) + } + + @Test("존재하지 않는 목표는 Todo 저장 전에 거부한다") + func 존재하지_않는_목표는_Todo_저장_전에_거부한다() async throws { + let todoRepository = TodoRepositorySpy(todo: makeTodo()) + let goalRepository = DevelopmentGoalRepositorySpy(error: .notFound) + let useCase = UpdateTodoGoalUseCaseImpl(todoRepository, goalRepository) + + await #expect(throws: DevelopmentGoalRepositorySpyError.notFound) { + try await useCase.execute(todoId: "todo-1", goalId: "missing-goal") + } + #expect(await todoRepository.savedTodos().isEmpty) + } +} + +private actor TodoRepositorySpy: TodoRepository { + private let todo: Todo + private var recordedSavedTodos = [Todo]() + + init(todo: Todo) { + self.todo = todo + } + + func fetchTodos(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { + fatalError() + } + + func fetchTodo(_ todoId: String) async throws -> Todo { + todo + } + + func fetchReferences(_ numbers: [Int]) async throws -> [Int: TodoReference] { + fatalError() + } + + func upsertTodo(_ todo: Todo) async throws { + recordedSavedTodos.append(todo) + } + + func upsertTodo(_ todoDraft: TodoDraft) async throws { + fatalError() + } + + func deleteTodo(_ todoId: String) async throws { + fatalError() + } + + func undoDeleteTodo(_ todoId: String) async throws { + fatalError() + } + + func savedTodos() -> [Todo] { + recordedSavedTodos + } +} + +private actor DevelopmentGoalRepositorySpy: DevelopmentGoalRepository { + private let result: Result + private var recordedFetchedGoalIds = [String]() + + init(goal: DevelopmentGoal) { + self.result = .success(goal) + } + + init(error: DevelopmentGoalRepositorySpyError) { + self.result = .failure(error) + } + + func createGoal( + id: String, + title: String, + description: String + ) async throws -> DevelopmentGoal { + try result.get() + } + + func fetchGoal(_ goalId: String) async throws -> DevelopmentGoal { + recordedFetchedGoalIds.append(goalId) + return try result.get() + } + + func fetchGoals(_ query: DevelopmentGoal.Query) async throws -> [DevelopmentGoal] { + [try result.get()] + } + + func fetchCompletionSnapshot(for goalId: String) async throws -> DevelopmentGoal.CompletionSnapshot { + .init(goal: try result.get(), records: []) + } + + func transitionGoalStatus( + _ goalId: String, + to status: DevelopmentGoal.Status, + completionSnapshot: DevelopmentGoal.CompletionSnapshot? + ) async throws { } + + func fetchedGoalIds() -> [String] { + recordedFetchedGoalIds + } +} + +private enum DevelopmentGoalRepositorySpyError: Error, Equatable { + case notFound +} + +private func makeTodo(goalId: String? = nil) -> Todo { + Todo( + id: "todo-1", + isPinned: false, + isCompleted: false, + isChecked: false, + number: 1, + title: "할 일", + content: "내용", + createdAt: .distantPast, + updatedAt: .distantPast, + completedAt: nil, + deletedAt: nil, + dueDate: nil, + tags: [], + category: .system(.feature), + goalId: goalId + ) +} + +private func makeGoal(id: String) throws -> DevelopmentGoal { + try DevelopmentGoal( + id: id, + title: "개발 목표", + description: "설명", + status: .inProgress, + createdAt: .distantPast, + updatedAt: .distantPast, + completedAt: nil + ) +}