From 8b6eac5ced04712b5b72ce149a31e3171b3504a9 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 27 Aug 2026 20:39:25 +0900 Subject: [PATCH 01/10] =?UTF-8?q?chore:=20.agents=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EB=AC=BC=20=EC=B6=94=EC=A0=81=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/specs/823-pr-826-review-follow-up.md | 50 -------------- .agents/specs/835-collection-ui-removal.md | 68 -------------------- .agents/specs/README.md | 56 ---------------- .gitignore | 2 + 4 files changed, 2 insertions(+), 174 deletions(-) delete mode 100644 .agents/specs/823-pr-826-review-follow-up.md delete mode 100644 .agents/specs/835-collection-ui-removal.md delete mode 100644 .agents/specs/README.md 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/.agents/specs/README.md b/.agents/specs/README.md deleted file mode 100644 index 0f9cc1c4..00000000 --- a/.agents/specs/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# DevLog Spec Format - -`Designer Result`를 사용자가 승인한 뒤, Planner가 비단순 설계 또는 구현 작업마다 이 디렉터리에 Spec을 작성한다. 이슈 기반 작업은 `-.md`, 이슈 없는 사용자 요청은 `user--.md` 형식을 사용한다. - -## Responsibility - -- `Design Brief`는 Planner가 요청, 현재 상태, 범위, 제외 범위, 알려진 제약을 Designer에게 전달하는 입력이다. -- `Designer Result`는 Designer가 제약, 대안, 변경 경계, 수용 기준, 검증, 최소 커밋 단위를 분석한 승인 대기 결과다. -- Spec은 사용자가 승인한 `Designer Result`를 영속화한 구현·검토·검증의 공통 기준이다. -- `Task Packet`은 승인된 Spec 경로와 수용 기준을 참조하고, 현재 작업의 역할 배정과 실행 권한을 전달한다. - -## Required format - -```md -# - -- Source: -- Approved Designer Result: -- User approval: - -## Constraints - -- - -## Alternatives and decision - -- - -## Changed boundaries - -- - -## Acceptance criteria - -- [ ] - -## Verification - -- Command: -- Evidence: - -## Minimum commit units - -1. - -## Execution constraints - -- app or Simulator execution: -- External writes: -- CI or PR actions: -``` - -## Change control - -- 구현 중 요구 사항 또는 범위가 바뀌면 Spec을 수정하고 사용자 재승인을 받은 뒤에만 `Task Packet`과 구현을 갱신한다. -- Spec은 동작과 수용 기준, 금지된 실행을 기록한다. `Task Packet`은 역할별 현재 작업 권한과 실제 검증 명령을 별도로 기록한다. diff --git a/.gitignore b/.gitignore index 9d415a0f..3dff74b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ ## AI docs/superpowers +.agents/specs/ +.agents/plans/ # MACOS .DS_Store From 83382791ed9af8736d1b639f32203e74a9682097 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 27 Aug 2026 20:54:05 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20=EA=B0=9C=EB=B0=9C=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=C2=B7=EA=B0=9C=EB=B0=9C=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20Domain=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Entity/DevelopmentGoal.swift | 87 ++++++++ .../Sources/Entity/DevelopmentRecord.swift | 151 ++++++++++++++ .../Sources/Entity/DomainLayerError.swift | 14 +- .../Tests/Entity/DevelopmentGoalTests.swift | 107 ++++++++++ .../Tests/Entity/DevelopmentRecordTests.swift | 190 ++++++++++++++++++ 5 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 Application/Domain/Sources/Entity/DevelopmentGoal.swift create mode 100644 Application/Domain/Sources/Entity/DevelopmentRecord.swift create mode 100644 Application/Domain/Tests/Entity/DevelopmentGoalTests.swift create mode 100644 Application/Domain/Tests/Entity/DevelopmentRecordTests.swift diff --git a/Application/Domain/Sources/Entity/DevelopmentGoal.swift b/Application/Domain/Sources/Entity/DevelopmentGoal.swift new file mode 100644 index 00000000..c3658ad4 --- /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 markdownDescription: String + public let status: Status + public let createdAt: Date + public let updatedAt: Date + public let completedAt: Date? + + public init( + id: String, + title: String, + markdownDescription: String, + status: Status, + createdAt: Date, + updatedAt: Date, + completedAt: Date? + ) throws { + guard title.containsMeaningfulCharacter else { + throw DomainLayerError.invalidDevelopmentGoalTitle + } + + self.id = id + self.title = title + self.markdownDescription = markdownDescription + 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..9ccce9e3 --- /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/Tests/Entity/DevelopmentGoalTests.swift b/Application/Domain/Tests/Entity/DevelopmentGoalTests.swift new file mode 100644 index 00000000..8dd10dda --- /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, + markdownDescription: "설명", + 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..54ae3701 --- /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)") + } +} From 3e3e430685f6cefaa2000325fad36a6465e13c3a Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 27 Aug 2026 21:15:46 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=EA=B0=9C=EB=B0=9C=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=20=EA=B4=80=EB=A0=A8=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Sources/DomainAssembler.swift | 21 ++ .../Protocol/DevelopmentGoalRepository.swift | 22 ++ .../Create/CreateDevelopmentGoalUseCase.swift | 10 + .../CreateDevelopmentGoalUseCaseImpl.swift | 29 ++ .../Fetch/FetchDevelopmentGoalUseCase.swift | 10 + .../FetchDevelopmentGoalUseCaseImpl.swift | 18 ++ .../Fetch/FetchDevelopmentGoalsUseCase.swift | 10 + .../FetchDevelopmentGoalsUseCaseImpl.swift | 27 ++ .../UpdateDevelopmentGoalStatusUseCase.swift | 10 + ...dateDevelopmentGoalStatusUseCaseImpl.swift | 62 ++++ .../DevelopmentGoalUseCaseTests.swift | 301 ++++++++++++++++++ 11 files changed, 520 insertions(+) create mode 100644 Application/Domain/Sources/Protocol/DevelopmentGoalRepository.swift create mode 100644 Application/Domain/Sources/UseCase/DevelopmentGoal/Create/CreateDevelopmentGoalUseCase.swift create mode 100644 Application/Domain/Sources/UseCase/DevelopmentGoal/Create/CreateDevelopmentGoalUseCaseImpl.swift create mode 100644 Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalUseCase.swift create mode 100644 Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalUseCaseImpl.swift create mode 100644 Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalsUseCase.swift create mode 100644 Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalsUseCaseImpl.swift create mode 100644 Application/Domain/Sources/UseCase/DevelopmentGoal/Transition/UpdateDevelopmentGoalStatusUseCase.swift create mode 100644 Application/Domain/Sources/UseCase/DevelopmentGoal/Transition/UpdateDevelopmentGoalStatusUseCaseImpl.swift create mode 100644 Application/Domain/Tests/UseCase/DevelopmentGoal/DevelopmentGoalUseCaseTests.swift diff --git a/Application/Domain/Sources/DomainAssembler.swift b/Application/Domain/Sources/DomainAssembler.swift index bb5f654f..b957f802 100644 --- a/Application/Domain/Sources/DomainAssembler.swift +++ b/Application/Domain/Sources/DomainAssembler.swift @@ -16,6 +16,7 @@ public final class DomainAssembler: Assembler { registerAuthUseCases(container) registerConnectivityUseCases(container) registerAuthProviderUseCases(container) + registerDevelopmentGoalUseCases(container) registerTodoUseCases(container) registerTodoCategoryUseCases(container) registerUserDataUseCases(container) @@ -26,6 +27,26 @@ 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 registerAppUpdateUseCases(_ container: any DIContainer) { container.register(CheckAppUpdateUseCase.self) { CheckAppUpdateUseCaseImpl(container.resolve(AppVersionRepository.self)) diff --git a/Application/Domain/Sources/Protocol/DevelopmentGoalRepository.swift b/Application/Domain/Sources/Protocol/DevelopmentGoalRepository.swift new file mode 100644 index 00000000..66fa9c32 --- /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, + markdownDescription: 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/UseCase/DevelopmentGoal/Create/CreateDevelopmentGoalUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentGoal/Create/CreateDevelopmentGoalUseCase.swift new file mode 100644 index 00000000..3c0b8598 --- /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, markdownDescription: 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..bf655833 --- /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, markdownDescription: String) async throws -> DevelopmentGoal { + try await repository.createGoal( + id: idProvider(), + title: title, + markdownDescription: markdownDescription + ) + } +} 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..2696d922 --- /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..5082935a --- /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..cae59e26 --- /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..4a380002 --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentGoal/Transition/UpdateDevelopmentGoalStatusUseCaseImpl.swift @@ -0,0 +1,62 @@ +// +// 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 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/Tests/UseCase/DevelopmentGoal/DevelopmentGoalUseCaseTests.swift b/Application/Domain/Tests/UseCase/DevelopmentGoal/DevelopmentGoalUseCaseTests.swift new file mode 100644 index 00000000..8ee99aaa --- /dev/null +++ b/Application/Domain/Tests/UseCase/DevelopmentGoal/DevelopmentGoalUseCaseTests.swift @@ -0,0 +1,301 @@ +// +// 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: "목표", markdownDescription: "설명") + + #expect(result == goal) + #expect(await repository.createRequests() == [ + .init(id: "goal-1", title: "목표", markdownDescription: "설명") + ]) + } + + @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 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 markdownDescription: 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, + markdownDescription: String + ) async throws -> DevelopmentGoal { + recordedCreateRequests.append(.init(id: id, title: title, markdownDescription: markdownDescription)) + 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: "개발 목표", + markdownDescription: "설명", + 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)") + } +} From 8b720dd2974af48a5d4a6ef8430ed12bf0c365df Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 27 Aug 2026 21:33:34 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20=EA=B0=9C=EB=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EA=B4=80=EB=A0=A8=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Sources/DomainAssembler.swift | 41 ++ .../DevelopmentRecordRepository.swift | 35 ++ .../ConfirmDevelopmentRecordUseCase.swift | 10 + .../ConfirmDevelopmentRecordUseCaseImpl.swift | 57 +++ .../CreateDevelopmentRecordUseCase.swift | 14 + .../CreateDevelopmentRecordUseCaseImpl.swift | 50 +++ .../SaveDevelopmentRecordDraftUseCase.swift | 15 + ...aveDevelopmentRecordDraftUseCaseImpl.swift | 52 +++ ...FetchDevelopmentRecordHistoryUseCase.swift | 10 + ...hDevelopmentRecordHistoryUseCaseImpl.swift | 21 + .../FetchDevelopmentRecordsUseCase.swift | 10 + .../FetchDevelopmentRecordsUseCaseImpl.swift | 18 + .../RestoreDevelopmentRecordUseCase.swift | 14 + .../RestoreDevelopmentRecordUseCaseImpl.swift | 62 +++ .../DevelopmentRecordUseCaseTestSupport.swift | 301 +++++++++++++ .../DevelopmentRecordUseCaseTests.swift | 400 ++++++++++++++++++ 16 files changed, 1110 insertions(+) create mode 100644 Application/Domain/Sources/Protocol/DevelopmentRecordRepository.swift create mode 100644 Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift create mode 100644 Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift create mode 100644 Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCase.swift create mode 100644 Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCaseImpl.swift create mode 100644 Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCase.swift create mode 100644 Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCaseImpl.swift create mode 100644 Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordHistoryUseCase.swift create mode 100644 Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordHistoryUseCaseImpl.swift create mode 100644 Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordsUseCase.swift create mode 100644 Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordsUseCaseImpl.swift create mode 100644 Application/Domain/Sources/UseCase/DevelopmentRecord/Restore/RestoreDevelopmentRecordUseCase.swift create mode 100644 Application/Domain/Sources/UseCase/DevelopmentRecord/Restore/RestoreDevelopmentRecordUseCaseImpl.swift create mode 100644 Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift create mode 100644 Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift diff --git a/Application/Domain/Sources/DomainAssembler.swift b/Application/Domain/Sources/DomainAssembler.swift index b957f802..6f08db6b 100644 --- a/Application/Domain/Sources/DomainAssembler.swift +++ b/Application/Domain/Sources/DomainAssembler.swift @@ -17,6 +17,7 @@ public final class DomainAssembler: Assembler { registerConnectivityUseCases(container) registerAuthProviderUseCases(container) registerDevelopmentGoalUseCases(container) + registerDevelopmentRecordUseCases(container) registerTodoUseCases(container) registerTodoCategoryUseCases(container) registerUserDataUseCases(container) @@ -47,6 +48,46 @@ private extension DomainAssembler { } } + 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)) diff --git a/Application/Domain/Sources/Protocol/DevelopmentRecordRepository.swift b/Application/Domain/Sources/Protocol/DevelopmentRecordRepository.swift new file mode 100644 index 00000000..2e17c09e --- /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/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift new file mode 100644 index 00000000..986619a2 --- /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..a6e363d4 --- /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..48278753 --- /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..8fe205c8 --- /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..f59a9041 --- /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..656d71c1 --- /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..dce2ae5f --- /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..d628c6e1 --- /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..01349224 --- /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..4e3cb709 --- /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..caa6eb71 --- /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..5dcba03a --- /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/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift new file mode 100644 index 00000000..328c9eed --- /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 DevelopmentGoalRepositorySpy: DevelopmentGoalRepository { + private let goal: DevelopmentGoal + + init(goal: DevelopmentGoal) { + self.goal = goal + } + + func createGoal( + id: String, + title: String, + markdownDescription: 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 required(createResult) + } + + func fetchRecords(goalID: String) async throws -> [DevelopmentRecord] { + recordedRecordQueries.append(goalID) + return records + } + + func fetchRecord(goalID: String, recordID: String) async throws -> DevelopmentRecord { + try required(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 required(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 required(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 required(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 required(_ value: Value?) throws -> Value { + guard let value else { + throw DevelopmentRecordRepositorySpyError.unconfigured + } + return value +} + +func makeGoal( + status: DevelopmentGoal.Status = .inProgress +) throws -> DevelopmentGoal { + try DevelopmentGoal( + id: "goal-1", + title: "개발 목표", + markdownDescription: "설명", + status: status, + createdAt: .distantPast, + updatedAt: .distantPast, + completedAt: status == .completed ? .distantPast : nil + ) +} + +func makeInitialDraftRecord( + updatedAt: Date = .distantPast +) throws -> DevelopmentRecord { + try makeRecord( + currentVersion: nil, + draft: DevelopmentRecord.Draft( + title: "기록", + markdownContent: "본문", + baseVersionID: nil, + updatedAt: updatedAt + ) + ) +} + +func makeConfirmedRecord() throws -> DevelopmentRecord { + try makeRecord( + currentVersion: DevelopmentRecord.CurrentVersion(id: "version-1", number: 1), + draft: nil + ) +} + +func makeRecord( + currentVersion: DevelopmentRecord.CurrentVersion?, + draft: DevelopmentRecord.Draft? +) throws -> DevelopmentRecord { + try DevelopmentRecord( + id: "record-1", + goalID: "goal-1", + currentVersion: currentVersion, + draft: draft, + createdAt: .distantPast + ) +} + +func makeInitialVersion() throws -> DevelopmentRecord.Version { + try DevelopmentRecord.Version( + id: "version-1", + recordID: "record-1", + number: 1, + title: "기록", + markdownContent: "본문", + kind: .initial, + sourceVersionID: nil, + confirmedAt: .distantPast + ) +} + +func makeCorrectionVersion( + 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 expectDomainError( + _ 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..82d3d0f2 --- /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 makeGoal() + let createdAt = Date(timeIntervalSinceReferenceDate: 10) + let record = try makeInitialDraftRecord(updatedAt: createdAt) + let repository = DevelopmentRecordRepositorySpy(createResult: record) + let useCase = CreateDevelopmentRecordUseCaseImpl( + repository, + DevelopmentGoalRepositorySpy(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, + DevelopmentGoalRepositorySpy(goal: try makeGoal(status: status)) + ) + await expectDomainError(.developmentGoalIsNotInProgress) { + try await useCase.execute( + goalID: "goal-1", + title: "기록", + markdownContent: "본문" + ) + } + #expect(await repository.createRequests().isEmpty) + } + } + + @Test("기록 목록 조회는 Repository 결과를 반환한다") + func 기록_목록_조회는_Repository_결과를_반환한다() async throws { + let record = try makeInitialDraftRecord() + 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 makeInitialVersion() + 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 makeGoal() + let record = try makeInitialDraftRecord() + let savedRecord = try makeInitialDraftRecord() + let updatedAt = Date(timeIntervalSinceReferenceDate: 20) + let repository = DevelopmentRecordRepositorySpy(record: record, savedRecord: savedRecord) + let useCase = SaveDevelopmentRecordDraftUseCaseImpl( + repository, + DevelopmentGoalRepositorySpy(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 makeGoal() + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-1", number: 1) + let record = try makeRecord( + currentVersion: currentVersion, + draft: DevelopmentRecord.Draft( + title: "정정 초안", + markdownContent: "본문", + baseVersionID: currentVersion.id, + updatedAt: .distantPast + ) + ) + let savedRecord = try makeRecord( + 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, + DevelopmentGoalRepositorySpy(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 makeGoal() + let record = try makeConfirmedRecord() + let currentVersion = try #require(record.currentVersion) + let savedRecord = try makeRecord( + 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, + DevelopmentGoalRepositorySpy(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 makeGoal() + let record = try makeInitialDraftRecord() + let version = try makeInitialVersion() + let repository = DevelopmentRecordRepositorySpy(record: record, confirmedVersion: version) + let useCase = ConfirmDevelopmentRecordUseCaseImpl( + repository, + DevelopmentGoalRepositorySpy(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 makeGoal() + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-1", number: 1) + let record = try makeRecord( + currentVersion: currentVersion, + draft: DevelopmentRecord.Draft( + title: "정정 초안", + markdownContent: "본문", + baseVersionID: currentVersion.id, + updatedAt: .distantPast + ) + ) + let version = try makeCorrectionVersion(id: "version-2", sourceVersionID: currentVersion.id) + let repository = DevelopmentRecordRepositorySpy(record: record, confirmedVersion: version) + let useCase = ConfirmDevelopmentRecordUseCaseImpl( + repository, + DevelopmentGoalRepositorySpy(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 makeGoal() + let repository = DevelopmentRecordRepositorySpy(record: try makeConfirmedRecord()) + let useCase = ConfirmDevelopmentRecordUseCaseImpl( + repository, + DevelopmentGoalRepositorySpy(goal: goal) + ) + + await expectDomainError(.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 makeGoal() + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-3", number: 3) + let record = try makeRecord(currentVersion: currentVersion, draft: nil) + let initialVersion = try makeInitialVersion() + let correctionVersion = try makeCorrectionVersion(id: "version-2", sourceVersionID: "version-1") + let currentRecordVersion = try makeCorrectionVersion( + 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, + DevelopmentGoalRepositorySpy(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 makeGoal() + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-2", number: 2) + let record = try makeRecord(currentVersion: currentVersion, draft: nil) + let currentRecordVersion = try makeCorrectionVersion( + 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, + DevelopmentGoalRepositorySpy(goal: goal) + ) + + await expectDomainError(.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 makeGoal() + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-1", number: 1) + let record = try makeRecord( + currentVersion: currentVersion, + draft: DevelopmentRecord.Draft( + title: "정정 초안", + markdownContent: "본문", + baseVersionID: currentVersion.id, + updatedAt: .distantPast + ) + ) + let repository = DevelopmentRecordRepositorySpy(record: record) + let useCase = RestoreDevelopmentRecordUseCaseImpl( + repository, + DevelopmentGoalRepositorySpy(goal: goal) + ) + + await expectDomainError(.developmentRecordDraftConflict) { + try await useCase.execute( + goalID: "goal-1", + recordID: "record-1", + sourceVersionID: "version-1" + ) + } + #expect(await repository.restoreRequests().isEmpty) + } +} From 3601b3e768f18658c68595a62971027038371b7c Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 27 Aug 2026 21:42:48 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20Todo=20=EB=AA=A9=ED=91=9C=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EA=B4=80=EB=A0=A8=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Sources/DomainAssembler.swift | 7 + Application/Domain/Sources/Entity/Todo.swift | 38 ++++ .../Domain/Sources/Entity/TodoDraft.swift | 36 ++++ .../Todo/Update/UpdateTodoGoalUseCase.swift | 10 + .../Update/UpdateTodoGoalUseCaseImpl.swift | 29 +++ .../Domain/Tests/Entity/TodoGoalTests.swift | 103 ++++++++++ .../Todo/UpdateTodoGoalUseCaseImplTests.swift | 181 ++++++++++++++++++ 7 files changed, 404 insertions(+) create mode 100644 Application/Domain/Sources/UseCase/Todo/Update/UpdateTodoGoalUseCase.swift create mode 100644 Application/Domain/Sources/UseCase/Todo/Update/UpdateTodoGoalUseCaseImpl.swift create mode 100644 Application/Domain/Tests/Entity/TodoGoalTests.swift create mode 100644 Application/Domain/Tests/UseCase/Todo/UpdateTodoGoalUseCaseImplTests.swift diff --git a/Application/Domain/Sources/DomainAssembler.swift b/Application/Domain/Sources/DomainAssembler.swift index 6f08db6b..ffa9465a 100644 --- a/Application/Domain/Sources/DomainAssembler.swift +++ b/Application/Domain/Sources/DomainAssembler.swift @@ -164,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/Todo.swift b/Application/Domain/Sources/Entity/Todo.swift index 188052f2..7267ac8a 100644 --- a/Application/Domain/Sources/Entity/Todo.swift +++ b/Application/Domain/Sources/Entity/Todo.swift @@ -9,6 +9,7 @@ import Foundation public struct Todo: Hashable { public let id: String + public var goalID: String? public var isPinned: Bool // 해당 할 일이 상단에 고정되어 있는지 여부 public var isCompleted: Bool // 해당 할 일의 완료 여부 public var isChecked: Bool // 해당 할 일의 체크 여부 @@ -38,8 +39,45 @@ public struct Todo: Hashable { dueDate: Date?, tags: [String], category: TodoCategory + ) { + self.init( + id: id, + isPinned: isPinned, + isCompleted: isCompleted, + isChecked: isChecked, + number: number, + title: title, + content: content, + createdAt: createdAt, + updatedAt: updatedAt, + completedAt: completedAt, + deletedAt: deletedAt, + dueDate: dueDate, + tags: tags, + category: category, + goalID: nil + ) + } + + public init( + id: String, + isPinned: Bool, + isCompleted: Bool, + isChecked: Bool, + number: Int, + title: String, + content: String, + createdAt: Date, + updatedAt: Date, + completedAt: Date?, + deletedAt: Date?, + dueDate: Date?, + tags: [String], + category: TodoCategory, + goalID: String? ) { 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..091ab8c8 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 @@ -34,8 +35,41 @@ public struct TodoDraft: Equatable { dueDate: Date?, tags: [String], category: TodoCategory + ) { + self.init( + id: id, + isPinned: isPinned, + isCompleted: isCompleted, + isChecked: isChecked, + title: title, + content: content, + createdAt: createdAt, + updatedAt: updatedAt, + completedAt: completedAt, + dueDate: dueDate, + tags: tags, + category: category, + goalID: nil + ) + } + + public init( + id: String, + isPinned: Bool, + isCompleted: Bool, + isChecked: Bool, + title: String, + content: String, + createdAt: Date, + updatedAt: Date, + completedAt: Date?, + dueDate: Date?, + tags: [String], + category: TodoCategory, + goalID: String? ) { self.id = id + self.goalID = goalID self.isPinned = isPinned self.isCompleted = isCompleted self.isChecked = isChecked @@ -51,6 +85,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 +101,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/UseCase/Todo/Update/UpdateTodoGoalUseCase.swift b/Application/Domain/Sources/UseCase/Todo/Update/UpdateTodoGoalUseCase.swift new file mode 100644 index 00000000..c76c32c3 --- /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..81a071e2 --- /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/TodoGoalTests.swift b/Application/Domain/Tests/Entity/TodoGoalTests.swift new file mode 100644 index 00000000..feaeabaf --- /dev/null +++ b/Application/Domain/Tests/Entity/TodoGoalTests.swift @@ -0,0 +1,103 @@ +// +// TodoGoalTests.swift +// DomainTests +// +// Created by opfic on 8/27/26. +// + +import Foundation +import Testing +@testable import Domain + +struct TodoGoalTests { + @Test("기존 Todo initializer 함수 값은 goalID 없이 Todo를 생성한다") + func 기존_Todo_initializer_함수_값은_goalID_없이_Todo를_생성한다() { + let initializer = Todo.init( + id:isPinned:isCompleted:isChecked:number:title:content:createdAt:updatedAt:completedAt: + deletedAt:dueDate:tags:category: + ) + + let todo = initializer( + "todo-1", + false, + false, + false, + 1, + "할 일", + "내용", + .distantPast, + .distantPast, + nil, + nil, + nil, + [], + .system(.feature) + ) + + #expect(todo.goalID == nil) + } + + @Test("기존 TodoDraft initializer 함수 값은 goalID 없이 Draft를 생성한다") + func 기존_TodoDraft_initializer_함수_값은_goalID_없이_Draft를_생성한다() { + let initializer = TodoDraft.init( + id:isPinned:isCompleted:isChecked:title:content:createdAt:updatedAt:completedAt:dueDate: + tags:category: + ) + + let draft = initializer( + "todo-1", + false, + false, + false, + "할 일", + "내용", + .distantPast, + .distantPast, + nil, + nil, + [], + .system(.feature) + ) + + #expect(draft.goalID == nil) + } + + @Test("goalID overload와 TodoDraft 복사는 연결 대상을 보존한다") + func goalID_overload와_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/Todo/UpdateTodoGoalUseCaseImplTests.swift b/Application/Domain/Tests/UseCase/Todo/UpdateTodoGoalUseCaseImplTests.swift new file mode 100644 index 00000000..2b7844dd --- /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, + markdownDescription: 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: "개발 목표", + markdownDescription: "설명", + status: .inProgress, + createdAt: .distantPast, + updatedAt: .distantPast, + completedAt: nil + ) +} From b04e048a62d2319346f0d747db610160c229d0bb Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 27 Aug 2026 21:49:40 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=EA=B0=9C=EB=B0=9C=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=20=EC=99=84=EB=A3=8C=20=EC=A0=84=ED=99=98=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=83=81=ED=83=9C=20=EA=B2=80=EC=A6=9D=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UpdateDevelopmentGoalStatusUseCaseImpl.swift | 1 + .../DevelopmentGoalUseCaseTests.swift | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/Application/Domain/Sources/UseCase/DevelopmentGoal/Transition/UpdateDevelopmentGoalStatusUseCaseImpl.swift b/Application/Domain/Sources/UseCase/DevelopmentGoal/Transition/UpdateDevelopmentGoalStatusUseCaseImpl.swift index 4a380002..8947a390 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentGoal/Transition/UpdateDevelopmentGoalStatusUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentGoal/Transition/UpdateDevelopmentGoalStatusUseCaseImpl.swift @@ -19,6 +19,7 @@ public final class UpdateDevelopmentGoalStatusUseCaseImpl: UpdateDevelopmentGoal 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 { diff --git a/Application/Domain/Tests/UseCase/DevelopmentGoal/DevelopmentGoalUseCaseTests.swift b/Application/Domain/Tests/UseCase/DevelopmentGoal/DevelopmentGoalUseCaseTests.swift index 8ee99aaa..96369911 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentGoal/DevelopmentGoalUseCaseTests.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentGoal/DevelopmentGoalUseCaseTests.swift @@ -82,6 +82,21 @@ struct DevelopmentGoalUseCaseTests { ]) } + @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") From 603ffc7c0cc539257e164d1cde02cf105f753fe2 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 27 Aug 2026 22:37:46 +0900 Subject: [PATCH 07/10] =?UTF-8?q?refactor:=20=EA=B0=9C=EB=B0=9C=20?= =?UTF-8?q?=EB=AA=A9=ED=91=9C=C2=B7=EA=B8=B0=EB=A1=9D=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EB=AA=85=EB=AA=85=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Entity/DevelopmentGoal.swift | 6 +- .../Sources/Entity/DevelopmentRecord.swift | 42 ++-- Application/Domain/Sources/Entity/Todo.swift | 8 +- .../Domain/Sources/Entity/TodoDraft.swift | 12 +- .../Protocol/DevelopmentGoalRepository.swift | 8 +- .../DevelopmentRecordRepository.swift | 28 +-- .../Create/CreateDevelopmentGoalUseCase.swift | 2 +- .../CreateDevelopmentGoalUseCaseImpl.swift | 4 +- .../Fetch/FetchDevelopmentGoalUseCase.swift | 2 +- .../FetchDevelopmentGoalUseCaseImpl.swift | 4 +- .../UpdateDevelopmentGoalStatusUseCase.swift | 2 +- ...dateDevelopmentGoalStatusUseCaseImpl.swift | 10 +- .../ConfirmDevelopmentRecordUseCase.swift | 2 +- .../ConfirmDevelopmentRecordUseCaseImpl.swift | 24 +- .../CreateDevelopmentRecordUseCase.swift | 2 +- .../CreateDevelopmentRecordUseCaseImpl.swift | 8 +- .../SaveDevelopmentRecordDraftUseCase.swift | 4 +- ...aveDevelopmentRecordDraftUseCaseImpl.swift | 16 +- ...FetchDevelopmentRecordHistoryUseCase.swift | 2 +- ...hDevelopmentRecordHistoryUseCaseImpl.swift | 6 +- .../FetchDevelopmentRecordsUseCase.swift | 2 +- .../FetchDevelopmentRecordsUseCaseImpl.swift | 4 +- .../RestoreDevelopmentRecordUseCase.swift | 6 +- .../RestoreDevelopmentRecordUseCaseImpl.swift | 26 +-- .../Todo/Update/UpdateTodoGoalUseCase.swift | 2 +- .../Update/UpdateTodoGoalUseCaseImpl.swift | 10 +- .../Tests/Entity/DevelopmentGoalTests.swift | 6 +- .../Tests/Entity/DevelopmentRecordTests.swift | 38 ++-- .../Domain/Tests/Entity/TodoGoalTests.swift | 34 +-- .../DevelopmentGoalUseCaseTests.swift | 56 ++--- .../DevelopmentRecordUseCaseTestSupport.swift | 128 +++++------ .../DevelopmentRecordUseCaseTests.swift | 210 +++++++++--------- .../Todo/UpdateTodoGoalUseCaseImplTests.swift | 50 ++--- 33 files changed, 382 insertions(+), 382 deletions(-) diff --git a/Application/Domain/Sources/Entity/DevelopmentGoal.swift b/Application/Domain/Sources/Entity/DevelopmentGoal.swift index c3658ad4..1dba3623 100644 --- a/Application/Domain/Sources/Entity/DevelopmentGoal.swift +++ b/Application/Domain/Sources/Entity/DevelopmentGoal.swift @@ -34,7 +34,7 @@ public struct DevelopmentGoal: Hashable { public let id: String public let title: String - public let markdownDescription: String + public let description: String public let status: Status public let createdAt: Date public let updatedAt: Date @@ -43,7 +43,7 @@ public struct DevelopmentGoal: Hashable { public init( id: String, title: String, - markdownDescription: String, + description: String, status: Status, createdAt: Date, updatedAt: Date, @@ -55,7 +55,7 @@ public struct DevelopmentGoal: Hashable { self.id = id self.title = title - self.markdownDescription = markdownDescription + self.description = description self.status = status self.createdAt = createdAt self.updatedAt = updatedAt diff --git a/Application/Domain/Sources/Entity/DevelopmentRecord.swift b/Application/Domain/Sources/Entity/DevelopmentRecord.swift index 9ccce9e3..50246cc1 100644 --- a/Application/Domain/Sources/Entity/DevelopmentRecord.swift +++ b/Application/Domain/Sources/Entity/DevelopmentRecord.swift @@ -11,25 +11,25 @@ public struct DevelopmentRecord: Hashable { public struct Draft: Hashable { public let title: String public let markdownContent: String - public let baseVersionID: String? + public let baseVersionId: String? public let updatedAt: Date public init( title: String, markdownContent: String, - baseVersionID: String?, + baseVersionId: String?, updatedAt: Date ) throws { guard title.containsMeaningfulCharacter else { throw DomainLayerError.invalidDevelopmentRecordTitle } - guard baseVersionID?.containsMeaningfulCharacter != false else { + guard baseVersionId?.containsMeaningfulCharacter != false else { throw DomainLayerError.invalidDevelopmentRecordVersion } self.title = title self.markdownContent = markdownContent - self.baseVersionID = baseVersionID + self.baseVersionId = baseVersionId self.updatedAt = updatedAt } } @@ -56,26 +56,26 @@ public struct DevelopmentRecord: Hashable { } public let id: String - public let recordID: 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 sourceVersionId: String? public let confirmedAt: Date public init( id: String, - recordID: String, + recordId: String, number: Int, title: String, markdownContent: String, kind: Kind, - sourceVersionID: String?, + sourceVersionId: String?, confirmedAt: Date ) throws { guard id.containsMeaningfulCharacter, - recordID.containsMeaningfulCharacter, + recordId.containsMeaningfulCharacter, title.containsMeaningfulCharacter, 0 < number else { throw DomainLayerError.invalidDevelopmentRecordVersion @@ -83,43 +83,43 @@ public struct DevelopmentRecord: Hashable { switch kind { case .initial: - guard number == 1, sourceVersionID == nil else { + guard number == 1, sourceVersionId == nil else { throw DomainLayerError.invalidDevelopmentRecordVersion } case .correction, .rollback: guard 1 < number, - let sourceVersionID, - sourceVersionID.containsMeaningfulCharacter, - sourceVersionID != id else { + let sourceVersionId, + sourceVersionId.containsMeaningfulCharacter, + sourceVersionId != id else { throw DomainLayerError.invalidDevelopmentRecordVersion } } self.id = id - self.recordID = recordID + self.recordId = recordId self.number = number self.title = title self.markdownContent = markdownContent self.kind = kind - self.sourceVersionID = sourceVersionID + self.sourceVersionId = sourceVersionId self.confirmedAt = confirmedAt } } public let id: String - public let goalID: String + public let goalId: String public let currentVersion: CurrentVersion? public let draft: Draft? public let createdAt: Date public init( id: String, - goalID: String, + goalId: String, currentVersion: CurrentVersion?, draft: Draft?, createdAt: Date ) throws { - guard id.containsMeaningfulCharacter, goalID.containsMeaningfulCharacter else { + guard id.containsMeaningfulCharacter, goalId.containsMeaningfulCharacter else { throw DomainLayerError.invalidData(context: "developmentRecord") } guard currentVersion != nil || draft != nil else { @@ -127,17 +127,17 @@ public struct DevelopmentRecord: Hashable { } if let currentVersion, let draft { - guard draft.baseVersionID == currentVersion.id else { + guard draft.baseVersionId == currentVersion.id else { throw DomainLayerError.invalidDevelopmentRecordCurrentVersion } } else if currentVersion == nil { - guard draft?.baseVersionID == nil else { + guard draft?.baseVersionId == nil else { throw DomainLayerError.invalidDevelopmentRecordCurrentVersion } } self.id = id - self.goalID = goalID + self.goalId = goalId self.currentVersion = currentVersion self.draft = draft self.createdAt = createdAt diff --git a/Application/Domain/Sources/Entity/Todo.swift b/Application/Domain/Sources/Entity/Todo.swift index 7267ac8a..243bfd2a 100644 --- a/Application/Domain/Sources/Entity/Todo.swift +++ b/Application/Domain/Sources/Entity/Todo.swift @@ -9,7 +9,7 @@ import Foundation public struct Todo: Hashable { public let id: String - public var goalID: String? + public var goalId: String? public var isPinned: Bool // 해당 할 일이 상단에 고정되어 있는지 여부 public var isCompleted: Bool // 해당 할 일의 완료 여부 public var isChecked: Bool // 해당 할 일의 체크 여부 @@ -55,7 +55,7 @@ public struct Todo: Hashable { dueDate: dueDate, tags: tags, category: category, - goalID: nil + goalId: nil ) } @@ -74,10 +74,10 @@ public struct Todo: Hashable { dueDate: Date?, tags: [String], category: TodoCategory, - goalID: String? + goalId: String? ) { self.id = id - self.goalID = goalID + 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 091ab8c8..3cd85d21 100644 --- a/Application/Domain/Sources/Entity/TodoDraft.swift +++ b/Application/Domain/Sources/Entity/TodoDraft.swift @@ -9,7 +9,7 @@ import Foundation public struct TodoDraft: Equatable { public var id: String - public var goalID: String? + public var goalId: String? public var isPinned: Bool public var isCompleted: Bool public var isChecked: Bool @@ -49,7 +49,7 @@ public struct TodoDraft: Equatable { dueDate: dueDate, tags: tags, category: category, - goalID: nil + goalId: nil ) } @@ -66,10 +66,10 @@ public struct TodoDraft: Equatable { dueDate: Date?, tags: [String], category: TodoCategory, - goalID: String? + goalId: String? ) { self.id = id - self.goalID = goalID + self.goalId = goalId self.isPinned = isPinned self.isCompleted = isCompleted self.isChecked = isChecked @@ -85,7 +85,7 @@ public struct TodoDraft: Equatable { public init(todo: Todo) { self.id = todo.id - self.goalID = todo.goalID + self.goalId = todo.goalId self.isPinned = todo.isPinned self.isCompleted = todo.isCompleted self.isChecked = todo.isChecked @@ -101,7 +101,7 @@ public struct TodoDraft: Equatable { public static func == (lhs: TodoDraft, rhs: TodoDraft) -> Bool { lhs.id == rhs.id && - lhs.goalID == rhs.goalID && + 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 index 66fa9c32..34cef57f 100644 --- a/Application/Domain/Sources/Protocol/DevelopmentGoalRepository.swift +++ b/Application/Domain/Sources/Protocol/DevelopmentGoalRepository.swift @@ -9,13 +9,13 @@ public protocol DevelopmentGoalRepository { func createGoal( id: String, title: String, - markdownDescription: String + description: String ) async throws -> DevelopmentGoal - func fetchGoal(_ goalID: 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 fetchCompletionSnapshot(for goalId: String) async throws -> DevelopmentGoal.CompletionSnapshot func transitionGoalStatus( - _ goalID: String, + _ 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 index 2e17c09e..11d23b7a 100644 --- a/Application/Domain/Sources/Protocol/DevelopmentRecordRepository.swift +++ b/Application/Domain/Sources/Protocol/DevelopmentRecordRepository.swift @@ -8,28 +8,28 @@ public protocol DevelopmentRecordRepository { func createRecord( id: String, - goalID: 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 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, + goalId: String, + recordId: String, draft: DevelopmentRecord.Draft ) async throws -> DevelopmentRecord func confirmDraft( - goalID: String, - recordID: String, - versionID: String, + goalId: String, + recordId: String, + versionId: String, kind: DevelopmentRecord.Version.Kind, - sourceVersionID: String? + sourceVersionId: String? ) async throws -> DevelopmentRecord.Version func restoreVersion( - goalID: String, - recordID: String, - versionID: String, - sourceVersionID: String + 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 index 3c0b8598..e1bda59f 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentGoal/Create/CreateDevelopmentGoalUseCase.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentGoal/Create/CreateDevelopmentGoalUseCase.swift @@ -6,5 +6,5 @@ // public protocol CreateDevelopmentGoalUseCase { - func execute(title: String, markdownDescription: String) async throws -> DevelopmentGoal + 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 index bf655833..ab1358f5 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentGoal/Create/CreateDevelopmentGoalUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentGoal/Create/CreateDevelopmentGoalUseCaseImpl.swift @@ -19,11 +19,11 @@ public final class CreateDevelopmentGoalUseCaseImpl: CreateDevelopmentGoalUseCas self.idProvider = idProvider } - public func execute(title: String, markdownDescription: String) async throws -> DevelopmentGoal { + public func execute(title: String, description: String) async throws -> DevelopmentGoal { try await repository.createGoal( id: idProvider(), title: title, - markdownDescription: markdownDescription + description: description ) } } diff --git a/Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalUseCase.swift index 2696d922..a06c85b5 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalUseCase.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalUseCase.swift @@ -6,5 +6,5 @@ // public protocol FetchDevelopmentGoalUseCase { - func execute(_ goalID: String) async throws -> DevelopmentGoal + 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 index 5082935a..2fc5d002 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentGoal/Fetch/FetchDevelopmentGoalUseCaseImpl.swift @@ -12,7 +12,7 @@ public final class FetchDevelopmentGoalUseCaseImpl: FetchDevelopmentGoalUseCase self.repository = repository } - public func execute(_ goalID: String) async throws -> DevelopmentGoal { - try await repository.fetchGoal(goalID) + public func execute(_ goalId: String) async throws -> DevelopmentGoal { + try await repository.fetchGoal(goalId) } } diff --git a/Application/Domain/Sources/UseCase/DevelopmentGoal/Transition/UpdateDevelopmentGoalStatusUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentGoal/Transition/UpdateDevelopmentGoalStatusUseCase.swift index cae59e26..40478378 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentGoal/Transition/UpdateDevelopmentGoalStatusUseCase.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentGoal/Transition/UpdateDevelopmentGoalStatusUseCase.swift @@ -6,5 +6,5 @@ // public protocol UpdateDevelopmentGoalStatusUseCase { - func execute(_ goalID: String, to status: DevelopmentGoal.Status) async throws + 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 index 8947a390..5e5702fe 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentGoal/Transition/UpdateDevelopmentGoalStatusUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentGoal/Transition/UpdateDevelopmentGoalStatusUseCaseImpl.swift @@ -12,13 +12,13 @@ public final class UpdateDevelopmentGoalStatusUseCaseImpl: UpdateDevelopmentGoal self.repository = repository } - public func execute(_ goalID: String, to status: DevelopmentGoal.Status) async throws { - let goal = try await repository.fetchGoal(goalID) + 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) + let completionSnapshot = try await repository.fetchCompletionSnapshot(for: goalId) try completionSnapshot.goal.validateTransition(to: status) try validate(completionSnapshot, for: goal) snapshot = completionSnapshot @@ -27,7 +27,7 @@ public final class UpdateDevelopmentGoalStatusUseCaseImpl: UpdateDevelopmentGoal } try await repository.transitionGoalStatus( - goalID, + goalId, to: status, completionSnapshot: snapshot ) @@ -40,7 +40,7 @@ private extension UpdateDevelopmentGoalStatusUseCaseImpl { for goal: DevelopmentGoal ) throws { guard snapshot.goal.id == goal.id, - snapshot.records.allSatisfy({ $0.goalID == goal.id }) else { + snapshot.records.allSatisfy({ $0.goalId == goal.id }) else { throw DomainLayerError.invalidData(context: "developmentGoalCompletionSnapshot") } guard !snapshot.records.isEmpty else { diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift index 986619a2..288b789e 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift @@ -6,5 +6,5 @@ // public protocol ConfirmDevelopmentRecordUseCase { - func execute(goalID: String, recordID: String) async throws -> DevelopmentRecord.Version + 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 index a6e363d4..8b664d31 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift @@ -22,14 +22,14 @@ public final class ConfirmDevelopmentRecordUseCaseImpl: ConfirmDevelopmentRecord self.idProvider = idProvider } - public func execute(goalID: String, recordID: String) async throws -> DevelopmentRecord.Version { - let goal = try await goalRepository.fetchGoal(goalID) + 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 { + 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 { @@ -38,20 +38,20 @@ public final class ConfirmDevelopmentRecordUseCaseImpl: ConfirmDevelopmentRecord if let currentVersion = record.currentVersion { return try await repository.confirmDraft( - goalID: goalID, - recordID: recordID, - versionID: idProvider(), + goalId: goalId, + recordId: recordId, + versionId: idProvider(), kind: .correction, - sourceVersionID: currentVersion.id + sourceVersionId: currentVersion.id ) } return try await repository.confirmDraft( - goalID: goalID, - recordID: recordID, - versionID: idProvider(), + goalId: goalId, + recordId: recordId, + versionId: idProvider(), kind: .initial, - sourceVersionID: nil + sourceVersionId: nil ) } } diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCase.swift index 48278753..c8e4d0e1 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCase.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCase.swift @@ -7,7 +7,7 @@ public protocol CreateDevelopmentRecordUseCase { func execute( - goalID: String, + 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 index 8fe205c8..08345081 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCaseImpl.swift @@ -26,11 +26,11 @@ public final class CreateDevelopmentRecordUseCaseImpl: CreateDevelopmentRecordUs } public func execute( - goalID: String, + goalId: String, title: String, markdownContent: String ) async throws -> DevelopmentRecord { - let goal = try await goalRepository.fetchGoal(goalID) + let goal = try await goalRepository.fetchGoal(goalId) guard goal.status == .inProgress else { throw DomainLayerError.developmentGoalIsNotInProgress } @@ -38,12 +38,12 @@ public final class CreateDevelopmentRecordUseCaseImpl: CreateDevelopmentRecordUs let draft = try DevelopmentRecord.Draft( title: title, markdownContent: markdownContent, - baseVersionID: nil, + baseVersionId: nil, updatedAt: now() ) return try await repository.createRecord( id: idProvider(), - goalID: goalID, + goalId: goalId, draft: draft ) } diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCase.swift index f59a9041..a3f59842 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCase.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCase.swift @@ -7,8 +7,8 @@ public protocol SaveDevelopmentRecordDraftUseCase { func execute( - goalID: String, - recordID: String, + 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 index 656d71c1..a77a3c42 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCaseImpl.swift @@ -23,29 +23,29 @@ public final class SaveDevelopmentRecordDraftUseCaseImpl: SaveDevelopmentRecordD } public func execute( - goalID: String, - recordID: String, + goalId: String, + recordId: String, title: String, markdownContent: String ) async throws -> DevelopmentRecord { - let goal = try await goalRepository.fetchGoal(goalID) + 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 { + 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, + baseVersionId: record.currentVersion?.id, updatedAt: now() ) return try await repository.saveDraft( - goalID: goalID, - recordID: recordID, + 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 index dce2ae5f..44106ad2 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordHistoryUseCase.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordHistoryUseCase.swift @@ -6,5 +6,5 @@ // public protocol FetchDevelopmentRecordHistoryUseCase { - func execute(goalID: String, recordID: String) async throws -> [DevelopmentRecord.Version] + 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 index d628c6e1..8f1237b4 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordHistoryUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordHistoryUseCaseImpl.swift @@ -13,9 +13,9 @@ public final class FetchDevelopmentRecordHistoryUseCaseImpl: FetchDevelopmentRec } public func execute( - goalID: String, - recordID: String + goalId: String, + recordId: String ) async throws -> [DevelopmentRecord.Version] { - try await repository.fetchVersions(goalID: goalID, recordID: recordID) + 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 index 01349224..2c99d560 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordsUseCase.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordsUseCase.swift @@ -6,5 +6,5 @@ // public protocol FetchDevelopmentRecordsUseCase { - func execute(goalID: String) async throws -> [DevelopmentRecord] + 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 index 4e3cb709..3ad632d9 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordsUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordsUseCaseImpl.swift @@ -12,7 +12,7 @@ public final class FetchDevelopmentRecordsUseCaseImpl: FetchDevelopmentRecordsUs self.repository = repository } - public func execute(goalID: String) async throws -> [DevelopmentRecord] { - try await repository.fetchRecords(goalID: goalID) + 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 index caa6eb71..324828f1 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Restore/RestoreDevelopmentRecordUseCase.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Restore/RestoreDevelopmentRecordUseCase.swift @@ -7,8 +7,8 @@ public protocol RestoreDevelopmentRecordUseCase { func execute( - goalID: String, - recordID: String, - sourceVersionID: String + 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 index 5dcba03a..c3fc8c91 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Restore/RestoreDevelopmentRecordUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Restore/RestoreDevelopmentRecordUseCaseImpl.swift @@ -23,17 +23,17 @@ public final class RestoreDevelopmentRecordUseCaseImpl: RestoreDevelopmentRecord } public func execute( - goalID: String, - recordID: String, - sourceVersionID: String + goalId: String, + recordId: String, + sourceVersionId: String ) async throws -> DevelopmentRecord.Version { - let goal = try await goalRepository.fetchGoal(goalID) + 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 { + 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 { @@ -43,20 +43,20 @@ public final class RestoreDevelopmentRecordUseCaseImpl: RestoreDevelopmentRecord throw DomainLayerError.developmentRecordVersionNotFound } - let versions = try await repository.fetchVersions(goalID: goalID, recordID: recordID) + let versions = try await repository.fetchVersions(goalId: goalId, recordId: recordId) guard versions.contains(where: { - $0.id == sourceVersionID - && $0.recordID == recordID + $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 + 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 index c76c32c3..e5267bf0 100644 --- a/Application/Domain/Sources/UseCase/Todo/Update/UpdateTodoGoalUseCase.swift +++ b/Application/Domain/Sources/UseCase/Todo/Update/UpdateTodoGoalUseCase.swift @@ -6,5 +6,5 @@ // public protocol UpdateTodoGoalUseCase { - func execute(todoID: String, goalID: String?) async throws + 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 index 81a071e2..ec21e519 100644 --- a/Application/Domain/Sources/UseCase/Todo/Update/UpdateTodoGoalUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/Todo/Update/UpdateTodoGoalUseCaseImpl.swift @@ -17,13 +17,13 @@ public final class UpdateTodoGoalUseCaseImpl: UpdateTodoGoalUseCase { self.goalRepository = goalRepository } - public func execute(todoID: String, goalID: String?) async throws { - if let goalID { - _ = try await goalRepository.fetchGoal(goalID) + 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 + 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 index 8dd10dda..9d8f7339 100644 --- a/Application/Domain/Tests/Entity/DevelopmentGoalTests.swift +++ b/Application/Domain/Tests/Entity/DevelopmentGoalTests.swift @@ -68,7 +68,7 @@ private func makeGoal( try DevelopmentGoal( id: "goal-1", title: title, - markdownDescription: "설명", + description: "설명", status: status, createdAt: .distantPast, updatedAt: .distantPast, @@ -80,12 +80,12 @@ private func makeInitialDraftRecord() throws -> DevelopmentRecord { let draft = try DevelopmentRecord.Draft( title: "초안", markdownContent: "본문", - baseVersionID: nil, + baseVersionId: nil, updatedAt: .distantPast ) return try DevelopmentRecord( id: "record-1", - goalID: "goal-1", + goalId: "goal-1", currentVersion: nil, draft: draft, createdAt: .distantPast diff --git a/Application/Domain/Tests/Entity/DevelopmentRecordTests.swift b/Application/Domain/Tests/Entity/DevelopmentRecordTests.swift index 54ae3701..bdf8948d 100644 --- a/Application/Domain/Tests/Entity/DevelopmentRecordTests.swift +++ b/Application/Domain/Tests/Entity/DevelopmentRecordTests.swift @@ -46,7 +46,7 @@ struct DevelopmentRecordTests { @Test("현재 버전이 없는 기록은 기준 버전을 가진 초안을 가질 수 없다") func 현재_버전이_없는_기록은_기준_버전을_가진_초안을_가질_수_없다() throws { - let draft = try makeDraft(baseVersionID: "version-1") + let draft = try makeDraft(baseVersionId: "version-1") expectDomainError(.invalidDevelopmentRecordCurrentVersion) { _ = try makeRecord(currentVersion: nil, draft: draft) @@ -56,7 +56,7 @@ struct DevelopmentRecordTests { @Test("현재 버전 기반 초안은 같은 현재 버전을 기준으로 가진다") func 현재_버전_기반_초안은_같은_현재_버전을_기준으로_가진다() throws { let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-2", number: 2) - let draft = try makeDraft(baseVersionID: "version-2") + let draft = try makeDraft(baseVersionId: "version-2") let record = try makeRecord(currentVersion: currentVersion, draft: draft) #expect(record.currentVersion == currentVersion) @@ -75,7 +75,7 @@ struct DevelopmentRecordTests { @Test("현재 버전과 다른 기준 버전을 가진 초안은 거부한다") func 현재_버전과_다른_기준_버전을_가진_초안은_거부한다() throws { let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-2", number: 2) - let draft = try makeDraft(baseVersionID: "version-1") + let draft = try makeDraft(baseVersionId: "version-1") expectDomainError(.invalidDevelopmentRecordCurrentVersion) { _ = try makeRecord(currentVersion: currentVersion, draft: draft) @@ -94,53 +94,53 @@ struct DevelopmentRecordTests { @Test("최초 확정 버전은 출처를 가지지 않는다") func 최초_확정_버전은_출처를_가지지_않는다() throws { - let version = try makeVersion(kind: .initial, sourceVersionID: nil) + let version = try makeVersion(kind: .initial, sourceVersionId: nil) - #expect(version.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") + 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") + #expect(correction.sourceVersionId == "version-1") + #expect(rollback.sourceVersionId == "version-1") } @Test("버전 종류와 출처 관계가 맞지 않으면 거부한다") func 버전_종류와_출처_관계가_맞지_않으면_거부한다() { expectDomainError(.invalidDevelopmentRecordVersion) { - _ = try makeVersion(kind: .initial, sourceVersionID: "version-1") + _ = try makeVersion(kind: .initial, sourceVersionId: "version-1") } expectDomainError(.invalidDevelopmentRecordVersion) { - _ = try makeVersion(kind: .correction, sourceVersionID: nil) + _ = try makeVersion(kind: .correction, sourceVersionId: nil) } } @Test("최초 확정 버전은 1번이어야 한다") func 최초_확정_버전은_1번이어야_한다() { expectDomainError(.invalidDevelopmentRecordVersion) { - _ = try makeVersion(kind: .initial, sourceVersionID: nil, number: 2) + _ = try makeVersion(kind: .initial, sourceVersionId: nil, number: 2) } } @Test("정정과 되돌리기 버전은 2번부터 시작한다") func 정정과_되돌리기_버전은_2번부터_시작한다() { expectDomainError(.invalidDevelopmentRecordVersion) { - _ = try makeVersion(kind: .correction, sourceVersionID: "version-1", number: 1) + _ = try makeVersion(kind: .correction, sourceVersionId: "version-1", number: 1) } } } private func makeDraft( title: String = "기록 제목", - baseVersionID: String? = nil + baseVersionId: String? = nil ) throws -> DevelopmentRecord.Draft { try DevelopmentRecord.Draft( title: title, markdownContent: "기록 본문", - baseVersionID: baseVersionID, + baseVersionId: baseVersionId, updatedAt: .distantPast ) } @@ -151,7 +151,7 @@ private func makeRecord( ) throws -> DevelopmentRecord { try DevelopmentRecord( id: "record-1", - goalID: "goal-1", + goalId: "goal-1", currentVersion: currentVersion, draft: draft, createdAt: .distantPast @@ -160,17 +160,17 @@ private func makeRecord( private func makeVersion( kind: DevelopmentRecord.Version.Kind, - sourceVersionID: String?, + sourceVersionId: String?, number: Int? = nil ) throws -> DevelopmentRecord.Version { try DevelopmentRecord.Version( id: "version-2", - recordID: "record-1", + recordId: "record-1", number: number ?? (kind == .initial ? 1 : 2), title: "확정 제목", markdownContent: "확정 본문", kind: kind, - sourceVersionID: sourceVersionID, + sourceVersionId: sourceVersionId, confirmedAt: .distantFuture ) } diff --git a/Application/Domain/Tests/Entity/TodoGoalTests.swift b/Application/Domain/Tests/Entity/TodoGoalTests.swift index feaeabaf..a332fbf7 100644 --- a/Application/Domain/Tests/Entity/TodoGoalTests.swift +++ b/Application/Domain/Tests/Entity/TodoGoalTests.swift @@ -10,8 +10,8 @@ import Testing @testable import Domain struct TodoGoalTests { - @Test("기존 Todo initializer 함수 값은 goalID 없이 Todo를 생성한다") - func 기존_Todo_initializer_함수_값은_goalID_없이_Todo를_생성한다() { + @Test("기존 Todo initializer 함수 값은 goalId 없이 Todo를 생성한다") + func 기존_Todo_initializer_함수_값은_goalId_없이_Todo를_생성한다() { let initializer = Todo.init( id:isPinned:isCompleted:isChecked:number:title:content:createdAt:updatedAt:completedAt: deletedAt:dueDate:tags:category: @@ -34,11 +34,11 @@ struct TodoGoalTests { .system(.feature) ) - #expect(todo.goalID == nil) + #expect(todo.goalId == nil) } - @Test("기존 TodoDraft initializer 함수 값은 goalID 없이 Draft를 생성한다") - func 기존_TodoDraft_initializer_함수_값은_goalID_없이_Draft를_생성한다() { + @Test("기존 TodoDraft initializer 함수 값은 goalId 없이 Draft를 생성한다") + func 기존_TodoDraft_initializer_함수_값은_goalId_없이_Draft를_생성한다() { let initializer = TodoDraft.init( id:isPinned:isCompleted:isChecked:title:content:createdAt:updatedAt:completedAt:dueDate: tags:category: @@ -59,30 +59,30 @@ struct TodoGoalTests { .system(.feature) ) - #expect(draft.goalID == nil) + #expect(draft.goalId == nil) } - @Test("goalID overload와 TodoDraft 복사는 연결 대상을 보존한다") - func goalID_overload와_TodoDraft_복사는_연결_대상을_보존한다() { - let todo = makeTodo(goalID: "goal-1") + @Test("goalId overload와 TodoDraft 복사는 연결 대상을 보존한다") + func goalId_overload와_TodoDraft_복사는_연결_대상을_보존한다() { + let todo = makeTodo(goalId: "goal-1") let draft = TodoDraft(todo: todo) - #expect(todo.goalID == "goal-1") - #expect(draft.goalID == "goal-1") + #expect(todo.goalId == "goal-1") + #expect(draft.goalId == "goal-1") } - @Test("TodoDraft 동등성 비교는 goalID 변경을 감지한다") - func TodoDraft_동등성_비교는_goalID_변경을_감지한다() { - let todo = makeTodo(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 + unlinkedDraft.goalId = nil #expect(linkedDraft != unlinkedDraft) } } -private func makeTodo(goalID: String? = nil) -> Todo { +private func makeTodo(goalId: String? = nil) -> Todo { Todo( id: "todo-1", isPinned: false, @@ -98,6 +98,6 @@ private func makeTodo(goalID: String? = nil) -> Todo { dueDate: nil, tags: [], category: .system(.feature), - goalID: goalID + goalId: goalId ) } diff --git a/Application/Domain/Tests/UseCase/DevelopmentGoal/DevelopmentGoalUseCaseTests.swift b/Application/Domain/Tests/UseCase/DevelopmentGoal/DevelopmentGoalUseCaseTests.swift index 96369911..1d433888 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentGoal/DevelopmentGoalUseCaseTests.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentGoal/DevelopmentGoalUseCaseTests.swift @@ -16,11 +16,11 @@ struct DevelopmentGoalUseCaseTests { let repository = DevelopmentGoalRepositorySpy(goal: goal) let useCase = CreateDevelopmentGoalUseCaseImpl(repository, idProvider: { "goal-1" }) - let result = try await useCase.execute(title: "목표", markdownDescription: "설명") + let result = try await useCase.execute(title: "목표", description: "설명") #expect(result == goal) #expect(await repository.createRequests() == [ - .init(id: "goal-1", title: "목표", markdownDescription: "설명") + .init(id: "goal-1", title: "목표", description: "설명") ]) } @@ -33,7 +33,7 @@ struct DevelopmentGoalUseCaseTests { let result = try await useCase.execute("goal-1") #expect(result == goal) - #expect(await repository.fetchedGoalIDs() == ["goal-1"]) + #expect(await repository.fetchedGoalIds() == ["goal-1"]) } @Test("목표 목록 조회는 상태를 필터링하고 생성 시각과 ID 오름차순으로 정렬한다") @@ -62,7 +62,7 @@ struct DevelopmentGoalUseCaseTests { try await useCase.execute("goal-1", to: .archived) #expect(await repository.transitions() == [ - .init(goalID: "goal-1", status: .archived, completionSnapshot: nil) + .init(goalId: "goal-1", status: .archived, completionSnapshot: nil) ]) } @@ -76,9 +76,9 @@ struct DevelopmentGoalUseCaseTests { try await useCase.execute("goal-1", to: .completed) - #expect(await repository.completionSnapshotGoalIDs() == ["goal-1"]) + #expect(await repository.completionSnapshotGoalIds() == ["goal-1"]) #expect(await repository.transitions() == [ - .init(goalID: "goal-1", status: .completed, completionSnapshot: snapshot) + .init(goalId: "goal-1", status: .completed, completionSnapshot: snapshot) ]) } @@ -147,11 +147,11 @@ private actor DevelopmentGoalRepositorySpy: DevelopmentGoalRepository { struct CreateRequest: Equatable { let id: String let title: String - let markdownDescription: String + let description: String } struct TransitionRequest: Equatable { - let goalID: String + let goalId: String let status: DevelopmentGoal.Status let completionSnapshot: DevelopmentGoal.CompletionSnapshot? } @@ -160,9 +160,9 @@ private actor DevelopmentGoalRepositorySpy: DevelopmentGoalRepository { private let goals: [DevelopmentGoal] private let snapshot: DevelopmentGoal.CompletionSnapshot? private var recordedCreateRequests = [CreateRequest]() - private var recordedFetchedGoalIDs = [String]() + private var recordedFetchedGoalIds = [String]() private var recordedQueries = [DevelopmentGoal.Query]() - private var recordedCompletionSnapshotGoalIDs = [String]() + private var recordedCompletionSnapshotGoalIds = [String]() private var recordedTransitions = [TransitionRequest]() init( @@ -178,14 +178,14 @@ private actor DevelopmentGoalRepositorySpy: DevelopmentGoalRepository { func createGoal( id: String, title: String, - markdownDescription: String + description: String ) async throws -> DevelopmentGoal { - recordedCreateRequests.append(.init(id: id, title: title, markdownDescription: markdownDescription)) + recordedCreateRequests.append(.init(id: id, title: title, description: description)) return goal } - func fetchGoal(_ goalID: String) async throws -> DevelopmentGoal { - recordedFetchedGoalIDs.append(goalID) + func fetchGoal(_ goalId: String) async throws -> DevelopmentGoal { + recordedFetchedGoalIds.append(goalId) return goal } @@ -194,8 +194,8 @@ private actor DevelopmentGoalRepositorySpy: DevelopmentGoalRepository { return goals } - func fetchCompletionSnapshot(for goalID: String) async throws -> DevelopmentGoal.CompletionSnapshot { - recordedCompletionSnapshotGoalIDs.append(goalID) + func fetchCompletionSnapshot(for goalId: String) async throws -> DevelopmentGoal.CompletionSnapshot { + recordedCompletionSnapshotGoalIds.append(goalId) guard let snapshot else { throw DevelopmentGoalRepositorySpyError.snapshotNotFound } @@ -203,13 +203,13 @@ private actor DevelopmentGoalRepositorySpy: DevelopmentGoalRepository { } func transitionGoalStatus( - _ goalID: String, + _ goalId: String, to status: DevelopmentGoal.Status, completionSnapshot: DevelopmentGoal.CompletionSnapshot? ) async throws { recordedTransitions.append( .init( - goalID: goalID, + goalId: goalId, status: status, completionSnapshot: completionSnapshot ) @@ -220,16 +220,16 @@ private actor DevelopmentGoalRepositorySpy: DevelopmentGoalRepository { recordedCreateRequests } - func fetchedGoalIDs() -> [String] { - recordedFetchedGoalIDs + func fetchedGoalIds() -> [String] { + recordedFetchedGoalIds } func queries() -> [DevelopmentGoal.Query] { recordedQueries } - func completionSnapshotGoalIDs() -> [String] { - recordedCompletionSnapshotGoalIDs + func completionSnapshotGoalIds() -> [String] { + recordedCompletionSnapshotGoalIds } func transitions() -> [TransitionRequest] { @@ -249,7 +249,7 @@ private func makeGoal( try DevelopmentGoal( id: id, title: "개발 목표", - markdownDescription: "설명", + description: "설명", status: status, createdAt: createdAt, updatedAt: createdAt, @@ -261,12 +261,12 @@ private func makeInitialDraftRecord(id: String, createdAt: Date) throws -> Devel let draft = try DevelopmentRecord.Draft( title: "기록", markdownContent: "본문", - baseVersionID: nil, + baseVersionId: nil, updatedAt: createdAt ) return try DevelopmentRecord( id: id, - goalID: "goal-1", + goalId: "goal-1", currentVersion: nil, draft: draft, createdAt: createdAt @@ -277,7 +277,7 @@ private func makeConfirmedRecord(id: String, createdAt: Date) throws -> Developm let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-\(id)", number: 1) return try DevelopmentRecord( id: id, - goalID: "goal-1", + goalId: "goal-1", currentVersion: currentVersion, draft: nil, createdAt: createdAt @@ -289,12 +289,12 @@ private func makeDraftRecord(id: String, createdAt: Date) throws -> DevelopmentR let draft = try DevelopmentRecord.Draft( title: "정정 초안", markdownContent: "본문", - baseVersionID: currentVersion.id, + baseVersionId: currentVersion.id, updatedAt: createdAt ) return try DevelopmentRecord( id: id, - goalID: "goal-1", + goalId: "goal-1", currentVersion: currentVersion, draft: draft, createdAt: createdAt diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift index 328c9eed..0ef8827a 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift @@ -9,7 +9,7 @@ import Foundation import Testing @testable import Domain -actor DevelopmentGoalRepositorySpy: DevelopmentGoalRepository { +actor DevelopmentRecordGoalRepositorySpy: DevelopmentGoalRepository { private let goal: DevelopmentGoal init(goal: DevelopmentGoal) { @@ -19,12 +19,12 @@ actor DevelopmentGoalRepositorySpy: DevelopmentGoalRepository { func createGoal( id: String, title: String, - markdownDescription: String + description: String ) async throws -> DevelopmentGoal { goal } - func fetchGoal(_ goalID: String) async throws -> DevelopmentGoal { + func fetchGoal(_ goalId: String) async throws -> DevelopmentGoal { goal } @@ -32,12 +32,12 @@ actor DevelopmentGoalRepositorySpy: DevelopmentGoalRepository { [goal] } - func fetchCompletionSnapshot(for goalID: String) async throws -> DevelopmentGoal.CompletionSnapshot { + func fetchCompletionSnapshot(for goalId: String) async throws -> DevelopmentGoal.CompletionSnapshot { .init(goal: goal, records: []) } func transitionGoalStatus( - _ goalID: String, + _ goalId: String, to status: DevelopmentGoal.Status, completionSnapshot: DevelopmentGoal.CompletionSnapshot? ) async throws { } @@ -46,34 +46,34 @@ actor DevelopmentGoalRepositorySpy: DevelopmentGoalRepository { actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { struct CreateRequest: Equatable { let id: String - let goalID: String + let goalId: String let draft: DevelopmentRecord.Draft } struct RecordQuery: Equatable { - let goalID: String - let recordID: String + let goalId: String + let recordId: String } struct DraftRequest: Equatable { - let goalID: String - let recordID: String + let goalId: String + let recordId: String let draft: DevelopmentRecord.Draft } struct ConfirmRequest: Equatable { - let goalID: String - let recordID: String - let versionID: String + let goalId: String + let recordId: String + let versionId: String let kind: DevelopmentRecord.Version.Kind - let sourceVersionID: String? + let sourceVersionId: String? } struct RestoreRequest: Equatable { - let goalID: String - let recordID: String - let versionID: String - let sourceVersionID: String + let goalId: String + let recordId: String + let versionId: String + let sourceVersionId: String } private let createResult: DevelopmentRecord? @@ -110,66 +110,66 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { func createRecord( id: String, - goalID: String, + goalId: String, draft: DevelopmentRecord.Draft ) async throws -> DevelopmentRecord { - recordedCreateRequests.append(.init(id: id, goalID: goalID, draft: draft)) - return try required(createResult) + recordedCreateRequests.append(.init(id: id, goalId: goalId, draft: draft)) + return try requiredDevelopmentRecordRepositoryResult(createResult) } - func fetchRecords(goalID: String) async throws -> [DevelopmentRecord] { - recordedRecordQueries.append(goalID) + func fetchRecords(goalId: String) async throws -> [DevelopmentRecord] { + recordedRecordQueries.append(goalId) return records } - func fetchRecord(goalID: String, recordID: String) async throws -> DevelopmentRecord { - try required(record) + 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)) + 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, + goalId: String, + recordId: String, draft: DevelopmentRecord.Draft ) async throws -> DevelopmentRecord { - recordedDraftRequests.append(.init(goalID: goalID, recordID: recordID, draft: draft)) - return try required(savedRecord) + recordedDraftRequests.append(.init(goalId: goalId, recordId: recordId, draft: draft)) + return try requiredDevelopmentRecordRepositoryResult(savedRecord) } func confirmDraft( - goalID: String, - recordID: String, - versionID: String, + goalId: String, + recordId: String, + versionId: String, kind: DevelopmentRecord.Version.Kind, - sourceVersionID: String? + sourceVersionId: String? ) async throws -> DevelopmentRecord.Version { recordedConfirmRequests.append( .init( - goalID: goalID, - recordID: recordID, - versionID: versionID, + goalId: goalId, + recordId: recordId, + versionId: versionId, kind: kind, - sourceVersionID: sourceVersionID + sourceVersionId: sourceVersionId ) ) - return try required(confirmedVersion) + return try requiredDevelopmentRecordRepositoryResult(confirmedVersion) } func restoreVersion( - goalID: String, - recordID: String, - versionID: String, - sourceVersionID: String + goalId: String, + recordId: String, + versionId: String, + sourceVersionId: String ) async throws -> DevelopmentRecord.Version { recordedRestoreRequests.append( .init( - goalID: goalID, recordID: recordID, versionID: versionID, sourceVersionID: sourceVersionID) + goalId: goalId, recordId: recordId, versionId: versionId, sourceVersionId: sourceVersionId) ) - return try required(restoredVersion) + return try requiredDevelopmentRecordRepositoryResult(restoredVersion) } func createRequests() -> [CreateRequest] { @@ -201,20 +201,20 @@ enum DevelopmentRecordRepositorySpyError: Error { case unconfigured } -func required(_ value: Value?) throws -> Value { +func requiredDevelopmentRecordRepositoryResult(_ value: Value?) throws -> Value { guard let value else { throw DevelopmentRecordRepositorySpyError.unconfigured } return value } -func makeGoal( +func makeDevelopmentRecordGoal( status: DevelopmentGoal.Status = .inProgress ) throws -> DevelopmentGoal { try DevelopmentGoal( id: "goal-1", title: "개발 목표", - markdownDescription: "설명", + description: "설명", status: status, createdAt: .distantPast, updatedAt: .distantPast, @@ -222,71 +222,71 @@ func makeGoal( ) } -func makeInitialDraftRecord( +func makeDevelopmentRecordInitialDraft( updatedAt: Date = .distantPast ) throws -> DevelopmentRecord { - try makeRecord( + try makeDevelopmentRecord( currentVersion: nil, draft: DevelopmentRecord.Draft( title: "기록", markdownContent: "본문", - baseVersionID: nil, + baseVersionId: nil, updatedAt: updatedAt ) ) } -func makeConfirmedRecord() throws -> DevelopmentRecord { - try makeRecord( +func makeDevelopmentRecordConfirmed() throws -> DevelopmentRecord { + try makeDevelopmentRecord( currentVersion: DevelopmentRecord.CurrentVersion(id: "version-1", number: 1), draft: nil ) } -func makeRecord( +func makeDevelopmentRecord( currentVersion: DevelopmentRecord.CurrentVersion?, draft: DevelopmentRecord.Draft? ) throws -> DevelopmentRecord { try DevelopmentRecord( id: "record-1", - goalID: "goal-1", + goalId: "goal-1", currentVersion: currentVersion, draft: draft, createdAt: .distantPast ) } -func makeInitialVersion() throws -> DevelopmentRecord.Version { +func makeDevelopmentRecordInitialVersion() throws -> DevelopmentRecord.Version { try DevelopmentRecord.Version( id: "version-1", - recordID: "record-1", + recordId: "record-1", number: 1, title: "기록", markdownContent: "본문", kind: .initial, - sourceVersionID: nil, + sourceVersionId: nil, confirmedAt: .distantPast ) } -func makeCorrectionVersion( +func makeDevelopmentRecordCorrectionVersion( id: String, number: Int = 2, - sourceVersionID: String + sourceVersionId: String ) throws -> DevelopmentRecord.Version { try DevelopmentRecord.Version( id: id, - recordID: "record-1", + recordId: "record-1", number: number, title: "정정 기록", markdownContent: "본문", kind: .correction, - sourceVersionID: sourceVersionID, + sourceVersionId: sourceVersionId, confirmedAt: .distantPast ) } -func expectDomainError( +func expectDevelopmentRecordDomainError( _ expected: DomainLayerError, operation: () async throws -> Value ) async { diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift index 82d3d0f2..56a1d324 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift @@ -12,18 +12,18 @@ import Testing struct DevelopmentRecordUseCaseTests { @Test("진행 중 목표에서 최초 Draft와 함께 기록을 생성한다") func 진행_중_목표에서_최초_Draft와_함께_기록을_생성한다() async throws { - let goal = try makeGoal() + let goal = try makeDevelopmentRecordGoal() let createdAt = Date(timeIntervalSinceReferenceDate: 10) - let record = try makeInitialDraftRecord(updatedAt: createdAt) + let record = try makeDevelopmentRecordInitialDraft(updatedAt: createdAt) let repository = DevelopmentRecordRepositorySpy(createResult: record) let useCase = CreateDevelopmentRecordUseCaseImpl( repository, - DevelopmentGoalRepositorySpy(goal: goal), + DevelopmentRecordGoalRepositorySpy(goal: goal), idProvider: { "record-1" }, now: { createdAt } ) let result = try await useCase.execute( - goalID: "goal-1", + goalId: "goal-1", title: "기록", markdownContent: "본문" ) @@ -32,11 +32,11 @@ struct DevelopmentRecordUseCaseTests { #expect(await repository.createRequests() == [ .init( id: "record-1", - goalID: "goal-1", + goalId: "goal-1", draft: try DevelopmentRecord.Draft( title: "기록", markdownContent: "본문", - baseVersionID: nil, + baseVersionId: nil, updatedAt: createdAt ) ) @@ -49,11 +49,11 @@ struct DevelopmentRecordUseCaseTests { let repository = DevelopmentRecordRepositorySpy() let useCase = CreateDevelopmentRecordUseCaseImpl( repository, - DevelopmentGoalRepositorySpy(goal: try makeGoal(status: status)) + DevelopmentRecordGoalRepositorySpy(goal: try makeDevelopmentRecordGoal(status: status)) ) - await expectDomainError(.developmentGoalIsNotInProgress) { + await expectDevelopmentRecordDomainError(.developmentGoalIsNotInProgress) { try await useCase.execute( - goalID: "goal-1", + goalId: "goal-1", title: "기록", markdownContent: "본문" ) @@ -64,41 +64,41 @@ struct DevelopmentRecordUseCaseTests { @Test("기록 목록 조회는 Repository 결과를 반환한다") func 기록_목록_조회는_Repository_결과를_반환한다() async throws { - let record = try makeInitialDraftRecord() + let record = try makeDevelopmentRecordInitialDraft() let repository = DevelopmentRecordRepositorySpy(records: [record]) let useCase = FetchDevelopmentRecordsUseCaseImpl(repository) - let result = try await useCase.execute(goalID: "goal-1") + 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 makeInitialVersion() + 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") + 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") + .init(goalId: "goal-1", recordId: "record-1") ]) } @Test("최초 Draft 저장은 기준 버전 없이 갱신한다") func 최초_Draft_저장은_기준_버전_없이_갱신한다() async throws { - let goal = try makeGoal() - let record = try makeInitialDraftRecord() - let savedRecord = try makeInitialDraftRecord() + 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, - DevelopmentGoalRepositorySpy(goal: goal), + DevelopmentRecordGoalRepositorySpy(goal: goal), now: { updatedAt } ) let result = try await useCase.execute( - goalID: "goal-1", - recordID: "record-1", + goalId: "goal-1", + recordId: "record-1", title: "수정 기록", markdownContent: "수정 본문" ) @@ -106,12 +106,12 @@ struct DevelopmentRecordUseCaseTests { #expect(result == savedRecord) #expect(await repository.draftRequests() == [ .init( - goalID: "goal-1", - recordID: "record-1", + goalId: "goal-1", + recordId: "record-1", draft: try DevelopmentRecord.Draft( title: "수정 기록", markdownContent: "수정 본문", - baseVersionID: nil, + baseVersionId: nil, updatedAt: updatedAt ) ) @@ -120,23 +120,23 @@ struct DevelopmentRecordUseCaseTests { @Test("확정 버전 기반 Draft 저장은 기준 버전을 유지한다") func 확정_버전_기반_Draft_저장은_기준_버전을_유지한다() async throws { - let goal = try makeGoal() + let goal = try makeDevelopmentRecordGoal() let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-1", number: 1) - let record = try makeRecord( + let record = try makeDevelopmentRecord( currentVersion: currentVersion, draft: DevelopmentRecord.Draft( title: "정정 초안", markdownContent: "본문", - baseVersionID: currentVersion.id, + baseVersionId: currentVersion.id, updatedAt: .distantPast ) ) - let savedRecord = try makeRecord( + let savedRecord = try makeDevelopmentRecord( currentVersion: currentVersion, draft: DevelopmentRecord.Draft( title: "수정 정정 초안", markdownContent: "수정 본문", - baseVersionID: currentVersion.id, + baseVersionId: currentVersion.id, updatedAt: .distantFuture ) ) @@ -144,32 +144,32 @@ struct DevelopmentRecordUseCaseTests { let repository = DevelopmentRecordRepositorySpy(record: record, savedRecord: savedRecord) let useCase = SaveDevelopmentRecordDraftUseCaseImpl( repository, - DevelopmentGoalRepositorySpy(goal: goal), + DevelopmentRecordGoalRepositorySpy(goal: goal), now: { updatedAt } ) _ = try await useCase.execute( - goalID: "goal-1", - recordID: "record-1", + goalId: "goal-1", + recordId: "record-1", title: "수정 정정 초안", markdownContent: "수정 본문" ) let requests = await repository.draftRequests() - #expect(requests.first?.draft.baseVersionID == currentVersion.id) + #expect(requests.first?.draft.baseVersionId == currentVersion.id) #expect(requests.first?.draft.updatedAt == updatedAt) } @Test("확정 기록의 저장은 현재 버전 기준 정정 Draft를 시작한다") func 확정_기록의_저장은_현재_버전_기준_정정_Draft를_시작한다() async throws { - let goal = try makeGoal() - let record = try makeConfirmedRecord() + let goal = try makeDevelopmentRecordGoal() + let record = try makeDevelopmentRecordConfirmed() let currentVersion = try #require(record.currentVersion) - let savedRecord = try makeRecord( + let savedRecord = try makeDevelopmentRecord( currentVersion: currentVersion, draft: DevelopmentRecord.Draft( title: "정정 초안", markdownContent: "본문", - baseVersionID: currentVersion.id, + baseVersionId: currentVersion.id, updatedAt: .distantFuture ) ) @@ -177,12 +177,12 @@ struct DevelopmentRecordUseCaseTests { let repository = DevelopmentRecordRepositorySpy(record: record, savedRecord: savedRecord) let useCase = SaveDevelopmentRecordDraftUseCaseImpl( repository, - DevelopmentGoalRepositorySpy(goal: goal), + DevelopmentRecordGoalRepositorySpy(goal: goal), now: { updatedAt } ) let result = try await useCase.execute( - goalID: "goal-1", - recordID: "record-1", + goalId: "goal-1", + recordId: "record-1", title: "정정 초안", markdownContent: "본문" ) @@ -190,12 +190,12 @@ struct DevelopmentRecordUseCaseTests { #expect(result == savedRecord) #expect(await repository.draftRequests() == [ .init( - goalID: "goal-1", - recordID: "record-1", + goalId: "goal-1", + recordId: "record-1", draft: try DevelopmentRecord.Draft( title: "정정 초안", markdownContent: "본문", - baseVersionID: currentVersion.id, + baseVersionId: currentVersion.id, updatedAt: updatedAt ) ) @@ -204,99 +204,102 @@ struct DevelopmentRecordUseCaseTests { @Test("최초 Draft 확정은 initial 버전으로 요청한다") func 최초_Draft_확정은_initial_버전으로_요청한다() async throws { - let goal = try makeGoal() - let record = try makeInitialDraftRecord() - let version = try makeInitialVersion() + let goal = try makeDevelopmentRecordGoal() + let record = try makeDevelopmentRecordInitialDraft() + let version = try makeDevelopmentRecordInitialVersion() let repository = DevelopmentRecordRepositorySpy(record: record, confirmedVersion: version) let useCase = ConfirmDevelopmentRecordUseCaseImpl( repository, - DevelopmentGoalRepositorySpy(goal: goal), + DevelopmentRecordGoalRepositorySpy(goal: goal), idProvider: { "version-1" } ) - let result = try await useCase.execute(goalID: "goal-1", recordID: "record-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", + goalId: "goal-1", + recordId: "record-1", + versionId: "version-1", kind: .initial, - sourceVersionID: nil + sourceVersionId: nil ) ]) } @Test("확정 버전 기반 Draft 확정은 correction 버전으로 요청한다") func 확정_버전_기반_Draft_확정은_correction_버전으로_요청한다() async throws { - let goal = try makeGoal() + let goal = try makeDevelopmentRecordGoal() let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-1", number: 1) - let record = try makeRecord( + let record = try makeDevelopmentRecord( currentVersion: currentVersion, draft: DevelopmentRecord.Draft( title: "정정 초안", markdownContent: "본문", - baseVersionID: currentVersion.id, + baseVersionId: currentVersion.id, updatedAt: .distantPast ) ) - let version = try makeCorrectionVersion(id: "version-2", sourceVersionID: currentVersion.id) + let version = try makeDevelopmentRecordCorrectionVersion(id: "version-2", sourceVersionId: currentVersion.id) let repository = DevelopmentRecordRepositorySpy(record: record, confirmedVersion: version) let useCase = ConfirmDevelopmentRecordUseCaseImpl( repository, - DevelopmentGoalRepositorySpy(goal: goal), + DevelopmentRecordGoalRepositorySpy(goal: goal), idProvider: { "version-2" } ) - _ = try await useCase.execute(goalID: "goal-1", recordID: "record-1") + _ = try await useCase.execute(goalId: "goal-1", recordId: "record-1") #expect(await repository.confirmRequests() == [ .init( - goalID: "goal-1", - recordID: "record-1", - versionID: "version-2", + goalId: "goal-1", + recordId: "record-1", + versionId: "version-2", kind: .correction, - sourceVersionID: currentVersion.id + sourceVersionId: currentVersion.id ) ]) } @Test("Draft가 없는 기록의 확정을 거부한다") func Draft가_없는_기록의_확정을_거부한다() async throws { - let goal = try makeGoal() - let repository = DevelopmentRecordRepositorySpy(record: try makeConfirmedRecord()) + let goal = try makeDevelopmentRecordGoal() + let repository = DevelopmentRecordRepositorySpy(record: try makeDevelopmentRecordConfirmed()) let useCase = ConfirmDevelopmentRecordUseCaseImpl( repository, - DevelopmentGoalRepositorySpy(goal: goal) + DevelopmentRecordGoalRepositorySpy(goal: goal) ) - await expectDomainError(.developmentRecordDraftNotFound) { - try await useCase.execute(goalID: "goal-1", recordID: "record-1") + 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 makeGoal() + let goal = try makeDevelopmentRecordGoal() let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-3", number: 3) - let record = try makeRecord(currentVersion: currentVersion, draft: nil) - let initialVersion = try makeInitialVersion() - let correctionVersion = try makeCorrectionVersion(id: "version-2", sourceVersionID: "version-1") - let currentRecordVersion = try makeCorrectionVersion( + 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" + sourceVersionId: "version-2" ) let rollbackVersion = try DevelopmentRecord.Version( id: "version-4", - recordID: "record-1", + recordId: "record-1", number: 4, title: "되돌린 기록", markdownContent: "본문", kind: .rollback, - sourceVersionID: "version-1", + sourceVersionId: "version-1", confirmedAt: .distantFuture ) let repository = DevelopmentRecordRepositorySpy( @@ -306,63 +309,60 @@ struct DevelopmentRecordUseCaseTests { ) let useCase = RestoreDevelopmentRecordUseCaseImpl( repository, - DevelopmentGoalRepositorySpy(goal: goal), + DevelopmentRecordGoalRepositorySpy(goal: goal), idProvider: { "version-4" } ) - let result = try await useCase.execute( - goalID: "goal-1", - recordID: "record-1", - sourceVersionID: "version-1" + 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" + goalId: "goal-1", + recordId: "record-1", + versionId: "version-4", + sourceVersionId: "version-1" ) ]) } @Test("현재 또는 다른 기록의 버전은 되돌리기 대상으로 거부한다") func 현재_또는_다른_기록의_버전은_되돌리기_대상으로_거부한다() async throws { - let goal = try makeGoal() + let goal = try makeDevelopmentRecordGoal() let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-2", number: 2) - let record = try makeRecord(currentVersion: currentVersion, draft: nil) - let currentRecordVersion = try makeCorrectionVersion( + let record = try makeDevelopmentRecord(currentVersion: currentVersion, draft: nil) + let currentRecordVersion = try makeDevelopmentRecordCorrectionVersion( id: "version-2", number: 2, - sourceVersionID: "version-1" + sourceVersionId: "version-1" ) let otherRecordVersion = try DevelopmentRecord.Version( id: "other-version-1", - recordID: "record-2", + recordId: "record-2", number: 1, title: "다른 기록", markdownContent: "본문", kind: .initial, - sourceVersionID: nil, + sourceVersionId: nil, confirmedAt: .distantPast ) - - for sourceVersionID in [currentRecordVersion.id, otherRecordVersion.id] { + for sourceVersionId in [currentRecordVersion.id, otherRecordVersion.id] { let repository = DevelopmentRecordRepositorySpy( record: record, versions: [currentRecordVersion, otherRecordVersion] ) let useCase = RestoreDevelopmentRecordUseCaseImpl( repository, - DevelopmentGoalRepositorySpy(goal: goal) + DevelopmentRecordGoalRepositorySpy(goal: goal) ) - await expectDomainError(.developmentRecordVersionNotFound) { + await expectDevelopmentRecordDomainError(.developmentRecordVersionNotFound) { try await useCase.execute( - goalID: "goal-1", - recordID: "record-1", - sourceVersionID: sourceVersionID + goalId: "goal-1", + recordId: "record-1", + sourceVersionId: sourceVersionId ) } #expect(await repository.restoreRequests().isEmpty) @@ -371,28 +371,28 @@ struct DevelopmentRecordUseCaseTests { @Test("Draft가 있으면 버전 되돌리기를 거부한다") func Draft가_있으면_버전_되돌리기를_거부한다() async throws { - let goal = try makeGoal() + let goal = try makeDevelopmentRecordGoal() let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-1", number: 1) - let record = try makeRecord( + let record = try makeDevelopmentRecord( currentVersion: currentVersion, draft: DevelopmentRecord.Draft( title: "정정 초안", markdownContent: "본문", - baseVersionID: currentVersion.id, + baseVersionId: currentVersion.id, updatedAt: .distantPast ) ) let repository = DevelopmentRecordRepositorySpy(record: record) let useCase = RestoreDevelopmentRecordUseCaseImpl( repository, - DevelopmentGoalRepositorySpy(goal: goal) + DevelopmentRecordGoalRepositorySpy(goal: goal) ) - await expectDomainError(.developmentRecordDraftConflict) { + await expectDevelopmentRecordDomainError(.developmentRecordDraftConflict) { try await useCase.execute( - goalID: "goal-1", - recordID: "record-1", - sourceVersionID: "version-1" + 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 index 2b7844dd..47ce1194 100644 --- a/Application/Domain/Tests/UseCase/Todo/UpdateTodoGoalUseCaseImplTests.swift +++ b/Application/Domain/Tests/UseCase/Todo/UpdateTodoGoalUseCaseImplTests.swift @@ -17,33 +17,33 @@ struct UpdateTodoGoalUseCaseImplTests { let goalRepository = DevelopmentGoalRepositorySpy(goal: try makeGoal(id: "goal-1")) let useCase = UpdateTodoGoalUseCaseImpl(todoRepository, goalRepository) - try await useCase.execute(todoID: "todo-1", goalID: "goal-1") + try await useCase.execute(todoId: "todo-1", goalId: "goal-1") - #expect(await goalRepository.fetchedGoalIDs() == ["goal-1"]) - #expect(await todoRepository.savedTodos().map(\.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 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") + try await useCase.execute(todoId: "todo-1", goalId: "goal-2") - #expect(await todoRepository.savedTodos().map(\.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 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) + try await useCase.execute(todoId: "todo-1", goalId: nil) - #expect(await goalRepository.fetchedGoalIDs().isEmpty) - #expect(await todoRepository.savedTodos().map(\.goalID) == [nil]) + #expect(await goalRepository.fetchedGoalIds().isEmpty) + #expect(await todoRepository.savedTodos().map(\.goalId) == [nil]) } @Test("존재하지 않는 목표는 Todo 저장 전에 거부한다") @@ -53,7 +53,7 @@ struct UpdateTodoGoalUseCaseImplTests { let useCase = UpdateTodoGoalUseCaseImpl(todoRepository, goalRepository) await #expect(throws: DevelopmentGoalRepositorySpyError.notFound) { - try await useCase.execute(todoID: "todo-1", goalID: "missing-goal") + try await useCase.execute(todoId: "todo-1", goalId: "missing-goal") } #expect(await todoRepository.savedTodos().isEmpty) } @@ -71,7 +71,7 @@ private actor TodoRepositorySpy: TodoRepository { fatalError() } - func fetchTodo(_ todoID: String) async throws -> Todo { + func fetchTodo(_ todoId: String) async throws -> Todo { todo } @@ -87,11 +87,11 @@ private actor TodoRepositorySpy: TodoRepository { fatalError() } - func deleteTodo(_ todoID: String) async throws { + func deleteTodo(_ todoId: String) async throws { fatalError() } - func undoDeleteTodo(_ todoID: String) async throws { + func undoDeleteTodo(_ todoId: String) async throws { fatalError() } @@ -102,7 +102,7 @@ private actor TodoRepositorySpy: TodoRepository { private actor DevelopmentGoalRepositorySpy: DevelopmentGoalRepository { private let result: Result - private var recordedFetchedGoalIDs = [String]() + private var recordedFetchedGoalIds = [String]() init(goal: DevelopmentGoal) { self.result = .success(goal) @@ -115,13 +115,13 @@ private actor DevelopmentGoalRepositorySpy: DevelopmentGoalRepository { func createGoal( id: String, title: String, - markdownDescription: String + description: String ) async throws -> DevelopmentGoal { try result.get() } - func fetchGoal(_ goalID: String) async throws -> DevelopmentGoal { - recordedFetchedGoalIDs.append(goalID) + func fetchGoal(_ goalId: String) async throws -> DevelopmentGoal { + recordedFetchedGoalIds.append(goalId) return try result.get() } @@ -129,18 +129,18 @@ private actor DevelopmentGoalRepositorySpy: DevelopmentGoalRepository { [try result.get()] } - func fetchCompletionSnapshot(for goalID: String) async throws -> DevelopmentGoal.CompletionSnapshot { + func fetchCompletionSnapshot(for goalId: String) async throws -> DevelopmentGoal.CompletionSnapshot { .init(goal: try result.get(), records: []) } func transitionGoalStatus( - _ goalID: String, + _ goalId: String, to status: DevelopmentGoal.Status, completionSnapshot: DevelopmentGoal.CompletionSnapshot? ) async throws { } - func fetchedGoalIDs() -> [String] { - recordedFetchedGoalIDs + func fetchedGoalIds() -> [String] { + recordedFetchedGoalIds } } @@ -148,7 +148,7 @@ private enum DevelopmentGoalRepositorySpyError: Error, Equatable { case notFound } -private func makeTodo(goalID: String? = nil) -> Todo { +private func makeTodo(goalId: String? = nil) -> Todo { Todo( id: "todo-1", isPinned: false, @@ -164,7 +164,7 @@ private func makeTodo(goalID: String? = nil) -> Todo { dueDate: nil, tags: [], category: .system(.feature), - goalID: goalID + goalId: goalId ) } @@ -172,7 +172,7 @@ private func makeGoal(id: String) throws -> DevelopmentGoal { try DevelopmentGoal( id: id, title: "개발 목표", - markdownDescription: "설명", + description: "설명", status: .inProgress, createdAt: .distantPast, updatedAt: .distantPast, From 6e77a1bd68cec19bea922e9f03f5ab39c42b05d9 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 27 Aug 2026 22:46:57 +0900 Subject: [PATCH 08/10] =?UTF-8?q?refactor:=20Todo=20=EB=AA=A9=ED=91=9C=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EA=B4=80=EB=A0=A8=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EC=9E=90=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/Domain/Sources/Entity/Todo.swift | 37 +-------- .../Domain/Sources/Entity/TodoDraft.swift | 33 +------- .../Domain/Tests/Entity/TodoGoalTests.swift | 78 ++++++++----------- 3 files changed, 36 insertions(+), 112 deletions(-) diff --git a/Application/Domain/Sources/Entity/Todo.swift b/Application/Domain/Sources/Entity/Todo.swift index 243bfd2a..27f64cb2 100644 --- a/Application/Domain/Sources/Entity/Todo.swift +++ b/Application/Domain/Sources/Entity/Todo.swift @@ -24,41 +24,6 @@ public struct Todo: Hashable { public var tags: [String] // 할 일에 연결된 태그들 public var category: TodoCategory // 할 일의 종류 - public init( - id: String, - isPinned: Bool, - isCompleted: Bool, - isChecked: Bool, - number: Int, - title: String, - content: String, - createdAt: Date, - updatedAt: Date, - completedAt: Date?, - deletedAt: Date?, - dueDate: Date?, - tags: [String], - category: TodoCategory - ) { - self.init( - id: id, - isPinned: isPinned, - isCompleted: isCompleted, - isChecked: isChecked, - number: number, - title: title, - content: content, - createdAt: createdAt, - updatedAt: updatedAt, - completedAt: completedAt, - deletedAt: deletedAt, - dueDate: dueDate, - tags: tags, - category: category, - goalId: nil - ) - } - public init( id: String, isPinned: Bool, @@ -74,7 +39,7 @@ public struct Todo: Hashable { dueDate: Date?, tags: [String], category: TodoCategory, - goalId: String? + goalId: String? = nil ) { self.id = id self.goalId = goalId diff --git a/Application/Domain/Sources/Entity/TodoDraft.swift b/Application/Domain/Sources/Entity/TodoDraft.swift index 3cd85d21..0ecf7bba 100644 --- a/Application/Domain/Sources/Entity/TodoDraft.swift +++ b/Application/Domain/Sources/Entity/TodoDraft.swift @@ -22,37 +22,6 @@ public struct TodoDraft: Equatable { public var tags: [String] public var category: TodoCategory - public init( - id: String, - isPinned: Bool, - isCompleted: Bool, - isChecked: Bool, - title: String, - content: String, - createdAt: Date, - updatedAt: Date, - completedAt: Date?, - dueDate: Date?, - tags: [String], - category: TodoCategory - ) { - self.init( - id: id, - isPinned: isPinned, - isCompleted: isCompleted, - isChecked: isChecked, - title: title, - content: content, - createdAt: createdAt, - updatedAt: updatedAt, - completedAt: completedAt, - dueDate: dueDate, - tags: tags, - category: category, - goalId: nil - ) - } - public init( id: String, isPinned: Bool, @@ -66,7 +35,7 @@ public struct TodoDraft: Equatable { dueDate: Date?, tags: [String], category: TodoCategory, - goalId: String? + goalId: String? = nil ) { self.id = id self.goalId = goalId diff --git a/Application/Domain/Tests/Entity/TodoGoalTests.swift b/Application/Domain/Tests/Entity/TodoGoalTests.swift index a332fbf7..a5ce67d4 100644 --- a/Application/Domain/Tests/Entity/TodoGoalTests.swift +++ b/Application/Domain/Tests/Entity/TodoGoalTests.swift @@ -10,60 +10,50 @@ import Testing @testable import Domain struct TodoGoalTests { - @Test("기존 Todo initializer 함수 값은 goalId 없이 Todo를 생성한다") - func 기존_Todo_initializer_함수_값은_goalId_없이_Todo를_생성한다() { - let initializer = Todo.init( - id:isPinned:isCompleted:isChecked:number:title:content:createdAt:updatedAt:completedAt: - deletedAt:dueDate:tags:category: - ) - - let todo = initializer( - "todo-1", - false, - false, - false, - 1, - "할 일", - "내용", - .distantPast, - .distantPast, - nil, - nil, - nil, - [], - .system(.feature) + @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 initializer 함수 값은 goalId 없이 Draft를 생성한다") - func 기존_TodoDraft_initializer_함수_값은_goalId_없이_Draft를_생성한다() { - let initializer = TodoDraft.init( - id:isPinned:isCompleted:isChecked:title:content:createdAt:updatedAt:completedAt:dueDate: - tags:category: - ) - - let draft = initializer( - "todo-1", - false, - false, - false, - "할 일", - "내용", - .distantPast, - .distantPast, - nil, - nil, - [], - .system(.feature) + @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 overload와 TodoDraft 복사는 연결 대상을 보존한다") - func goalId_overload와_TodoDraft_복사는_연결_대상을_보존한다() { + @Test("goalId 인자와 TodoDraft 복사는 연결 대상을 보존한다") + func goalId_인자와_TodoDraft_복사는_연결_대상을_보존한다() { let todo = makeTodo(goalId: "goal-1") let draft = TodoDraft(todo: todo) From 0cdc8986f226a34db38090911048370347f8f9a2 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 27 Aug 2026 23:02:53 +0900 Subject: [PATCH 09/10] =?UTF-8?q?docs:=20Todo=20=EC=86=8D=EC=84=B1=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/Domain/Sources/Entity/Todo.swift | 28 ++++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Application/Domain/Sources/Entity/Todo.swift b/Application/Domain/Sources/Entity/Todo.swift index 27f64cb2..4844db4e 100644 --- a/Application/Domain/Sources/Entity/Todo.swift +++ b/Application/Domain/Sources/Entity/Todo.swift @@ -9,20 +9,20 @@ import Foundation public struct Todo: Hashable { public let id: String - public var goalId: 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, From c5ae23fcff391c10ef9a401b34ae013f65d05e4d Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 27 Aug 2026 23:40:19 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20Spec=20=ED=98=95=EC=8B=9D=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=B6=94=EC=A0=81=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/specs/README.md | 56 +++++++++++++++++++++++++++++++++++++++++ .gitignore | 3 ++- 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 .agents/specs/README.md diff --git a/.agents/specs/README.md b/.agents/specs/README.md new file mode 100644 index 00000000..0f9cc1c4 --- /dev/null +++ b/.agents/specs/README.md @@ -0,0 +1,56 @@ +# DevLog Spec Format + +`Designer Result`를 사용자가 승인한 뒤, Planner가 비단순 설계 또는 구현 작업마다 이 디렉터리에 Spec을 작성한다. 이슈 기반 작업은 `-.md`, 이슈 없는 사용자 요청은 `user--.md` 형식을 사용한다. + +## Responsibility + +- `Design Brief`는 Planner가 요청, 현재 상태, 범위, 제외 범위, 알려진 제약을 Designer에게 전달하는 입력이다. +- `Designer Result`는 Designer가 제약, 대안, 변경 경계, 수용 기준, 검증, 최소 커밋 단위를 분석한 승인 대기 결과다. +- Spec은 사용자가 승인한 `Designer Result`를 영속화한 구현·검토·검증의 공통 기준이다. +- `Task Packet`은 승인된 Spec 경로와 수용 기준을 참조하고, 현재 작업의 역할 배정과 실행 권한을 전달한다. + +## Required format + +```md +# + +- Source: +- Approved Designer Result: +- User approval: + +## Constraints + +- + +## Alternatives and decision + +- + +## Changed boundaries + +- + +## Acceptance criteria + +- [ ] + +## Verification + +- Command: +- Evidence: + +## Minimum commit units + +1. + +## Execution constraints + +- app or Simulator execution: +- External writes: +- CI or PR actions: +``` + +## Change control + +- 구현 중 요구 사항 또는 범위가 바뀌면 Spec을 수정하고 사용자 재승인을 받은 뒤에만 `Task Packet`과 구현을 갱신한다. +- Spec은 동작과 수용 기준, 금지된 실행을 기록한다. `Task Packet`은 역할별 현재 작업 권한과 실제 검증 명령을 별도로 기록한다. diff --git a/.gitignore b/.gitignore index 3dff74b5..41e69d89 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ ## AI docs/superpowers -.agents/specs/ +.agents/specs/* +!.agents/specs/README.md .agents/plans/ # MACOS