From 39744d8c6654822b8f7f57c728ea7b7ddda1c353 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 28 Aug 2026 00:49:52 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20Todo=20=EB=AA=A9=ED=91=9C=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EC=A0=80=EC=9E=A5=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/Data/Sources/DTO/TodoDTO.swift | 10 ++- .../Data/Sources/Mapper/TodoMapping.swift | 9 +- .../Repository/TodoRepositoryImpl.swift | 3 +- .../Tests/Mapper/TodoGoalMappingTests.swift | 80 +++++++++++++++++ .../Sources/Service/TodoServiceImpl.swift | 90 +++++++++++-------- .../Service/TodoGoalIDStorageTests.swift | 74 +++++++++++++++ 6 files changed, 223 insertions(+), 43 deletions(-) create mode 100644 Application/Data/Tests/Mapper/TodoGoalMappingTests.swift create mode 100644 Application/Infra/Tests/Service/TodoGoalIDStorageTests.swift diff --git a/Application/Data/Sources/DTO/TodoDTO.swift b/Application/Data/Sources/DTO/TodoDTO.swift index 93860a84..79e2a4fc 100644 --- a/Application/Data/Sources/DTO/TodoDTO.swift +++ b/Application/Data/Sources/DTO/TodoDTO.swift @@ -9,6 +9,7 @@ import Foundation public struct TodoRequest: Encodable { public let id: String + public let goalId: String? public let isPinned: Bool public let isCompleted: Bool public let isChecked: Bool @@ -35,9 +36,11 @@ public struct TodoRequest: Encodable { deletedAt: Date?, dueDate: Date?, tags: [String], - category: String + category: String, + goalId: String? = nil ) { self.id = id + self.goalId = goalId self.isPinned = isPinned self.isCompleted = isCompleted self.isChecked = isChecked @@ -55,6 +58,7 @@ public struct TodoRequest: Encodable { public struct TodoResponse { public let id: String + public let goalId: String? public let isPinned: Bool public let isCompleted: Bool public let isChecked: Bool @@ -83,9 +87,11 @@ public struct TodoResponse { deletedAt: Date?, dueDate: Date?, tags: [String], - category: TodoCategoryResponse + category: TodoCategoryResponse, + goalId: String? = nil ) { self.id = id + self.goalId = goalId self.isPinned = isPinned self.isCompleted = isCompleted self.isChecked = isChecked diff --git a/Application/Data/Sources/Mapper/TodoMapping.swift b/Application/Data/Sources/Mapper/TodoMapping.swift index e6cec398..fdabcbfa 100644 --- a/Application/Data/Sources/Mapper/TodoMapping.swift +++ b/Application/Data/Sources/Mapper/TodoMapping.swift @@ -23,7 +23,8 @@ public extension TodoRequest { deletedAt: todo.deletedAt, dueDate: todo.dueDate, tags: todo.tags, - category: todo.category.storageValue + category: todo.category.storageValue, + goalId: todo.goalId ) } @@ -41,7 +42,8 @@ public extension TodoRequest { deletedAt: nil, dueDate: todoDraft.dueDate, tags: todoDraft.tags, - category: todoDraft.category.storageValue + category: todoDraft.category.storageValue, + goalId: todoDraft.goalId ) } } @@ -71,7 +73,8 @@ public extension TodoResponse { deletedAt: self.deletedAt, dueDate: self.dueDate, tags: self.tags, - category: todoCategory + category: todoCategory, + goalId: goalId ) } } diff --git a/Application/Data/Sources/Repository/TodoRepositoryImpl.swift b/Application/Data/Sources/Repository/TodoRepositoryImpl.swift index f7af928a..413f3991 100644 --- a/Application/Data/Sources/Repository/TodoRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/TodoRepositoryImpl.swift @@ -225,7 +225,8 @@ private extension TodoRepositoryImpl { deletedAt: response.deletedAt, dueDate: response.dueDate, tags: response.tags, - category: .decoded(category) + category: .decoded(category), + goalId: response.goalId ) } diff --git a/Application/Data/Tests/Mapper/TodoGoalMappingTests.swift b/Application/Data/Tests/Mapper/TodoGoalMappingTests.swift new file mode 100644 index 00000000..7ed92b64 --- /dev/null +++ b/Application/Data/Tests/Mapper/TodoGoalMappingTests.swift @@ -0,0 +1,80 @@ +// +// TodoGoalMappingTests.swift +// DataTests +// +// Created by opfic on 8/28/26. +// + +import Foundation +import Testing +import Domain +@testable import Data + +struct TodoGoalMappingTests { + @Test("Todo 목표 연결은 저장 요청에 보존한다") + func Todo_목표_연결은_저장_요청에_보존한다() { + let todo = makeTodo(goalId: "goal-1") + + let request = TodoRequest.fromDomain(todo) + + #expect(request.goalId == "goal-1") + } + + @Test("Todo 초안 목표 연결은 저장 요청에 보존한다") + func Todo_초안_목표_연결은_저장_요청에_보존한다() { + let draft = TodoDraft(todo: makeTodo(goalId: "goal-1")) + + let request = TodoRequest.fromDomain(draft) + + #expect(request.goalId == "goal-1") + } + + @Test("목표 연결이 없는 Todo 응답은 nil로 변환한다") + func 목표_연결이_없는_Todo_응답은_nil로_변환한다() throws { + let response = makeResponse(goalId: nil) + + let todo = try response.toDomain() + + #expect(todo.goalId == nil) + } + + private func makeTodo(goalId: String?) -> Todo { + Todo( + id: "todo-1", + isPinned: false, + isCompleted: false, + isChecked: false, + number: 1, + title: "Todo", + content: "내용", + createdAt: .distantPast, + updatedAt: .distantPast, + completedAt: nil, + deletedAt: nil, + dueDate: nil, + tags: [], + category: .system(.feature), + goalId: goalId + ) + } + + private func makeResponse(goalId: String?) -> TodoResponse { + TodoResponse( + id: "todo-1", + isPinned: false, + isCompleted: false, + isChecked: false, + number: 1, + title: "Todo", + content: "내용", + createdAt: .distantPast, + updatedAt: .distantPast, + completedAt: nil, + deletedAt: nil, + dueDate: nil, + tags: [], + category: .decoded(.system(.feature)), + goalId: goalId + ) + } +} diff --git a/Application/Infra/Sources/Service/TodoServiceImpl.swift b/Application/Infra/Sources/Service/TodoServiceImpl.swift index c86cb858..7a72cdf3 100644 --- a/Application/Infra/Sources/Service/TodoServiceImpl.swift +++ b/Application/Infra/Sources/Service/TodoServiceImpl.swift @@ -182,17 +182,7 @@ final class TodoServiceImpl: TodoService { do { let collection = store.collection(FirestorePath.todos(uid)) let docRef = collection.document(request.id) - var data = try encoder.encode(request) - data.removeValue(forKey: TodoFieldKey.id.rawValue) - if request.completedAt == nil { - data[TodoFieldKey.completedAt.rawValue] = NSNull() - } - if request.deletedAt == nil { - data[TodoFieldKey.deletedAt.rawValue] = NSNull() - } - if request.dueDate == nil { - data[TodoFieldKey.dueDate.rawValue] = NSNull() - } + let data = try Self.makeDocumentData(from: request, encoder: encoder) try await upsertTodoWithNumberOnCreate( data, for: docRef, @@ -500,17 +490,63 @@ private extension TodoServiceImpl { } func makeResponse(from snapshot: QueryDocumentSnapshot) -> TodoResponse? { - return makeResponse(documentID: snapshot.documentID, data: snapshot.data()) + Self.makeResponse(documentID: snapshot.documentID, data: snapshot.data()) } func makeResponse(from snapshot: DocumentSnapshot) -> TodoResponse? { guard let data = snapshot.data() else { return nil } - return makeResponse(documentID: snapshot.documentID, data: data) + return Self.makeResponse(documentID: snapshot.documentID, data: data) + } + + enum TodoFieldKey: String { + case id + case goalId + case isPinned + case isCompleted + case isChecked + case number + case title + case content + case createdAt + case updatedAt + case completedAt + case deletedAt + case dueDate + case tags + case category + } + + enum CounterFieldKey: String { + case nextNumber + case updatedAt + } +} + +extension TodoServiceImpl { + static func makeDocumentData( + from request: TodoRequest, + encoder: Firestore.Encoder = .init() + ) throws -> [String: Any] { + var data = try encoder.encode(request) + data.removeValue(forKey: TodoFieldKey.id.rawValue) + if request.completedAt == nil { + data[TodoFieldKey.completedAt.rawValue] = NSNull() + } + if request.deletedAt == nil { + data[TodoFieldKey.deletedAt.rawValue] = NSNull() + } + if request.dueDate == nil { + data[TodoFieldKey.dueDate.rawValue] = NSNull() + } + if request.goalId == nil { + data[TodoFieldKey.goalId.rawValue] = FieldValue.delete() + } + return data } - func makeResponse(documentID: String, data: [String: Any]) -> TodoResponse? { + static func makeResponse(documentID: String, data: [String: Any]) -> TodoResponse? { guard let number = data[TodoFieldKey.number.rawValue] as? Int, let title = data[TodoFieldKey.title.rawValue] as? String, @@ -529,6 +565,7 @@ private extension TodoServiceImpl { let isChecked = data[TodoFieldKey.isChecked.rawValue] as? Bool ?? false let content = data[TodoFieldKey.content.rawValue] as? String ?? "" let tags = data[TodoFieldKey.tags.rawValue] as? [String] ?? [] + let goalId = data[TodoFieldKey.goalId.rawValue] as? String return TodoResponse( id: documentID, @@ -544,31 +581,10 @@ private extension TodoServiceImpl { deletedAt: deletedAt, dueDate: dueDate, tags: tags, - category: .raw(category) + category: .raw(category), + goalId: goalId ) } - - enum TodoFieldKey: String { - case id - case isPinned - case isCompleted - case isChecked - case number - case title - case content - case createdAt - case updatedAt - case completedAt - case deletedAt - case dueDate - case tags - case category - } - - enum CounterFieldKey: String { - case nextNumber - case updatedAt - } } private extension TodoQuery.SortTarget { diff --git a/Application/Infra/Tests/Service/TodoGoalIDStorageTests.swift b/Application/Infra/Tests/Service/TodoGoalIDStorageTests.swift new file mode 100644 index 00000000..a11dc5de --- /dev/null +++ b/Application/Infra/Tests/Service/TodoGoalIDStorageTests.swift @@ -0,0 +1,74 @@ +// +// TodoGoalIDStorageTests.swift +// InfraTests +// +// Created by opfic on 8/28/26. +// + +import Foundation +import Testing +import FirebaseFirestore +import Data +@testable import Infra + +struct TodoGoalIDStorageTests { + @Test("목표 연결 해제 요청은 goalId 삭제 값을 만든다") + func 목표_연결_해제_요청은_goalId_삭제_값을_만든다() throws { + let data = try TodoServiceImpl.makeDocumentData(from: makeRequest(goalId: nil)) + + #expect(data["goalId"] is FieldValue) + } + + @Test("목표 연결 요청은 goalId 값을 저장한다") + func 목표_연결_요청은_goalId_값을_저장한다() throws { + let data = try TodoServiceImpl.makeDocumentData(from: makeRequest(goalId: "goal-1")) + + #expect(data["goalId"] as? String == "goal-1") + } + + @Test("goalId 누락과 null은 연결되지 않은 Todo로 읽는다") + func goalId_누락과_null은_연결되지_않은_Todo로_읽는다() throws { + let missing = try #require( + TodoServiceImpl.makeResponse(documentID: "todo-1", data: makeDocumentData(goalId: nil)) + ) + let null = try #require( + TodoServiceImpl.makeResponse(documentID: "todo-2", data: makeDocumentData(goalId: NSNull())) + ) + + #expect(missing.goalId == nil) + #expect(null.goalId == nil) + } + + private func makeRequest(goalId: String?) -> TodoRequest { + TodoRequest( + id: "todo-1", + isPinned: false, + isCompleted: false, + isChecked: false, + title: "Todo", + content: "내용", + createdAt: .distantPast, + updatedAt: .distantPast, + completedAt: nil, + deletedAt: nil, + dueDate: nil, + tags: [], + category: "feature", + goalId: goalId + ) + } + + private func makeDocumentData(goalId: Any?) -> [String: Any] { + var data: [String: Any] = [ + "number": 1, + "title": "Todo", + "createdAt": Timestamp(date: .distantPast), + "updatedAt": Timestamp(date: .distantPast), + "category": "feature" + ] + if let goalId { + data["goalId"] = goalId + } + return data + } +} From bc2a5a3a3a713c4dd0c72d9912c57682f5cfbd05 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 28 Aug 2026 00:52:54 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20=EA=B0=9C=EB=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20Data=20=EC=A0=80=EC=9E=A5=20=EA=B3=84=EC=95=BD=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 --- .../Sources/DTO/DevelopmentRecordDTO.swift | 140 +++++++++++++++ .../Mapper/DevelopmentRecordMapping.swift | 88 ++++++++++ .../Protocol/DevelopmentRecordService.swift | 35 ++++ .../DevelopmentRecordRepositoryImpl.swift | 118 +++++++++++++ .../DevelopmentRecordMappingTests.swift | 55 ++++++ ...DevelopmentRecordRepositoryImplTests.swift | 163 ++++++++++++++++++ 6 files changed, 599 insertions(+) create mode 100644 Application/Data/Sources/DTO/DevelopmentRecordDTO.swift create mode 100644 Application/Data/Sources/Mapper/DevelopmentRecordMapping.swift create mode 100644 Application/Data/Sources/Protocol/DevelopmentRecordService.swift create mode 100644 Application/Data/Sources/Repository/DevelopmentRecordRepositoryImpl.swift create mode 100644 Application/Data/Tests/Mapper/DevelopmentRecordMappingTests.swift create mode 100644 Application/Data/Tests/Repository/DevelopmentRecordRepositoryImplTests.swift diff --git a/Application/Data/Sources/DTO/DevelopmentRecordDTO.swift b/Application/Data/Sources/DTO/DevelopmentRecordDTO.swift new file mode 100644 index 00000000..c73ff511 --- /dev/null +++ b/Application/Data/Sources/DTO/DevelopmentRecordDTO.swift @@ -0,0 +1,140 @@ +// +// DevelopmentRecordDTO.swift +// Data +// +// Created by opfic on 8/28/26. +// + +import Foundation + +public struct DevelopmentRecordDraftRequest: Encodable { + public let title: String + public let markdownContent: String + public let baseVersionId: String? + + public init( + title: String, + markdownContent: String, + baseVersionId: String? + ) { + self.title = title + self.markdownContent = markdownContent + self.baseVersionId = baseVersionId + } +} + +public struct DevelopmentRecordCreateRequest: Encodable { + public let draft: DevelopmentRecordDraftRequest + + public init(draft: DevelopmentRecordDraftRequest) { + self.draft = draft + } +} + +public struct DevelopmentRecordConfirmationRequest { + public let versionId: String + public let kind: String + public let sourceVersionId: String? + + public init( + versionId: String, + kind: String, + sourceVersionId: String? + ) { + self.versionId = versionId + self.kind = kind + self.sourceVersionId = sourceVersionId + } +} + +public struct DevelopmentRecordRestoreRequest { + public let versionId: String + public let sourceVersionId: String + + public init(versionId: String, sourceVersionId: String) { + self.versionId = versionId + self.sourceVersionId = sourceVersionId + } +} + +public struct DevelopmentRecordDraftResponse { + 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 + ) { + self.title = title + self.markdownContent = markdownContent + self.baseVersionId = baseVersionId + self.updatedAt = updatedAt + } +} + +public struct DevelopmentRecordCurrentVersionResponse { + public let id: String + public let number: Int + + public init(id: String, number: Int) { + self.id = id + self.number = number + } +} + +public struct DevelopmentRecordResponse { + public let id: String + public let goalId: String + public let currentVersion: DevelopmentRecordCurrentVersionResponse? + public let draft: DevelopmentRecordDraftResponse? + public let createdAt: Date + + public init( + id: String, + goalId: String, + currentVersion: DevelopmentRecordCurrentVersionResponse?, + draft: DevelopmentRecordDraftResponse?, + createdAt: Date + ) { + self.id = id + self.goalId = goalId + self.currentVersion = currentVersion + self.draft = draft + self.createdAt = createdAt + } +} + +public struct DevelopmentRecordVersionResponse { + public let id: String + public let recordId: String + public let number: Int + public let title: String + public let markdownContent: String + public let kind: String + public let sourceVersionId: String? + public let confirmedAt: Date + + public init( + id: String, + recordId: String, + number: Int, + title: String, + markdownContent: String, + kind: String, + sourceVersionId: String?, + confirmedAt: Date + ) { + self.id = id + self.recordId = recordId + self.number = number + self.title = title + self.markdownContent = markdownContent + self.kind = kind + self.sourceVersionId = sourceVersionId + self.confirmedAt = confirmedAt + } +} diff --git a/Application/Data/Sources/Mapper/DevelopmentRecordMapping.swift b/Application/Data/Sources/Mapper/DevelopmentRecordMapping.swift new file mode 100644 index 00000000..be89745f --- /dev/null +++ b/Application/Data/Sources/Mapper/DevelopmentRecordMapping.swift @@ -0,0 +1,88 @@ +// +// DevelopmentRecordMapping.swift +// Data +// +// Created by opfic on 8/28/26. +// + +import Domain + +public extension DevelopmentRecordDraftRequest { + static func fromDomain(_ draft: DevelopmentRecord.Draft) -> Self { + Self( + title: draft.title, + markdownContent: draft.markdownContent, + baseVersionId: draft.baseVersionId + ) + } +} + +public extension DevelopmentRecordDraftResponse { + func toDomain() throws -> DevelopmentRecord.Draft { + try DevelopmentRecord.Draft( + title: title, + markdownContent: markdownContent, + baseVersionId: baseVersionId, + updatedAt: updatedAt + ) + } +} + +public extension DevelopmentRecordCurrentVersionResponse { + func toDomain() throws -> DevelopmentRecord.CurrentVersion { + try DevelopmentRecord.CurrentVersion(id: id, number: number) + } +} + +public extension DevelopmentRecordResponse { + func toDomain() throws -> DevelopmentRecord { + try DevelopmentRecord( + id: id, + goalId: goalId, + currentVersion: try currentVersion?.toDomain(), + draft: try draft?.toDomain(), + createdAt: createdAt + ) + } +} + +public extension DevelopmentRecordVersionResponse { + func toDomain() throws -> DevelopmentRecord.Version { + try DevelopmentRecord.Version( + id: id, + recordId: recordId, + number: number, + title: title, + markdownContent: markdownContent, + kind: try .fromStorageValue(kind), + sourceVersionId: sourceVersionId, + confirmedAt: confirmedAt + ) + } +} + +public extension DevelopmentRecord.Version.Kind { + var storageValue: String { + switch self { + case .initial: + "initial" + case .correction: + "correction" + case .rollback: + "rollback" + } + } + + static func fromStorageValue(_ value: String) throws -> Self { + switch value { + case "initial": + .initial + case "correction": + .correction + case "rollback": + .rollback + default: + throw DataLayerError.invalidData("DevelopmentRecordVersionResponse.kind: \(value)") + } + } +} diff --git a/Application/Data/Sources/Protocol/DevelopmentRecordService.swift b/Application/Data/Sources/Protocol/DevelopmentRecordService.swift new file mode 100644 index 00000000..0852fcf4 --- /dev/null +++ b/Application/Data/Sources/Protocol/DevelopmentRecordService.swift @@ -0,0 +1,35 @@ +// +// DevelopmentRecordService.swift +// Data +// +// Created by opfic on 8/28/26. +// + +public protocol DevelopmentRecordService { + func createRecord( + goalId: String, + recordId: String, + request: DevelopmentRecordCreateRequest + ) async throws -> DevelopmentRecordResponse + func fetchRecords(goalId: String) async throws -> [DevelopmentRecordResponse] + func fetchRecord(goalId: String, recordId: String) async throws -> DevelopmentRecordResponse + func fetchVersions( + goalId: String, + recordId: String + ) async throws -> [DevelopmentRecordVersionResponse] + func saveDraft( + goalId: String, + recordId: String, + request: DevelopmentRecordDraftRequest + ) async throws -> DevelopmentRecordResponse + func confirmDraft( + goalId: String, + recordId: String, + request: DevelopmentRecordConfirmationRequest + ) async throws -> DevelopmentRecordVersionResponse + func restoreVersion( + goalId: String, + recordId: String, + request: DevelopmentRecordRestoreRequest + ) async throws -> DevelopmentRecordVersionResponse +} diff --git a/Application/Data/Sources/Repository/DevelopmentRecordRepositoryImpl.swift b/Application/Data/Sources/Repository/DevelopmentRecordRepositoryImpl.swift new file mode 100644 index 00000000..60d12822 --- /dev/null +++ b/Application/Data/Sources/Repository/DevelopmentRecordRepositoryImpl.swift @@ -0,0 +1,118 @@ +// +// DevelopmentRecordRepositoryImpl.swift +// Data +// +// Created by opfic on 8/28/26. +// + +import Domain + +final class DevelopmentRecordRepositoryImpl: DevelopmentRecordRepository { + private let service: DevelopmentRecordService + + init(service: DevelopmentRecordService) { + self.service = service + } + + func createRecord( + id: String, + goalId: String, + draft: DevelopmentRecord.Draft + ) async throws -> DevelopmentRecord { + do { + let response = try await service.createRecord( + goalId: goalId, + recordId: id, + request: .init(draft: .fromDomain(draft)) + ) + return try response.toDomain() + } catch { + throw error.toDomain() + } + } + + func fetchRecords(goalId: String) async throws -> [DevelopmentRecord] { + do { + return try await service.fetchRecords(goalId: goalId).map { try $0.toDomain() } + } catch { + throw error.toDomain() + } + } + + func fetchRecord(goalId: String, recordId: String) async throws -> DevelopmentRecord { + do { + return try await service.fetchRecord(goalId: goalId, recordId: recordId).toDomain() + } catch { + throw error.toDomain() + } + } + + func fetchVersions( + goalId: String, + recordId: String + ) async throws -> [DevelopmentRecord.Version] { + do { + return try await service.fetchVersions(goalId: goalId, recordId: recordId).map { try $0.toDomain() } + } catch { + throw error.toDomain() + } + } + + func saveDraft( + goalId: String, + recordId: String, + draft: DevelopmentRecord.Draft + ) async throws -> DevelopmentRecord { + do { + let response = try await service.saveDraft( + goalId: goalId, + recordId: recordId, + request: .fromDomain(draft) + ) + return try response.toDomain() + } catch { + throw error.toDomain() + } + } + + func confirmDraft( + goalId: String, + recordId: String, + versionId: String, + kind: DevelopmentRecord.Version.Kind, + sourceVersionId: String? + ) async throws -> DevelopmentRecord.Version { + do { + let response = try await service.confirmDraft( + goalId: goalId, + recordId: recordId, + request: .init( + versionId: versionId, + kind: kind.storageValue, + sourceVersionId: sourceVersionId + ) + ) + return try response.toDomain() + } catch { + throw error.toDomain() + } + } + + func restoreVersion( + goalId: String, + recordId: String, + versionId: String, + sourceVersionId: String + ) async throws -> DevelopmentRecord.Version { + do { + let response = try await service.restoreVersion( + goalId: goalId, + recordId: recordId, + request: .init(versionId: versionId, sourceVersionId: sourceVersionId) + ) + return try response.toDomain() + } catch { + throw error.toDomain() + } + } +} diff --git a/Application/Data/Tests/Mapper/DevelopmentRecordMappingTests.swift b/Application/Data/Tests/Mapper/DevelopmentRecordMappingTests.swift new file mode 100644 index 00000000..404f5e57 --- /dev/null +++ b/Application/Data/Tests/Mapper/DevelopmentRecordMappingTests.swift @@ -0,0 +1,55 @@ +// +// DevelopmentRecordMappingTests.swift +// DataTests +// +// Created by opfic on 8/28/26. +// + +import Foundation +import Testing +import Domain +@testable import Data + +struct DevelopmentRecordMappingTests { + @Test("기록 초안은 저장 요청에서 갱신 시각을 제외한다") + func 기록_초안은_저장_요청에서_갱신_시각을_제외한다() throws { + let draft = try DevelopmentRecord.Draft( + title: "기록", + markdownContent: "본문", + baseVersionId: "version-1", + updatedAt: .distantPast + ) + + let request = DevelopmentRecordDraftRequest.fromDomain(draft) + + #expect(request.title == "기록") + #expect(request.markdownContent == "본문") + #expect(request.baseVersionId == "version-1") + } + + @Test("저장 버전 종류는 Domain 종류로 변환한다") + func 저장_버전_종류는_Domain_종류로_변환한다() throws { + let response = DevelopmentRecordVersionResponse( + id: "version-1", + recordId: "record-1", + number: 1, + title: "기록", + markdownContent: "본문", + kind: "initial", + sourceVersionId: nil, + confirmedAt: .distantPast + ) + + let version = try response.toDomain() + + #expect(version.kind == .initial) + #expect(version.sourceVersionId == nil) + } + + @Test("알 수 없는 저장 버전 종류는 유효하지 않은 데이터 오류를 만든다") + func 알_수_없는_저장_버전_종류는_유효하지_않은_데이터_오류를_만든다() { + #expect(throws: DataLayerError.self) { + _ = try DevelopmentRecord.Version.Kind.fromStorageValue("unknown") + } + } +} diff --git a/Application/Data/Tests/Repository/DevelopmentRecordRepositoryImplTests.swift b/Application/Data/Tests/Repository/DevelopmentRecordRepositoryImplTests.swift new file mode 100644 index 00000000..d3155b3d --- /dev/null +++ b/Application/Data/Tests/Repository/DevelopmentRecordRepositoryImplTests.swift @@ -0,0 +1,163 @@ +// +// DevelopmentRecordRepositoryImplTests.swift +// DataTests +// +// Created by opfic on 8/28/26. +// + +import Foundation +import Testing +import Domain +@testable import Data + +struct DevelopmentRecordRepositoryImplTests { + @Test("기록 생성은 Data 초안 요청을 서비스에 전달한다") + func 기록_생성은_Data_초안_요청을_서비스에_전달한다() async throws { + let service = DevelopmentRecordServiceSpy(response: makeRecordResponse()) + let repository = DevelopmentRecordRepositoryImpl(service: service) + let draft = try DevelopmentRecord.Draft( + title: "기록", + markdownContent: "본문", + baseVersionId: nil, + updatedAt: .distantPast + ) + + let record = try await repository.createRecord( + id: "record-1", + goalId: "goal-1", + draft: draft + ) + + let request = try #require(await service.createRequest()) + #expect(record.id == "record-1") + #expect(request.goalId == "goal-1") + #expect(request.recordId == "record-1") + #expect(request.draft.title == "기록") + } + + @Test("기록 확정은 버전 종류와 원본 버전을 서비스에 전달한다") + func 기록_확정은_버전_종류와_원본_버전을_서비스에_전달한다() async throws { + let service = DevelopmentRecordServiceSpy(version: makeVersionResponse()) + let repository = DevelopmentRecordRepositoryImpl(service: service) + + let version = try await repository.confirmDraft( + goalId: "goal-1", + recordId: "record-1", + versionId: "version-2", + kind: .correction, + sourceVersionId: "version-1" + ) + + let request = try #require(await service.confirmationRequest()) + #expect(version.kind == .correction) + #expect(request.versionId == "version-2") + #expect(request.kind == "correction") + #expect(request.sourceVersionId == "version-1") + } +} + +private actor DevelopmentRecordServiceSpy: DevelopmentRecordService { + struct CreateRequest { + let goalId: String + let recordId: String + let draft: DevelopmentRecordDraftRequest + } + + private let response: DevelopmentRecordResponse + private let version: DevelopmentRecordVersionResponse + private var recordedCreateRequest: CreateRequest? + private var recordedConfirmationRequest: DevelopmentRecordConfirmationRequest? + + init( + response: DevelopmentRecordResponse = makeRecordResponse(), + version: DevelopmentRecordVersionResponse = makeVersionResponse() + ) { + self.response = response + self.version = version + } + + func createRecord( + goalId: String, + recordId: String, + request: DevelopmentRecordCreateRequest + ) async throws -> DevelopmentRecordResponse { + recordedCreateRequest = .init(goalId: goalId, recordId: recordId, draft: request.draft) + return response + } + + func fetchRecords(goalId: String) async throws -> [DevelopmentRecordResponse] { + [response] + } + + func fetchRecord(goalId: String, recordId: String) async throws -> DevelopmentRecordResponse { + response + } + + func fetchVersions( + goalId: String, + recordId: String + ) async throws -> [DevelopmentRecordVersionResponse] { + [version] + } + + func saveDraft( + goalId: String, + recordId: String, + request: DevelopmentRecordDraftRequest + ) async throws -> DevelopmentRecordResponse { + response + } + + func confirmDraft( + goalId: String, + recordId: String, + request: DevelopmentRecordConfirmationRequest + ) async throws -> DevelopmentRecordVersionResponse { + recordedConfirmationRequest = request + return version + } + + func restoreVersion( + goalId: String, + recordId: String, + request: DevelopmentRecordRestoreRequest + ) async throws -> DevelopmentRecordVersionResponse { + version + } + + func createRequest() -> CreateRequest? { + recordedCreateRequest + } + + func confirmationRequest() -> DevelopmentRecordConfirmationRequest? { + recordedConfirmationRequest + } +} + +private func makeRecordResponse() -> DevelopmentRecordResponse { + DevelopmentRecordResponse( + id: "record-1", + goalId: "goal-1", + currentVersion: nil, + draft: .init( + title: "기록", + markdownContent: "본문", + baseVersionId: nil, + updatedAt: .distantPast + ), + createdAt: .distantPast + ) +} + +private func makeVersionResponse() -> DevelopmentRecordVersionResponse { + DevelopmentRecordVersionResponse( + id: "version-2", + recordId: "record-1", + number: 2, + title: "기록", + markdownContent: "본문", + kind: "correction", + sourceVersionId: "version-1", + confirmedAt: .distantPast + ) +} From fa5e13f74698ff709a3bc8ccb6f4e1d3e19728d8 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 28 Aug 2026 00:55:06 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20=EA=B0=9C=EB=B0=9C=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=20Data=20=EC=A0=80=EC=9E=A5=20=EA=B3=84=EC=95=BD=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 --- .../Data/Sources/DTO/DevelopmentGoalDTO.swift | 72 ++++++++++ .../Mapper/DevelopmentGoalMapping.swift | 63 ++++++++ .../Protocol/DevelopmentGoalService.swift | 22 +++ .../DevelopmentGoalRepositoryImpl.swift | 73 ++++++++++ .../Mapper/DevelopmentGoalMappingTests.swift | 49 +++++++ .../DevelopmentGoalRepositoryImplTests.swift | 135 ++++++++++++++++++ 6 files changed, 414 insertions(+) create mode 100644 Application/Data/Sources/DTO/DevelopmentGoalDTO.swift create mode 100644 Application/Data/Sources/Mapper/DevelopmentGoalMapping.swift create mode 100644 Application/Data/Sources/Protocol/DevelopmentGoalService.swift create mode 100644 Application/Data/Sources/Repository/DevelopmentGoalRepositoryImpl.swift create mode 100644 Application/Data/Tests/Mapper/DevelopmentGoalMappingTests.swift create mode 100644 Application/Data/Tests/Repository/DevelopmentGoalRepositoryImplTests.swift diff --git a/Application/Data/Sources/DTO/DevelopmentGoalDTO.swift b/Application/Data/Sources/DTO/DevelopmentGoalDTO.swift new file mode 100644 index 00000000..bee4c57b --- /dev/null +++ b/Application/Data/Sources/DTO/DevelopmentGoalDTO.swift @@ -0,0 +1,72 @@ +// +// DevelopmentGoalDTO.swift +// Data +// +// Created by opfic on 8/28/26. +// + +import Foundation + +public struct DevelopmentGoalCreateRequest: Encodable { + public let title: String + public let markdownDescription: String + + public init(title: String, markdownDescription: String) { + self.title = title + self.markdownDescription = markdownDescription + } +} + +public struct DevelopmentGoalQuery { + public let status: String? + + public init(status: String?) { + self.status = status + } +} + +public struct DevelopmentGoalStatusRequest { + public let status: String + + public init(status: String) { + self.status = status + } +} + +public struct DevelopmentGoalResponse { + public let id: String + public let title: String + public let markdownDescription: String + public let status: String + public let createdAt: Date + public let updatedAt: Date + public let completedAt: Date? + + public init( + id: String, + title: String, + markdownDescription: String, + status: String, + createdAt: Date, + updatedAt: Date, + completedAt: Date? + ) { + self.id = id + self.title = title + self.markdownDescription = markdownDescription + self.status = status + self.createdAt = createdAt + self.updatedAt = updatedAt + self.completedAt = completedAt + } +} + +public struct DevelopmentGoalCompletionResponse { + public let goal: DevelopmentGoalResponse + public let records: [DevelopmentRecordResponse] + + public init(goal: DevelopmentGoalResponse, records: [DevelopmentRecordResponse]) { + self.goal = goal + self.records = records + } +} diff --git a/Application/Data/Sources/Mapper/DevelopmentGoalMapping.swift b/Application/Data/Sources/Mapper/DevelopmentGoalMapping.swift new file mode 100644 index 00000000..0175df75 --- /dev/null +++ b/Application/Data/Sources/Mapper/DevelopmentGoalMapping.swift @@ -0,0 +1,63 @@ +// +// DevelopmentGoalMapping.swift +// Data +// +// Created by opfic on 8/28/26. +// + +import Domain + +public extension DevelopmentGoalQuery { + static func fromDomain(_ query: DevelopmentGoal.Query) -> Self { + Self(status: query.status?.storageValue) + } +} + +public extension DevelopmentGoalResponse { + func toDomain() throws -> DevelopmentGoal { + try DevelopmentGoal( + id: id, + title: title, + description: markdownDescription, + status: try .fromStorageValue(status), + createdAt: createdAt, + updatedAt: updatedAt, + completedAt: completedAt + ) + } +} + +public extension DevelopmentGoalCompletionResponse { + func toDomain() throws -> DevelopmentGoal.CompletionSnapshot { + try DevelopmentGoal.CompletionSnapshot( + goal: goal.toDomain(), + records: records.map { try $0.toDomain() } + ) + } +} + +public extension DevelopmentGoal.Status { + var storageValue: String { + switch self { + case .inProgress: + "inProgress" + case .completed: + "completed" + case .archived: + "archived" + } + } + + static func fromStorageValue(_ value: String) throws -> Self { + switch value { + case "inProgress": + .inProgress + case "completed": + .completed + case "archived": + .archived + default: + throw DataLayerError.invalidData("DevelopmentGoalResponse.status: \(value)") + } + } +} diff --git a/Application/Data/Sources/Protocol/DevelopmentGoalService.swift b/Application/Data/Sources/Protocol/DevelopmentGoalService.swift new file mode 100644 index 00000000..5669b338 --- /dev/null +++ b/Application/Data/Sources/Protocol/DevelopmentGoalService.swift @@ -0,0 +1,22 @@ +// +// DevelopmentGoalService.swift +// Data +// +// Created by opfic on 8/28/26. +// + +public protocol DevelopmentGoalService { + func createGoal( + goalId: String, + request: DevelopmentGoalCreateRequest + ) async throws -> DevelopmentGoalResponse + func fetchGoal(goalId: String) async throws -> DevelopmentGoalResponse + func fetchGoals(_ query: DevelopmentGoalQuery) async throws -> [DevelopmentGoalResponse] + func fetchCompletionSnapshot( + goalId: String + ) async throws -> DevelopmentGoalCompletionResponse + func transitionGoalStatus( + goalId: String, + request: DevelopmentGoalStatusRequest + ) async throws +} diff --git a/Application/Data/Sources/Repository/DevelopmentGoalRepositoryImpl.swift b/Application/Data/Sources/Repository/DevelopmentGoalRepositoryImpl.swift new file mode 100644 index 00000000..847ec823 --- /dev/null +++ b/Application/Data/Sources/Repository/DevelopmentGoalRepositoryImpl.swift @@ -0,0 +1,73 @@ +// +// DevelopmentGoalRepositoryImpl.swift +// Data +// +// Created by opfic on 8/28/26. +// + +import Domain + +final class DevelopmentGoalRepositoryImpl: DevelopmentGoalRepository { + private let service: DevelopmentGoalService + + init(service: DevelopmentGoalService) { + self.service = service + } + + func createGoal( + id: String, + title: String, + description: String + ) async throws -> DevelopmentGoal { + do { + let response = try await service.createGoal( + goalId: id, + request: .init(title: title, markdownDescription: description) + ) + return try response.toDomain() + } catch { + throw error.toDomain() + } + } + + func fetchGoal(_ goalId: String) async throws -> DevelopmentGoal { + do { + return try await service.fetchGoal(goalId: goalId).toDomain() + } catch { + throw error.toDomain() + } + } + + func fetchGoals(_ query: DevelopmentGoal.Query) async throws -> [DevelopmentGoal] { + do { + return try await service.fetchGoals(.fromDomain(query)).map { try $0.toDomain() } + } catch { + throw error.toDomain() + } + } + + func fetchCompletionSnapshot( + for goalId: String + ) async throws -> DevelopmentGoal.CompletionSnapshot { + do { + return try await service.fetchCompletionSnapshot(goalId: goalId).toDomain() + } catch { + throw error.toDomain() + } + } + + func transitionGoalStatus( + _ goalId: String, + to status: DevelopmentGoal.Status, + completionSnapshot: DevelopmentGoal.CompletionSnapshot? + ) async throws { + do { + try await service.transitionGoalStatus( + goalId: goalId, + request: .init(status: status.storageValue) + ) + } catch { + throw error.toDomain() + } + } +} diff --git a/Application/Data/Tests/Mapper/DevelopmentGoalMappingTests.swift b/Application/Data/Tests/Mapper/DevelopmentGoalMappingTests.swift new file mode 100644 index 00000000..a63a1f1e --- /dev/null +++ b/Application/Data/Tests/Mapper/DevelopmentGoalMappingTests.swift @@ -0,0 +1,49 @@ +// +// DevelopmentGoalMappingTests.swift +// DataTests +// +// Created by opfic on 8/28/26. +// + +import Foundation +import Testing +import Domain +@testable import Data + +struct DevelopmentGoalMappingTests { + @Test("저장 목표 응답은 markdownDescription을 Domain 설명으로 변환한다") + func 저장_목표_응답은_markdownDescription을_Domain_설명으로_변환한다() throws { + let response = makeGoalResponse() + + let goal = try response.toDomain() + + #expect(goal.description == "설명") + #expect(goal.status == .inProgress) + } + + @Test("상태 조건은 저장 문자열로 변환한다") + func 상태_조건은_저장_문자열로_변환한다() { + let query = DevelopmentGoalQuery.fromDomain(.init(status: .completed)) + + #expect(query.status == "completed") + } + + @Test("알 수 없는 저장 상태는 유효하지 않은 데이터 오류를 만든다") + func 알_수_없는_저장_상태는_유효하지_않은_데이터_오류를_만든다() { + #expect(throws: DataLayerError.self) { + _ = try DevelopmentGoal.Status.fromStorageValue("unknown") + } + } +} + +private func makeGoalResponse() -> DevelopmentGoalResponse { + DevelopmentGoalResponse( + id: "goal-1", + title: "목표", + markdownDescription: "설명", + status: "inProgress", + createdAt: .distantPast, + updatedAt: .distantPast, + completedAt: nil + ) +} diff --git a/Application/Data/Tests/Repository/DevelopmentGoalRepositoryImplTests.swift b/Application/Data/Tests/Repository/DevelopmentGoalRepositoryImplTests.swift new file mode 100644 index 00000000..ca69e37f --- /dev/null +++ b/Application/Data/Tests/Repository/DevelopmentGoalRepositoryImplTests.swift @@ -0,0 +1,135 @@ +// +// DevelopmentGoalRepositoryImplTests.swift +// DataTests +// +// Created by opfic on 8/28/26. +// + +import Foundation +import Testing +import Domain +@testable import Data + +struct DevelopmentGoalRepositoryImplTests { + @Test("목표 생성은 markdownDescription 요청을 서비스에 전달한다") + func 목표_생성은_markdownDescription_요청을_서비스에_전달한다() async throws { + let service = DevelopmentGoalServiceSpy() + let repository = DevelopmentGoalRepositoryImpl(service: service) + + let goal = try await repository.createGoal( + id: "goal-1", + title: "목표", + description: "설명" + ) + + let request = try #require(await service.createRequest()) + #expect(goal.id == "goal-1") + #expect(request.goalId == "goal-1") + #expect(request.markdownDescription == "설명") + } + + @Test("완료 검증 문맥은 목표 서비스 응답만으로 복원한다") + func 완료_검증_문맥은_목표_서비스_응답만으로_복원한다() async throws { + let service = DevelopmentGoalServiceSpy() + let repository = DevelopmentGoalRepositoryImpl(service: service) + + let snapshot = try await repository.fetchCompletionSnapshot(for: "goal-1") + + #expect(snapshot.goal.id == "goal-1") + #expect(snapshot.records.map(\.id) == ["record-1"]) + #expect(await service.completionSnapshotGoalIds() == ["goal-1"]) + } + + @Test("목표 상태 전환은 저장 상태 문자열을 서비스에 전달한다") + func 목표_상태_전환은_저장_상태_문자열을_서비스에_전달한다() async throws { + let service = DevelopmentGoalServiceSpy() + let repository = DevelopmentGoalRepositoryImpl(service: service) + + try await repository.transitionGoalStatus( + "goal-1", + to: .archived, + completionSnapshot: nil + ) + + let request = try #require(await service.transitionRequest()) + #expect(request.goalId == "goal-1") + #expect(request.status == "archived") + } +} + +private actor DevelopmentGoalServiceSpy: DevelopmentGoalService { + struct CreateRequest { + let goalId: String + let markdownDescription: String + } + + struct TransitionRequest { + let goalId: String + let status: String + } + + private let goal = DevelopmentGoalResponse( + id: "goal-1", + title: "목표", + markdownDescription: "설명", + status: "inProgress", + createdAt: .distantPast, + updatedAt: .distantPast, + completedAt: nil + ) + private let record = DevelopmentRecordResponse( + id: "record-1", + goalId: "goal-1", + currentVersion: .init(id: "version-1", number: 1), + draft: nil, + createdAt: .distantPast + ) + private var recordedCreateRequest: CreateRequest? + private var recordedCompletionSnapshotGoalIds = [String]() + private var recordedTransitionRequest: TransitionRequest? + + func createGoal( + goalId: String, + request: DevelopmentGoalCreateRequest + ) async throws -> DevelopmentGoalResponse { + recordedCreateRequest = .init( + goalId: goalId, + markdownDescription: request.markdownDescription + ) + return goal + } + + func fetchGoal(goalId: String) async throws -> DevelopmentGoalResponse { + goal + } + + func fetchGoals(_ query: DevelopmentGoalQuery) async throws -> [DevelopmentGoalResponse] { + [goal] + } + + func fetchCompletionSnapshot( + goalId: String + ) async throws -> DevelopmentGoalCompletionResponse { + recordedCompletionSnapshotGoalIds.append(goalId) + return .init(goal: goal, records: [record]) + } + + func transitionGoalStatus( + goalId: String, + request: DevelopmentGoalStatusRequest + ) async throws { + recordedTransitionRequest = .init(goalId: goalId, status: request.status) + } + + func createRequest() -> CreateRequest? { + recordedCreateRequest + } + + func completionSnapshotGoalIds() -> [String] { + recordedCompletionSnapshotGoalIds + } + + func transitionRequest() -> TransitionRequest? { + recordedTransitionRequest + } +} From 9a7323d5bb7ba5fa0173ac0b7e9b91f303459c3c Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 28 Aug 2026 00:58:18 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20=EA=B0=9C=EB=B0=9C=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=20Firestore=20=EC=84=9C=EB=B9=84=EC=8A=A4=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 --- .../Infra/Sources/Common/FirestorePath.swift | 22 ++ .../DevelopmentRecordDocumentMapper.swift | 73 ++++++ .../Service/DevelopmentGoalServiceImpl.swift | 213 ++++++++++++++++++ .../FirestorePathDevelopmentGoalTests.swift | 24 ++ .../DevelopmentGoalServiceImplTests.swift | 56 +++++ 5 files changed, 388 insertions(+) create mode 100644 Application/Infra/Sources/Mapper/DevelopmentRecordDocumentMapper.swift create mode 100644 Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift create mode 100644 Application/Infra/Tests/Common/FirestorePathDevelopmentGoalTests.swift create mode 100644 Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift diff --git a/Application/Infra/Sources/Common/FirestorePath.swift b/Application/Infra/Sources/Common/FirestorePath.swift index 850f3e10..06dea129 100644 --- a/Application/Infra/Sources/Common/FirestorePath.swift +++ b/Application/Infra/Sources/Common/FirestorePath.swift @@ -11,6 +11,8 @@ enum FirestorePath { case userData case counters case todoLists + case developmentGoals + case records case notifications case webPages } @@ -48,6 +50,26 @@ enum FirestorePath { "\(todos(uid))/\(todoId)" } + static func developmentGoals(_ uid: String) -> String { + "\(user(uid))/\(Collection.developmentGoals.rawValue)" + } + + static func developmentGoal(_ uid: String, goalId: String) -> String { + "\(developmentGoals(uid))/\(goalId)" + } + + static func developmentRecords(_ uid: String, goalId: String) -> String { + "\(developmentGoal(uid, goalId: goalId))/\(Collection.records.rawValue)" + } + + static func developmentRecord( + _ uid: String, + goalId: String, + recordId: String + ) -> String { + "\(developmentRecords(uid, goalId: goalId))/\(recordId)" + } + static func notifications(_ uid: String) -> String { "\(user(uid))/\(Collection.notifications.rawValue)" } diff --git a/Application/Infra/Sources/Mapper/DevelopmentRecordDocumentMapper.swift b/Application/Infra/Sources/Mapper/DevelopmentRecordDocumentMapper.swift new file mode 100644 index 00000000..3b679a9c --- /dev/null +++ b/Application/Infra/Sources/Mapper/DevelopmentRecordDocumentMapper.swift @@ -0,0 +1,73 @@ +// +// DevelopmentRecordDocumentMapper.swift +// Infra +// +// Created by opfic on 8/28/26. +// + +import FirebaseFirestore +import Data + +struct DevelopmentRecordDocumentMapper { + func map( + goalId: String, + documentId: String, + data: [String: Any] + ) -> DevelopmentRecordResponse? { + guard let createdAt = data[DevelopmentRecordFieldKey.createdAt.rawValue] as? Timestamp else { + return nil + } + + let currentVersion: DevelopmentRecordCurrentVersionResponse? + let currentVersionId = data[DevelopmentRecordFieldKey.currentVersionId.rawValue] as? String + let currentVersionNumber = data[DevelopmentRecordFieldKey.currentVersionNumber.rawValue] as? Int + switch (currentVersionId, currentVersionNumber) { + case let (.some(id), .some(number)): + currentVersion = .init(id: id, number: number) + case (nil, nil): + currentVersion = nil + default: + return nil + } + + let draft: DevelopmentRecordDraftResponse? + if let data = data[DevelopmentRecordFieldKey.draft.rawValue] as? [String: Any] { + guard + let title = data[DevelopmentRecordDraftFieldKey.title.rawValue] as? String, + let markdownContent = data[DevelopmentRecordDraftFieldKey.markdownContent.rawValue] as? String, + let updatedAt = data[DevelopmentRecordDraftFieldKey.updatedAt.rawValue] as? Timestamp else { + return nil + } + draft = .init( + title: title, + markdownContent: markdownContent, + baseVersionId: data[DevelopmentRecordDraftFieldKey.baseVersionId.rawValue] as? String, + updatedAt: updatedAt.dateValue() + ) + } else { + draft = nil + } + + return DevelopmentRecordResponse( + id: documentId, + goalId: goalId, + currentVersion: currentVersion, + draft: draft, + createdAt: createdAt.dateValue() + ) + } +} + +enum DevelopmentRecordFieldKey: String { + case currentVersionId + case currentVersionNumber + case draft + case createdAt +} + +enum DevelopmentRecordDraftFieldKey: String { + case title + case markdownContent + case baseVersionId + case updatedAt +} diff --git a/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift b/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift new file mode 100644 index 00000000..1681e1d9 --- /dev/null +++ b/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift @@ -0,0 +1,213 @@ +// +// DevelopmentGoalServiceImpl.swift +// Infra +// +// Created by opfic on 8/28/26. +// + +import FirebaseAuth +import FirebaseFirestore +import Core +import Data + +final class DevelopmentGoalServiceImpl: DevelopmentGoalService { + private enum CrashlyticsError { + static let domain = "DevLogInfra.DevelopmentGoalServiceImpl" + + enum Code: Int { + case createGoal = 1 + case fetchGoal + case fetchGoals + case fetchCompletionSnapshot + case transitionGoalStatus + } + } + + private let store = FirebaseConfiguration.firestore + private let encoder = Firestore.Encoder() + private let logger = Logger(category: "DevelopmentGoalServiceImpl") + private let recordMapper = DevelopmentRecordDocumentMapper() + + func createGoal( + goalId: String, + request: DevelopmentGoalCreateRequest + ) async throws -> DevelopmentGoalResponse { + guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } + + do { + let reference = store.document(FirestorePath.developmentGoal(uid, goalId: goalId)) + var data = try encoder.encode(request) + data[DevelopmentGoalFieldKey.status.rawValue] = "inProgress" + data[DevelopmentGoalFieldKey.createdAt.rawValue] = FieldValue.serverTimestamp() + data[DevelopmentGoalFieldKey.updatedAt.rawValue] = FieldValue.serverTimestamp() + try await reference.setData(data) + return try await fetchGoal(uid: uid, goalId: goalId) + } catch { + logger.error("Failed to create development goal", error: error) + record(error, code: .createGoal) + throw error + } + } + + func fetchGoal(goalId: String) async throws -> DevelopmentGoalResponse { + guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } + + do { + return try await fetchGoal(uid: uid, goalId: goalId) + } catch { + logger.error("Failed to fetch development goal", error: error) + record(error, code: .fetchGoal) + throw error + } + } + + func fetchGoals(_ query: DevelopmentGoalQuery) async throws -> [DevelopmentGoalResponse] { + guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } + + do { + var reference: Query = store.collection(FirestorePath.developmentGoals(uid)) + if let status = query.status { + reference = reference.whereField( + DevelopmentGoalFieldKey.status.rawValue, + isEqualTo: status + ) + } + let snapshot = try await reference + .order(by: DevelopmentGoalFieldKey.createdAt.rawValue) + .order(by: FieldPath.documentID()) + .getDocuments() + return try snapshot.documents.map { document in + guard let response = Self.makeResponse( + documentId: document.documentID, + data: document.data() + ) else { + throw DataLayerError.invalidData("developmentGoal") + } + return response + } + } catch { + logger.error("Failed to fetch development goals", error: error) + record(error, code: .fetchGoals) + throw error + } + } + + func fetchCompletionSnapshot( + goalId: String + ) async throws -> DevelopmentGoalCompletionResponse { + guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } + + do { + let goal = try await fetchGoal(uid: uid, goalId: goalId) + let snapshot = try await store.collection( + FirestorePath.developmentRecords(uid, goalId: goalId) + ) + .order(by: DevelopmentRecordFieldKey.createdAt.rawValue) + .getDocuments() + let records = try snapshot.documents.map { document in + guard let response = recordMapper.map( + goalId: goalId, + documentId: document.documentID, + data: document.data() + ) else { + throw DataLayerError.invalidData("developmentRecord") + } + return response + } + return .init(goal: goal, records: records) + } catch { + logger.error("Failed to fetch development goal completion snapshot", error: error) + record(error, code: .fetchCompletionSnapshot) + throw error + } + } + + func transitionGoalStatus( + goalId: String, + request: DevelopmentGoalStatusRequest + ) async throws { + guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } + + do { + var data: [String: Any] = [ + DevelopmentGoalFieldKey.status.rawValue: request.status, + DevelopmentGoalFieldKey.updatedAt.rawValue: FieldValue.serverTimestamp() + ] + if request.status == "completed" { + data[DevelopmentGoalFieldKey.completedAt.rawValue] = FieldValue.serverTimestamp() + } else { + data[DevelopmentGoalFieldKey.completedAt.rawValue] = FieldValue.delete() + } + try await store.document( + FirestorePath.developmentGoal(uid, goalId: goalId) + ) + .updateData(data) + } catch { + logger.error("Failed to transition development goal status", error: error) + record(error, code: .transitionGoalStatus) + throw error + } + } +} + +extension DevelopmentGoalServiceImpl { + static func makeResponse( + documentId: String, + data: [String: Any] + ) -> DevelopmentGoalResponse? { + guard + let title = data[DevelopmentGoalFieldKey.title.rawValue] as? String, + let markdownDescription = data[DevelopmentGoalFieldKey.markdownDescription.rawValue] as? String, + let status = data[DevelopmentGoalFieldKey.status.rawValue] as? String, + let createdAt = data[DevelopmentGoalFieldKey.createdAt.rawValue] as? Timestamp, + let updatedAt = data[DevelopmentGoalFieldKey.updatedAt.rawValue] as? Timestamp else { + return nil + } + return DevelopmentGoalResponse( + id: documentId, + title: title, + markdownDescription: markdownDescription, + status: status, + createdAt: createdAt.dateValue(), + updatedAt: updatedAt.dateValue(), + completedAt: (data[DevelopmentGoalFieldKey.completedAt.rawValue] as? Timestamp)?.dateValue() + ) + } +} + +private extension DevelopmentGoalServiceImpl { + static func record(_ error: Error, code: CrashlyticsError.Code) { + FirebaseCrashlyticsHelper.record( + error, + domain: "\(CrashlyticsError.domain).\(code)", + code: code.rawValue + ) + } + + func record(_ error: Error, code: CrashlyticsError.Code) { + Self.record(error, code: code) + } + + func fetchGoal(uid: String, goalId: String) async throws -> DevelopmentGoalResponse { + let snapshot = try await store.document( + FirestorePath.developmentGoal(uid, goalId: goalId) + ) + .getDocument() + guard let data = snapshot.data(), let response = Self.makeResponse( + documentId: snapshot.documentID, + data: data + ) else { + throw FirestoreError.dataNotFound("developmentGoal") + } + return response + } +} + +private enum DevelopmentGoalFieldKey: String { + case title + case markdownDescription + case status + case createdAt + case updatedAt + case completedAt +} diff --git a/Application/Infra/Tests/Common/FirestorePathDevelopmentGoalTests.swift b/Application/Infra/Tests/Common/FirestorePathDevelopmentGoalTests.swift new file mode 100644 index 00000000..2af7e4d8 --- /dev/null +++ b/Application/Infra/Tests/Common/FirestorePathDevelopmentGoalTests.swift @@ -0,0 +1,24 @@ +// +// FirestorePathDevelopmentGoalTests.swift +// InfraTests +// +// Created by opfic on 8/28/26. +// + +import Testing +@testable import Infra + +struct FirestorePathDevelopmentGoalTests { + @Test("개발 목표와 기록 경로는 사용자 경로 아래에 중첩한다") + func 개발_목표와_기록_경로는_사용자_경로_아래에_중첩한다() { + let goalPath = FirestorePath.developmentGoal("user-1", goalId: "goal-1") + let recordPath = FirestorePath.developmentRecord( + "user-1", + goalId: "goal-1", + recordId: "record-1" + ) + + #expect(goalPath == "users/user-1/developmentGoals/goal-1") + #expect(recordPath == "users/user-1/developmentGoals/goal-1/records/record-1") + } +} diff --git a/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift b/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift new file mode 100644 index 00000000..f847bb62 --- /dev/null +++ b/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift @@ -0,0 +1,56 @@ +// +// DevelopmentGoalServiceImplTests.swift +// InfraTests +// +// Created by opfic on 8/28/26. +// + +import Foundation +import Testing +import FirebaseFirestore +@testable import Infra + +struct DevelopmentGoalServiceImplTests { + @Test("개발 목표 문서는 Data 응답으로 변환한다") + func 개발_목표_문서는_Data_응답으로_변환한다() throws { + let response = try #require( + DevelopmentGoalServiceImpl.makeResponse(documentId: "goal-1", data: makeData()) + ) + + #expect(response.id == "goal-1") + #expect(response.markdownDescription == "설명") + #expect(response.status == "inProgress") + } + + @Test("개발 기록 문서는 경로 식별값을 응답에 복원한다") + func 개발_기록_문서는_경로_식별값을_응답에_복원한다() throws { + let response = try #require( + DevelopmentRecordDocumentMapper().map( + goalId: "goal-1", + documentId: "record-1", + data: [ + DevelopmentRecordFieldKey.createdAt.rawValue: Timestamp(date: .distantPast), + DevelopmentRecordFieldKey.draft.rawValue: [ + DevelopmentRecordDraftFieldKey.title.rawValue: "기록", + DevelopmentRecordDraftFieldKey.markdownContent.rawValue: "본문", + DevelopmentRecordDraftFieldKey.updatedAt.rawValue: Timestamp(date: .distantPast) + ] + ] + ) + ) + + #expect(response.goalId == "goal-1") + #expect(response.id == "record-1") + #expect(response.draft?.baseVersionId == nil) + } + + private func makeData() -> [String: Any] { + [ + "title": "목표", + "markdownDescription": "설명", + "status": "inProgress", + "createdAt": Timestamp(date: .distantPast), + "updatedAt": Timestamp(date: .distantPast) + ] + } +} From 283112cc068c5aad287323491dc9cc754719e50a Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 28 Aug 2026 01:02:59 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20=EA=B0=9C=EB=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20Firestore=20=EC=84=9C=EB=B9=84=EC=8A=A4=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 --- .../Infra/Sources/Common/FirestorePath.swift | 18 + .../DevelopmentRecordDocumentMapper.swift | 34 ++ .../DevelopmentRecordServiceImpl.swift | 369 ++++++++++++++++++ .../DevelopmentRecordVersionMutation.swift | 109 ++++++ .../DevelopmentRecordServiceImplTests.swift | 118 ++++++ 5 files changed, 648 insertions(+) create mode 100644 Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift create mode 100644 Application/Infra/Sources/Service/DevelopmentRecordVersionMutation.swift create mode 100644 Application/Infra/Tests/Service/DevelopmentRecordServiceImplTests.swift diff --git a/Application/Infra/Sources/Common/FirestorePath.swift b/Application/Infra/Sources/Common/FirestorePath.swift index 06dea129..d39a458a 100644 --- a/Application/Infra/Sources/Common/FirestorePath.swift +++ b/Application/Infra/Sources/Common/FirestorePath.swift @@ -13,6 +13,7 @@ enum FirestorePath { case todoLists case developmentGoals case records + case versions case notifications case webPages } @@ -70,6 +71,23 @@ enum FirestorePath { "\(developmentRecords(uid, goalId: goalId))/\(recordId)" } + static func developmentRecordVersions( + _ uid: String, + goalId: String, + recordId: String + ) -> String { + "\(developmentRecord(uid, goalId: goalId, recordId: recordId))/\(Collection.versions.rawValue)" + } + + static func developmentRecordVersion( + _ uid: String, + goalId: String, + recordId: String, + versionId: String + ) -> String { + "\(developmentRecordVersions(uid, goalId: goalId, recordId: recordId))/\(versionId)" + } + static func notifications(_ uid: String) -> String { "\(user(uid))/\(Collection.notifications.rawValue)" } diff --git a/Application/Infra/Sources/Mapper/DevelopmentRecordDocumentMapper.swift b/Application/Infra/Sources/Mapper/DevelopmentRecordDocumentMapper.swift index 3b679a9c..322a9a0e 100644 --- a/Application/Infra/Sources/Mapper/DevelopmentRecordDocumentMapper.swift +++ b/Application/Infra/Sources/Mapper/DevelopmentRecordDocumentMapper.swift @@ -56,6 +56,31 @@ struct DevelopmentRecordDocumentMapper { createdAt: createdAt.dateValue() ) } + + func mapVersion( + recordId: String, + documentId: String, + data: [String: Any] + ) -> DevelopmentRecordVersionResponse? { + guard + let number = data[DevelopmentRecordVersionFieldKey.versionNumber.rawValue] as? Int, + let title = data[DevelopmentRecordVersionFieldKey.title.rawValue] as? String, + let markdownContent = data[DevelopmentRecordVersionFieldKey.markdownContent.rawValue] as? String, + let kind = data[DevelopmentRecordVersionFieldKey.changeKind.rawValue] as? String, + let confirmedAt = data[DevelopmentRecordVersionFieldKey.confirmedAt.rawValue] as? Timestamp else { + return nil + } + return DevelopmentRecordVersionResponse( + id: documentId, + recordId: recordId, + number: number, + title: title, + markdownContent: markdownContent, + kind: kind, + sourceVersionId: data[DevelopmentRecordVersionFieldKey.sourceVersionId.rawValue] as? String, + confirmedAt: confirmedAt.dateValue() + ) + } } enum DevelopmentRecordFieldKey: String { @@ -71,3 +96,12 @@ enum DevelopmentRecordDraftFieldKey: String { case baseVersionId case updatedAt } + +enum DevelopmentRecordVersionFieldKey: String { + case versionNumber + case title + case markdownContent + case changeKind + case sourceVersionId + case confirmedAt +} diff --git a/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift b/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift new file mode 100644 index 00000000..41295668 --- /dev/null +++ b/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift @@ -0,0 +1,369 @@ +// +// DevelopmentRecordServiceImpl.swift +// Infra +// +// Created by opfic on 8/28/26. +// + +import FirebaseAuth +import FirebaseFirestore +import Core +import Data + +final class DevelopmentRecordServiceImpl: DevelopmentRecordService { + private enum CrashlyticsError { + static let domain = "DevLogInfra.DevelopmentRecordServiceImpl" + + enum Code: Int { + case createRecord = 1 + case fetchRecords + case fetchRecord + case fetchVersions + case saveDraft + case confirmDraft + case restoreVersion + } + } + + private let store = FirebaseConfiguration.firestore + private let logger = Logger(category: "DevelopmentRecordServiceImpl") + private let mapper = DevelopmentRecordDocumentMapper() + + func createRecord( + goalId: String, + recordId: String, + request: DevelopmentRecordCreateRequest + ) async throws -> DevelopmentRecordResponse { + guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } + + do { + try await store.document( + FirestorePath.developmentRecord(uid, goalId: goalId, recordId: recordId) + ) + .setData([ + DevelopmentRecordFieldKey.draft.rawValue: Self.makeDraftData(request.draft), + DevelopmentRecordFieldKey.createdAt.rawValue: FieldValue.serverTimestamp() + ]) + return try await fetchRecord(uid: uid, goalId: goalId, recordId: recordId) + } catch { + logger.error("Failed to create development record", error: error) + record(error, code: .createRecord) + throw error + } + } + + func fetchRecords(goalId: String) async throws -> [DevelopmentRecordResponse] { + guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } + + do { + let snapshot = try await store.collection( + FirestorePath.developmentRecords(uid, goalId: goalId) + ) + .order(by: DevelopmentRecordFieldKey.createdAt.rawValue) + .order(by: FieldPath.documentID()) + .getDocuments() + return try snapshot.documents.map { document in + guard let response = mapper.map( + goalId: goalId, + documentId: document.documentID, + data: document.data() + ) else { + throw DataLayerError.invalidData("developmentRecord") + } + return response + } + } catch { + logger.error("Failed to fetch development records", error: error) + record(error, code: .fetchRecords) + throw error + } + } + + func fetchRecord( + goalId: String, + recordId: String + ) async throws -> DevelopmentRecordResponse { + guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } + + do { + return try await fetchRecord(uid: uid, goalId: goalId, recordId: recordId) + } catch { + logger.error("Failed to fetch development record", error: error) + record(error, code: .fetchRecord) + throw error + } + } + + func fetchVersions( + goalId: String, + recordId: String + ) async throws -> [DevelopmentRecordVersionResponse] { + guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } + + do { + let snapshot = try await store.collection( + FirestorePath.developmentRecordVersions( + uid, + goalId: goalId, + recordId: recordId + ) + ) + .order(by: DevelopmentRecordVersionFieldKey.versionNumber.rawValue) + .getDocuments() + return try snapshot.documents.map { document in + guard let response = mapper.mapVersion( + recordId: recordId, + documentId: document.documentID, + data: document.data() + ) else { + throw DataLayerError.invalidData("developmentRecordVersion") + } + return response + } + } catch { + logger.error("Failed to fetch development record versions", error: error) + record(error, code: .fetchVersions) + throw error + } + } + + func saveDraft( + goalId: String, + recordId: String, + request: DevelopmentRecordDraftRequest + ) async throws -> DevelopmentRecordResponse { + guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } + + do { + try await store.document( + FirestorePath.developmentRecord(uid, goalId: goalId, recordId: recordId) + ) + .updateData([ + DevelopmentRecordFieldKey.draft.rawValue: Self.makeDraftData(request) + ]) + return try await fetchRecord(uid: uid, goalId: goalId, recordId: recordId) + } catch { + logger.error("Failed to save development record draft", error: error) + record(error, code: .saveDraft) + throw error + } + } + + func confirmDraft( + goalId: String, + recordId: String, + request: DevelopmentRecordConfirmationRequest + ) async throws -> DevelopmentRecordVersionResponse { + guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } + + do { + let recordReference = store.document( + FirestorePath.developmentRecord(uid, goalId: goalId, recordId: recordId) + ) + let versionReference = store.document( + FirestorePath.developmentRecordVersion( + uid, + goalId: goalId, + recordId: recordId, + versionId: request.versionId + ) + ) + _ = try await store.runTransaction { transaction, errorPointer in + do { + let recordSnapshot = try transaction.getDocument(recordReference) + let versionSnapshot = try transaction.getDocument(versionReference) + guard + !versionSnapshot.exists, + let recordData = recordSnapshot.data(), + let mutation = Self.makeConfirmationMutation( + recordData: recordData, + request: request + ) else { + errorPointer?.pointee = Self.transactionError("developmentRecordConfirmation") + return nil + } + + if let sourceVersionId = mutation.sourceVersionId { + let sourceSnapshot = try transaction.getDocument( + store.document( + FirestorePath.developmentRecordVersion( + uid, + goalId: goalId, + recordId: recordId, + versionId: sourceVersionId + ) + ) + ) + guard sourceSnapshot.exists else { + errorPointer?.pointee = Self.transactionError("sourceVersion") + return nil + } + } + + transaction.setData(mutation.documentData(), forDocument: versionReference) + transaction.updateData( + [ + DevelopmentRecordFieldKey.currentVersionId.rawValue: request.versionId, + DevelopmentRecordFieldKey.currentVersionNumber.rawValue: mutation.number, + DevelopmentRecordFieldKey.draft.rawValue: FieldValue.delete() + ], + forDocument: recordReference + ) + return nil + } catch let error as NSError { + errorPointer?.pointee = error + return nil + } + } + return try await fetchVersion( + uid: uid, + goalId: goalId, + recordId: recordId, + versionId: request.versionId + ) + } catch { + logger.error("Failed to confirm development record draft", error: error) + record(error, code: .confirmDraft) + throw error + } + } + + func restoreVersion( + goalId: String, + recordId: String, + request: DevelopmentRecordRestoreRequest + ) async throws -> DevelopmentRecordVersionResponse { + guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } + + do { + let recordReference = store.document( + FirestorePath.developmentRecord(uid, goalId: goalId, recordId: recordId) + ) + let sourceReference = store.document( + FirestorePath.developmentRecordVersion( + uid, + goalId: goalId, + recordId: recordId, + versionId: request.sourceVersionId + ) + ) + let versionReference = store.document( + FirestorePath.developmentRecordVersion( + uid, + goalId: goalId, + recordId: recordId, + versionId: request.versionId + ) + ) + _ = try await store.runTransaction { transaction, errorPointer in + do { + let recordSnapshot = try transaction.getDocument(recordReference) + let sourceSnapshot = try transaction.getDocument(sourceReference) + let versionSnapshot = try transaction.getDocument(versionReference) + guard + !versionSnapshot.exists, + let recordData = recordSnapshot.data(), + let sourceData = sourceSnapshot.data(), + let mutation = Self.makeRestoreMutation( + recordId: recordId, + recordData: recordData, + sourceVersionId: request.sourceVersionId, + sourceData: sourceData + ) else { + errorPointer?.pointee = Self.transactionError("developmentRecordRestore") + return nil + } + + transaction.setData(mutation.documentData(), forDocument: versionReference) + transaction.updateData( + [ + DevelopmentRecordFieldKey.currentVersionId.rawValue: request.versionId, + DevelopmentRecordFieldKey.currentVersionNumber.rawValue: mutation.number + ], + forDocument: recordReference + ) + return nil + } catch let error as NSError { + errorPointer?.pointee = error + return nil + } + } + return try await fetchVersion( + uid: uid, + goalId: goalId, + recordId: recordId, + versionId: request.versionId + ) + } catch { + logger.error("Failed to restore development record version", error: error) + record(error, code: .restoreVersion) + throw error + } + } +} + +private extension DevelopmentRecordServiceImpl { + static func record(_ error: Error, code: CrashlyticsError.Code) { + FirebaseCrashlyticsHelper.record( + error, + domain: "\(CrashlyticsError.domain).\(code)", + code: code.rawValue + ) + } + + static func transactionError(_ context: String) -> NSError { + NSError( + domain: "DevelopmentRecordServiceImpl", + code: 1, + userInfo: [NSLocalizedDescriptionKey: context] + ) + } + + func record(_ error: Error, code: CrashlyticsError.Code) { + Self.record(error, code: code) + } + + func fetchRecord( + uid: String, + goalId: String, + recordId: String + ) async throws -> DevelopmentRecordResponse { + let snapshot = try await store.document( + FirestorePath.developmentRecord(uid, goalId: goalId, recordId: recordId) + ) + .getDocument() + guard let data = snapshot.data(), let response = mapper.map( + goalId: goalId, + documentId: snapshot.documentID, + data: data + ) else { + throw FirestoreError.dataNotFound("developmentRecord") + } + return response + } + + func fetchVersion( + uid: String, + goalId: String, + recordId: String, + versionId: String + ) async throws -> DevelopmentRecordVersionResponse { + let snapshot = try await store.document( + FirestorePath.developmentRecordVersion( + uid, + goalId: goalId, + recordId: recordId, + versionId: versionId + ) + ) + .getDocument() + guard let data = snapshot.data(), let response = mapper.mapVersion( + recordId: recordId, + documentId: snapshot.documentID, + data: data + ) else { + throw FirestoreError.dataNotFound("developmentRecordVersion") + } + return response + } +} diff --git a/Application/Infra/Sources/Service/DevelopmentRecordVersionMutation.swift b/Application/Infra/Sources/Service/DevelopmentRecordVersionMutation.swift new file mode 100644 index 00000000..cafa1844 --- /dev/null +++ b/Application/Infra/Sources/Service/DevelopmentRecordVersionMutation.swift @@ -0,0 +1,109 @@ +// +// DevelopmentRecordVersionMutation.swift +// Infra +// +// Created by opfic on 8/28/26. +// + +import FirebaseFirestore +import Data + +extension DevelopmentRecordServiceImpl { + static func makeConfirmationMutation( + recordData: [String: Any], + request: DevelopmentRecordConfirmationRequest + ) -> DevelopmentRecordVersionMutation? { + let mapper = DevelopmentRecordDocumentMapper() + guard let record = mapper.map(goalId: "goal", documentId: "record", data: recordData), + let draft = record.draft else { + return nil + } + + switch (request.kind, record.currentVersion) { + case ("initial", nil): + guard request.sourceVersionId == nil, draft.baseVersionId == nil else { return nil } + return .init( + number: 1, + title: draft.title, + markdownContent: draft.markdownContent, + kind: "initial", + sourceVersionId: nil + ) + case let ("correction", .some(currentVersion)): + guard request.sourceVersionId == currentVersion.id, + draft.baseVersionId == currentVersion.id else { + return nil + } + return .init( + number: currentVersion.number + 1, + title: draft.title, + markdownContent: draft.markdownContent, + kind: "correction", + sourceVersionId: currentVersion.id + ) + default: + return nil + } + } + + static func makeRestoreMutation( + recordId: String, + recordData: [String: Any], + sourceVersionId: String, + sourceData: [String: Any] + ) -> DevelopmentRecordVersionMutation? { + let mapper = DevelopmentRecordDocumentMapper() + guard let record = mapper.map(goalId: "goal", documentId: recordId, data: recordData), + let currentVersion = record.currentVersion, + record.draft == nil, + let source = mapper.mapVersion( + recordId: recordId, + documentId: sourceVersionId, + data: sourceData + ), + source.number < currentVersion.number else { + return nil + } + return .init( + number: currentVersion.number + 1, + title: source.title, + markdownContent: source.markdownContent, + kind: "rollback", + sourceVersionId: sourceVersionId + ) + } + + static func makeDraftData(_ request: DevelopmentRecordDraftRequest) -> [String: Any] { + var data: [String: Any] = [ + DevelopmentRecordDraftFieldKey.title.rawValue: request.title, + DevelopmentRecordDraftFieldKey.markdownContent.rawValue: request.markdownContent, + DevelopmentRecordDraftFieldKey.updatedAt.rawValue: FieldValue.serverTimestamp() + ] + if let baseVersionId = request.baseVersionId { + data[DevelopmentRecordDraftFieldKey.baseVersionId.rawValue] = baseVersionId + } + return data + } +} + +struct DevelopmentRecordVersionMutation { + let number: Int + let title: String + let markdownContent: String + let kind: String + let sourceVersionId: String? + + func documentData() -> [String: Any] { + var data: [String: Any] = [ + DevelopmentRecordVersionFieldKey.versionNumber.rawValue: number, + DevelopmentRecordVersionFieldKey.title.rawValue: title, + DevelopmentRecordVersionFieldKey.markdownContent.rawValue: markdownContent, + DevelopmentRecordVersionFieldKey.changeKind.rawValue: kind, + DevelopmentRecordVersionFieldKey.confirmedAt.rawValue: FieldValue.serverTimestamp() + ] + if let sourceVersionId { + data[DevelopmentRecordVersionFieldKey.sourceVersionId.rawValue] = sourceVersionId + } + return data + } +} diff --git a/Application/Infra/Tests/Service/DevelopmentRecordServiceImplTests.swift b/Application/Infra/Tests/Service/DevelopmentRecordServiceImplTests.swift new file mode 100644 index 00000000..0ba4ae69 --- /dev/null +++ b/Application/Infra/Tests/Service/DevelopmentRecordServiceImplTests.swift @@ -0,0 +1,118 @@ +// +// DevelopmentRecordServiceImplTests.swift +// InfraTests +// +// Created by opfic on 8/28/26. +// + +import Foundation +import Testing +import FirebaseFirestore +import Data +@testable import Infra + +struct DevelopmentRecordServiceImplTests { + @Test("최초 확정은 첫 버전과 초안 내용을 만든다") + func 최초_확정은_첫_버전과_초안_내용을_만든다() { + let mutation = DevelopmentRecordServiceImpl.makeConfirmationMutation( + recordData: makeRecordData(draftBaseVersionId: nil), + request: .init(versionId: "version-1", kind: "initial", sourceVersionId: nil) + ) + + #expect(mutation?.number == 1) + #expect(mutation?.kind == "initial") + #expect(mutation?.sourceVersionId == nil) + } + + @Test("정정은 현재 버전과 초안 기준 버전이 일치할 때만 새 버전을 만든다") + func 정정은_현재_버전과_초안_기준_버전이_일치할_때만_새_버전을_만든다() { + let mutation = DevelopmentRecordServiceImpl.makeConfirmationMutation( + recordData: makeRecordData( + currentVersionId: "version-1", + currentVersionNumber: 1, + draftBaseVersionId: "version-1" + ), + request: .init( + versionId: "version-2", + kind: "correction", + sourceVersionId: "version-1" + ) + ) + + #expect(mutation?.number == 2) + #expect(mutation?.sourceVersionId == "version-1") + } + + @Test("되돌리기는 현재 버전보다 앞선 원본으로 새 rollback 버전을 만든다") + func 되돌리기는_현재_버전보다_앞선_원본으로_새_rollback_버전을_만든다() { + let mutation = DevelopmentRecordServiceImpl.makeRestoreMutation( + recordId: "record-1", + recordData: makeRecordData( + currentVersionId: "version-2", + currentVersionNumber: 2, + draftBaseVersionId: nil, + includesDraft: false + ), + sourceVersionId: "version-1", + sourceData: makeVersionData(number: 1) + ) + + #expect(mutation?.number == 3) + #expect(mutation?.kind == "rollback") + #expect(mutation?.sourceVersionId == "version-1") + } + + @Test("이미 현재인 버전은 되돌리기 원본으로 사용할 수 없다") + func 이미_현재인_버전은_되돌리기_원본으로_사용할_수_없다() { + let mutation = DevelopmentRecordServiceImpl.makeRestoreMutation( + recordId: "record-1", + recordData: makeRecordData( + currentVersionId: "version-2", + currentVersionNumber: 2, + draftBaseVersionId: nil, + includesDraft: false + ), + sourceVersionId: "version-2", + sourceData: makeVersionData(number: 2) + ) + + #expect(mutation?.number == nil) + } + + private func makeRecordData( + currentVersionId: String? = nil, + currentVersionNumber: Int? = nil, + draftBaseVersionId: String?, + includesDraft: Bool = true + ) -> [String: Any] { + var data: [String: Any] = [ + DevelopmentRecordFieldKey.createdAt.rawValue: Timestamp(date: .distantPast) + ] + if let currentVersionId, let currentVersionNumber { + data[DevelopmentRecordFieldKey.currentVersionId.rawValue] = currentVersionId + data[DevelopmentRecordFieldKey.currentVersionNumber.rawValue] = currentVersionNumber + } + if includesDraft { + var draft: [String: Any] = [ + DevelopmentRecordDraftFieldKey.title.rawValue: "기록", + DevelopmentRecordDraftFieldKey.markdownContent.rawValue: "본문", + DevelopmentRecordDraftFieldKey.updatedAt.rawValue: Timestamp(date: .distantPast) + ] + if let draftBaseVersionId { + draft[DevelopmentRecordDraftFieldKey.baseVersionId.rawValue] = draftBaseVersionId + } + data[DevelopmentRecordFieldKey.draft.rawValue] = draft + } + return data + } + + private func makeVersionData(number: Int) -> [String: Any] { + [ + DevelopmentRecordVersionFieldKey.versionNumber.rawValue: number, + DevelopmentRecordVersionFieldKey.title.rawValue: "기록", + DevelopmentRecordVersionFieldKey.markdownContent.rawValue: "본문", + DevelopmentRecordVersionFieldKey.changeKind.rawValue: "initial", + DevelopmentRecordVersionFieldKey.confirmedAt.rawValue: Timestamp(date: .distantPast) + ] + } +} From b6f49203f5f312d1ca7d0c757393d253b97e1810 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 28 Aug 2026 01:06:43 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20=EA=B0=9C=EB=B0=9C=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=C2=B7=EA=B8=B0=EB=A1=9D=20=EC=A0=80=EC=9E=A5=EC=86=8C?= =?UTF-8?q?=20DI=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/Data/Sources/DataAssembler.swift | 12 ++++++++++++ Application/Infra/Sources/InfraAssembler.swift | 8 ++++++++ 2 files changed, 20 insertions(+) diff --git a/Application/Data/Sources/DataAssembler.swift b/Application/Data/Sources/DataAssembler.swift index 58cbeba4..ab6a69a8 100644 --- a/Application/Data/Sources/DataAssembler.swift +++ b/Application/Data/Sources/DataAssembler.swift @@ -36,6 +36,18 @@ public final class DataAssembler: Assembler { TodoMutationEventBusImpl() } + container.register(DevelopmentGoalRepository.self) { + DevelopmentGoalRepositoryImpl( + service: container.resolve(DevelopmentGoalService.self) + ) + } + + container.register(DevelopmentRecordRepository.self) { + DevelopmentRecordRepositoryImpl( + service: container.resolve(DevelopmentRecordService.self) + ) + } + container.register(TodoRepository.self) { TodoRepositoryImpl( todoService: container.resolve(TodoService.self), diff --git a/Application/Infra/Sources/InfraAssembler.swift b/Application/Infra/Sources/InfraAssembler.swift index 0645f606..03bafc92 100644 --- a/Application/Infra/Sources/InfraAssembler.swift +++ b/Application/Infra/Sources/InfraAssembler.swift @@ -57,6 +57,14 @@ public final class InfraAssembler: Assembler { TodoServiceImpl() } + container.register(DevelopmentGoalService.self) { + DevelopmentGoalServiceImpl() + } + + container.register(DevelopmentRecordService.self) { + DevelopmentRecordServiceImpl() + } + container.register(TodoCategoryService.self) { TodoCategoryServiceImpl() } From ec88f7da5020d80c1f6c49c364a8f7a4860737b1 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 28 Aug 2026 01:06:53 +0900 Subject: [PATCH 07/14] =?UTF-8?q?fix:=20Firestore=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=A0=91=EA=B7=BC=20=EC=88=98=EC=A4=80=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Service/DevelopmentGoalServiceImpl.swift | 6 +++--- .../Service/DevelopmentRecordServiceImpl.swift | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift b/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift index 1681e1d9..0abc165b 100644 --- a/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift +++ b/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift @@ -176,7 +176,7 @@ extension DevelopmentGoalServiceImpl { } private extension DevelopmentGoalServiceImpl { - static func record(_ error: Error, code: CrashlyticsError.Code) { + private static func record(_ error: Error, code: CrashlyticsError.Code) { FirebaseCrashlyticsHelper.record( error, domain: "\(CrashlyticsError.domain).\(code)", @@ -184,11 +184,11 @@ private extension DevelopmentGoalServiceImpl { ) } - func record(_ error: Error, code: CrashlyticsError.Code) { + private func record(_ error: Error, code: CrashlyticsError.Code) { Self.record(error, code: code) } - func fetchGoal(uid: String, goalId: String) async throws -> DevelopmentGoalResponse { + private func fetchGoal(uid: String, goalId: String) async throws -> DevelopmentGoalResponse { let snapshot = try await store.document( FirestorePath.developmentGoal(uid, goalId: goalId) ) diff --git a/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift b/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift index 41295668..7e464fe0 100644 --- a/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift +++ b/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift @@ -185,7 +185,7 @@ final class DevelopmentRecordServiceImpl: DevelopmentRecordService { if let sourceVersionId = mutation.sourceVersionId { let sourceSnapshot = try transaction.getDocument( - store.document( + self.store.document( FirestorePath.developmentRecordVersion( uid, goalId: goalId, @@ -303,7 +303,7 @@ final class DevelopmentRecordServiceImpl: DevelopmentRecordService { } private extension DevelopmentRecordServiceImpl { - static func record(_ error: Error, code: CrashlyticsError.Code) { + private static func record(_ error: Error, code: CrashlyticsError.Code) { FirebaseCrashlyticsHelper.record( error, domain: "\(CrashlyticsError.domain).\(code)", @@ -311,7 +311,7 @@ private extension DevelopmentRecordServiceImpl { ) } - static func transactionError(_ context: String) -> NSError { + private static func transactionError(_ context: String) -> NSError { NSError( domain: "DevelopmentRecordServiceImpl", code: 1, @@ -319,11 +319,11 @@ private extension DevelopmentRecordServiceImpl { ) } - func record(_ error: Error, code: CrashlyticsError.Code) { + private func record(_ error: Error, code: CrashlyticsError.Code) { Self.record(error, code: code) } - func fetchRecord( + private func fetchRecord( uid: String, goalId: String, recordId: String @@ -342,7 +342,7 @@ private extension DevelopmentRecordServiceImpl { return response } - func fetchVersion( + private func fetchVersion( uid: String, goalId: String, recordId: String, From a0c24ca7832aae8029bc46875bad14a9dd6dd87f Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 28 Aug 2026 10:48:41 +0900 Subject: [PATCH 08/14] =?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=80=EC=9E=A5=20=EC=B1=85?= =?UTF-8?q?=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevelopmentGoalRepositoryImpl.swift | 3 + .../DevelopmentGoalRepositoryImplTests.swift | 16 ++++++ .../Service/DevelopmentGoalServiceImpl.swift | 55 +++++++++++++++---- .../DevelopmentGoalServiceImplTests.swift | 30 +++++++++- 4 files changed, 91 insertions(+), 13 deletions(-) diff --git a/Application/Data/Sources/Repository/DevelopmentGoalRepositoryImpl.swift b/Application/Data/Sources/Repository/DevelopmentGoalRepositoryImpl.swift index 847ec823..3665d721 100644 --- a/Application/Data/Sources/Repository/DevelopmentGoalRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/DevelopmentGoalRepositoryImpl.swift @@ -62,6 +62,9 @@ final class DevelopmentGoalRepositoryImpl: DevelopmentGoalRepository { completionSnapshot: DevelopmentGoal.CompletionSnapshot? ) async throws { do { + guard status != .completed else { + throw DomainLayerError.invalidDevelopmentGoalTransition + } try await service.transitionGoalStatus( goalId: goalId, request: .init(status: status.storageValue) diff --git a/Application/Data/Tests/Repository/DevelopmentGoalRepositoryImplTests.swift b/Application/Data/Tests/Repository/DevelopmentGoalRepositoryImplTests.swift index ca69e37f..b0d56562 100644 --- a/Application/Data/Tests/Repository/DevelopmentGoalRepositoryImplTests.swift +++ b/Application/Data/Tests/Repository/DevelopmentGoalRepositoryImplTests.swift @@ -55,6 +55,22 @@ struct DevelopmentGoalRepositoryImplTests { #expect(request.goalId == "goal-1") #expect(request.status == "archived") } + + @Test("목표 완료 전환은 서비스에 전달하지 않는다") + func 목표_완료_전환은_서비스에_전달하지_않는다() async { + let service = DevelopmentGoalServiceSpy() + let repository = DevelopmentGoalRepositoryImpl(service: service) + + await #expect(throws: DomainLayerError.invalidDevelopmentGoalTransition) { + try await repository.transitionGoalStatus( + "goal-1", + to: .completed, + completionSnapshot: nil + ) + } + + #expect(await service.transitionRequest() == nil) + } } private actor DevelopmentGoalServiceSpy: DevelopmentGoalService { diff --git a/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift b/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift index 0abc165b..73fe2909 100644 --- a/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift +++ b/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift @@ -129,19 +129,28 @@ final class DevelopmentGoalServiceImpl: DevelopmentGoalService { guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } do { - var data: [String: Any] = [ - DevelopmentGoalFieldKey.status.rawValue: request.status, - DevelopmentGoalFieldKey.updatedAt.rawValue: FieldValue.serverTimestamp() - ] - if request.status == "completed" { - data[DevelopmentGoalFieldKey.completedAt.rawValue] = FieldValue.serverTimestamp() - } else { - data[DevelopmentGoalFieldKey.completedAt.rawValue] = FieldValue.delete() - } - try await store.document( + let reference = store.document( FirestorePath.developmentGoal(uid, goalId: goalId) ) - .updateData(data) + _ = try await store.runTransaction { transaction, errorPointer in + do { + let snapshot = try transaction.getDocument(reference) + guard + let recordData = snapshot.data(), + let data = Self.makeTransitionData( + recordData: recordData, + request: request + ) else { + errorPointer?.pointee = Self.transactionError("developmentGoalTransition") + return nil + } + transaction.updateData(data, forDocument: reference) + return nil + } catch let error as NSError { + errorPointer?.pointee = error + return nil + } + } } catch { logger.error("Failed to transition development goal status", error: error) record(error, code: .transitionGoalStatus) @@ -151,6 +160,22 @@ final class DevelopmentGoalServiceImpl: DevelopmentGoalService { } extension DevelopmentGoalServiceImpl { + static func makeTransitionData( + recordData: [String: Any], + request: DevelopmentGoalStatusRequest + ) -> [String: Any]? { + guard + let currentStatus = recordData[DevelopmentGoalFieldKey.status.rawValue] as? String, + (currentStatus == "inProgress" && request.status == "archived") || + (currentStatus == "archived" && request.status == "inProgress") else { + return nil + } + return [ + DevelopmentGoalFieldKey.status.rawValue: request.status, + DevelopmentGoalFieldKey.updatedAt.rawValue: FieldValue.serverTimestamp() + ] + } + static func makeResponse( documentId: String, data: [String: Any] @@ -176,6 +201,14 @@ extension DevelopmentGoalServiceImpl { } private extension DevelopmentGoalServiceImpl { + private static func transactionError(_ context: String) -> NSError { + NSError( + domain: "DevelopmentGoalServiceImpl", + code: 1, + userInfo: [NSLocalizedDescriptionKey: context] + ) + } + private static func record(_ error: Error, code: CrashlyticsError.Code) { FirebaseCrashlyticsHelper.record( error, diff --git a/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift b/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift index f847bb62..77147aad 100644 --- a/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift +++ b/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift @@ -22,6 +22,32 @@ struct DevelopmentGoalServiceImplTests { #expect(response.status == "inProgress") } + @Test("목표 상태 전환은 진행 중과 보관 사이에서만 저장 데이터를 만든다") + func 목표_상태_전환은_진행_중과_보관_사이에서만_저장_데이터를_만든다() { + let data = DevelopmentGoalServiceImpl.makeTransitionData( + recordData: makeData(), + request: .init(status: "archived") + ) + + #expect(data?["status"] as? String == "archived") + #expect(data?["completedAt"] == nil) + } + + @Test("완료 상태를 포함한 목표 상태 전환은 저장 데이터를 만들지 않는다") + func 완료_상태를_포함한_목표_상태_전환은_저장_데이터를_만들지_않는다() { + let requestedCompletion = DevelopmentGoalServiceImpl.makeTransitionData( + recordData: makeData(), + request: .init(status: "completed") + ) + let existingCompletion = DevelopmentGoalServiceImpl.makeTransitionData( + recordData: makeData(status: "completed"), + request: .init(status: "archived") + ) + + #expect(requestedCompletion == nil) + #expect(existingCompletion == nil) + } + @Test("개발 기록 문서는 경로 식별값을 응답에 복원한다") func 개발_기록_문서는_경로_식별값을_응답에_복원한다() throws { let response = try #require( @@ -44,11 +70,11 @@ struct DevelopmentGoalServiceImplTests { #expect(response.draft?.baseVersionId == nil) } - private func makeData() -> [String: Any] { + private func makeData(status: String = "inProgress") -> [String: Any] { [ "title": "목표", "markdownDescription": "설명", - "status": "inProgress", + "status": status, "createdAt": Timestamp(date: .distantPast), "updatedAt": Timestamp(date: .distantPast) ] From 5b1df0f9549778b52da3efc8f08e38c0b1fbf20a Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 28 Aug 2026 10:56:11 +0900 Subject: [PATCH 09/14] =?UTF-8?q?fix:=20=EA=B0=9C=EB=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20Draft=20=EA=B8=B0=EC=A4=80=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Sources/Common/DataLayerError.swift | 1 + .../Data/Sources/Mapper/ErrorMapping.swift | 2 + .../Data/Tests/Mapper/ErrorMappingTests.swift | 19 +++++++ .../DevelopmentRecordServiceImpl.swift | 48 +++++++++++----- .../DevelopmentRecordTransactionError.swift | 25 ++++++++ .../DevelopmentRecordVersionMutation.swift | 12 ++++ .../DevelopmentRecordServiceImplTests.swift | 57 +++++++++++++++++++ 7 files changed, 149 insertions(+), 15 deletions(-) create mode 100644 Application/Data/Tests/Mapper/ErrorMappingTests.swift create mode 100644 Application/Infra/Sources/Service/DevelopmentRecordTransactionError.swift diff --git a/Application/Data/Sources/Common/DataLayerError.swift b/Application/Data/Sources/Common/DataLayerError.swift index 42b9f673..fed15492 100644 --- a/Application/Data/Sources/Common/DataLayerError.swift +++ b/Application/Data/Sources/Common/DataLayerError.swift @@ -18,6 +18,7 @@ public enum DataLayerError: Error { case notAuthenticated case failedToUnlinkLastProvider case linkCredentialAlreadyInUse + case developmentRecordDraftConflict case invalidData(context: String) private static let logger = Logger(category: "DataLayerError") diff --git a/Application/Data/Sources/Mapper/ErrorMapping.swift b/Application/Data/Sources/Mapper/ErrorMapping.swift index 7fd286c2..7c3ebc50 100644 --- a/Application/Data/Sources/Mapper/ErrorMapping.swift +++ b/Application/Data/Sources/Mapper/ErrorMapping.swift @@ -16,6 +16,8 @@ extension Error { return AuthError.failedToUnlinkLastProvider case .linkCredentialAlreadyInUse: return AuthError.linkCredentialAlreadyInUse + case .developmentRecordDraftConflict: + return DomainLayerError.developmentRecordDraftConflict case .invalidData(let context): return DomainLayerError.invalidData(context: context) case .none: diff --git a/Application/Data/Tests/Mapper/ErrorMappingTests.swift b/Application/Data/Tests/Mapper/ErrorMappingTests.swift new file mode 100644 index 00000000..eadfc638 --- /dev/null +++ b/Application/Data/Tests/Mapper/ErrorMappingTests.swift @@ -0,0 +1,19 @@ +// +// ErrorMappingTests.swift +// DataTests +// +// Created by opfic on 8/28/26. +// + +import Testing +import Domain +@testable import Data + +struct ErrorMappingTests { + @Test("Draft 기준 버전 충돌은 Domain 충돌 오류로 변환한다") + func Draft_기준_버전_충돌은_Domain_충돌_오류로_변환한다() { + let error = DataLayerError.developmentRecordDraftConflict.toDomain() + + #expect(error as? DomainLayerError == .developmentRecordDraftConflict) + } +} diff --git a/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift b/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift index 7e464fe0..943b6949 100644 --- a/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift +++ b/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift @@ -135,12 +135,38 @@ final class DevelopmentRecordServiceImpl: DevelopmentRecordService { guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } do { - try await store.document( + let reference = store.document( FirestorePath.developmentRecord(uid, goalId: goalId, recordId: recordId) ) - .updateData([ - DevelopmentRecordFieldKey.draft.rawValue: Self.makeDraftData(request) - ]) + do { + _ = try await store.runTransaction { transaction, errorPointer in + do { + let snapshot = try transaction.getDocument(reference) + guard + let recordData = snapshot.data(), + let data = Self.makeDraftData( + recordData: recordData, + request: request + ) else { + errorPointer?.pointee = DevelopmentRecordTransactionError.make( + "developmentRecordDraft", + code: DevelopmentRecordTransactionError.draftConflictCode + ) + return nil + } + transaction.updateData( + [DevelopmentRecordFieldKey.draft.rawValue: data], + forDocument: reference + ) + return nil + } catch let error as NSError { + errorPointer?.pointee = error + return nil + } + } + } catch let error as NSError where DevelopmentRecordTransactionError.isDraftConflict(error) { + throw DataLayerError.developmentRecordDraftConflict + } return try await fetchRecord(uid: uid, goalId: goalId, recordId: recordId) } catch { logger.error("Failed to save development record draft", error: error) @@ -179,7 +205,7 @@ final class DevelopmentRecordServiceImpl: DevelopmentRecordService { recordData: recordData, request: request ) else { - errorPointer?.pointee = Self.transactionError("developmentRecordConfirmation") + errorPointer?.pointee = DevelopmentRecordTransactionError.make("developmentRecordConfirmation") return nil } @@ -195,7 +221,7 @@ final class DevelopmentRecordServiceImpl: DevelopmentRecordService { ) ) guard sourceSnapshot.exists else { - errorPointer?.pointee = Self.transactionError("sourceVersion") + errorPointer?.pointee = DevelopmentRecordTransactionError.make("sourceVersion") return nil } } @@ -270,7 +296,7 @@ final class DevelopmentRecordServiceImpl: DevelopmentRecordService { sourceVersionId: request.sourceVersionId, sourceData: sourceData ) else { - errorPointer?.pointee = Self.transactionError("developmentRecordRestore") + errorPointer?.pointee = DevelopmentRecordTransactionError.make("developmentRecordRestore") return nil } @@ -311,14 +337,6 @@ private extension DevelopmentRecordServiceImpl { ) } - private static func transactionError(_ context: String) -> NSError { - NSError( - domain: "DevelopmentRecordServiceImpl", - code: 1, - userInfo: [NSLocalizedDescriptionKey: context] - ) - } - private func record(_ error: Error, code: CrashlyticsError.Code) { Self.record(error, code: code) } diff --git a/Application/Infra/Sources/Service/DevelopmentRecordTransactionError.swift b/Application/Infra/Sources/Service/DevelopmentRecordTransactionError.swift new file mode 100644 index 00000000..ccd6bcf5 --- /dev/null +++ b/Application/Infra/Sources/Service/DevelopmentRecordTransactionError.swift @@ -0,0 +1,25 @@ +// +// DevelopmentRecordTransactionError.swift +// Infra +// +// Created by opfic on 8/28/26. +// + +import Foundation + +enum DevelopmentRecordTransactionError { + static let draftConflictCode = 2 + + static func make(_ context: String, code: Int = 1) -> NSError { + NSError( + domain: "DevelopmentRecordServiceImpl", + code: code, + userInfo: [NSLocalizedDescriptionKey: context] + ) + } + + static func isDraftConflict(_ error: NSError) -> Bool { + error.domain == "DevelopmentRecordServiceImpl" && + error.code == draftConflictCode + } +} diff --git a/Application/Infra/Sources/Service/DevelopmentRecordVersionMutation.swift b/Application/Infra/Sources/Service/DevelopmentRecordVersionMutation.swift index cafa1844..80840b8a 100644 --- a/Application/Infra/Sources/Service/DevelopmentRecordVersionMutation.swift +++ b/Application/Infra/Sources/Service/DevelopmentRecordVersionMutation.swift @@ -84,6 +84,18 @@ extension DevelopmentRecordServiceImpl { } return data } + + static func makeDraftData( + recordData: [String: Any], + request: DevelopmentRecordDraftRequest + ) -> [String: Any]? { + guard + (recordData[DevelopmentRecordFieldKey.currentVersionId.rawValue] as? String) == + request.baseVersionId else { + return nil + } + return makeDraftData(request) + } } struct DevelopmentRecordVersionMutation { diff --git a/Application/Infra/Tests/Service/DevelopmentRecordServiceImplTests.swift b/Application/Infra/Tests/Service/DevelopmentRecordServiceImplTests.swift index 0ba4ae69..2cd8577d 100644 --- a/Application/Infra/Tests/Service/DevelopmentRecordServiceImplTests.swift +++ b/Application/Infra/Tests/Service/DevelopmentRecordServiceImplTests.swift @@ -79,6 +79,63 @@ struct DevelopmentRecordServiceImplTests { #expect(mutation?.number == nil) } + @Test("Draft 저장은 현재 버전과 기준 버전이 같을 때만 저장 데이터를 만든다") + func Draft_저장은_현재_버전과_기준_버전이_같을_때만_저장_데이터를_만든다() { + let data = DevelopmentRecordServiceImpl.makeDraftData( + recordData: makeRecordData( + currentVersionId: "version-1", + currentVersionNumber: 1, + draftBaseVersionId: "version-1" + ), + request: .init( + title: "새 기록", + markdownContent: "새 본문", + baseVersionId: "version-1" + ) + ) + + #expect(data?[DevelopmentRecordDraftFieldKey.title.rawValue] as? String == "새 기록") + #expect(data?[DevelopmentRecordDraftFieldKey.baseVersionId.rawValue] as? String == "version-1") + + let initialData = DevelopmentRecordServiceImpl.makeDraftData( + recordData: makeRecordData(draftBaseVersionId: nil), + request: .init( + title: "첫 기록", + markdownContent: "첫 본문", + baseVersionId: nil + ) + ) + + #expect(initialData?[DevelopmentRecordDraftFieldKey.baseVersionId.rawValue] == nil) + } + + @Test("기준 버전이 바뀐 Draft 저장은 데이터를 만들지 않는다") + func 기준_버전이_바뀐_Draft_저장은_데이터를_만들지_않는다() { + let staleDraft = DevelopmentRecordServiceImpl.makeDraftData( + recordData: makeRecordData( + currentVersionId: "version-2", + currentVersionNumber: 2, + draftBaseVersionId: nil + ), + request: .init( + title: "기록", + markdownContent: "본문", + baseVersionId: "version-1" + ) + ) + let missingCurrentVersion = DevelopmentRecordServiceImpl.makeDraftData( + recordData: makeRecordData(draftBaseVersionId: nil), + request: .init( + title: "기록", + markdownContent: "본문", + baseVersionId: "version-1" + ) + ) + + #expect(staleDraft == nil) + #expect(missingCurrentVersion == nil) + } + private func makeRecordData( currentVersionId: String? = nil, currentVersionNumber: Int? = nil, From 142071b55143663097f6be339dc095b49f8a7c19 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 28 Aug 2026 12:14:51 +0900 Subject: [PATCH 10/14] =?UTF-8?q?refactor:=20Todo=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=C2=B7=EB=AA=85=EB=A0=B9=20Firestore=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/Data/Sources/DataAssembler.swift | 5 +- .../Sources/Protocol/TodoCommandService.swift | 14 + ...doService.swift => TodoQueryService.swift} | 10 +- .../Repository/TodoRepositoryImpl.swift | 21 +- .../WidgetTodoSnapshotRepositoryImpl.swift | 10 +- .../TodoRepositoryImplTestSupport.swift | 153 +++++ .../Repository/TodoRepositoryImplTests.swift | 46 ++ ...idgetTodoSnapshotRepositoryImplTests.swift | 31 + .../Infra/Sources/InfraAssembler.swift | 8 +- .../Sources/Mapper/TodoDocumentMapper.swift | 87 +++ .../Mapper/TodoQueryCursorMapper.swift | 83 +++ .../Service/TodoCommandServiceImpl.swift | 138 ++++ .../Service/TodoQueryServiceImpl.swift | 281 ++++++++ .../Sources/Service/TodoSearchMatching.swift | 34 + .../Sources/Service/TodoServiceImpl.swift | 624 ------------------ .../TodoDocumentMapperTests.swift} | 12 +- .../Mapper/TodoQueryCursorMapperTests.swift | 44 ++ .../Service/TodoSearchMatchingTests.swift | 12 +- 18 files changed, 952 insertions(+), 661 deletions(-) create mode 100644 Application/Data/Sources/Protocol/TodoCommandService.swift rename Application/Data/Sources/Protocol/{TodoService.swift => TodoQueryService.swift} (52%) create mode 100644 Application/Data/Tests/Repository/TodoRepositoryImplTestSupport.swift create mode 100644 Application/Data/Tests/Repository/TodoRepositoryImplTests.swift create mode 100644 Application/Data/Tests/Repository/WidgetTodoSnapshotRepositoryImplTests.swift create mode 100644 Application/Infra/Sources/Mapper/TodoDocumentMapper.swift create mode 100644 Application/Infra/Sources/Mapper/TodoQueryCursorMapper.swift create mode 100644 Application/Infra/Sources/Service/TodoCommandServiceImpl.swift create mode 100644 Application/Infra/Sources/Service/TodoQueryServiceImpl.swift create mode 100644 Application/Infra/Sources/Service/TodoSearchMatching.swift delete mode 100644 Application/Infra/Sources/Service/TodoServiceImpl.swift rename Application/Infra/Tests/{Service/TodoGoalIDStorageTests.swift => Mapper/TodoDocumentMapperTests.swift} (79%) create mode 100644 Application/Infra/Tests/Mapper/TodoQueryCursorMapperTests.swift diff --git a/Application/Data/Sources/DataAssembler.swift b/Application/Data/Sources/DataAssembler.swift index ab6a69a8..c7f23fa2 100644 --- a/Application/Data/Sources/DataAssembler.swift +++ b/Application/Data/Sources/DataAssembler.swift @@ -50,7 +50,8 @@ public final class DataAssembler: Assembler { container.register(TodoRepository.self) { TodoRepositoryImpl( - todoService: container.resolve(TodoService.self), + queryService: container.resolve(TodoQueryService.self), + commandService: container.resolve(TodoCommandService.self), todoCategoryService: container.resolve(TodoCategoryService.self), store: container.resolve(MemoryCacheStore.self), updater: container.resolve(WidgetSnapshotUpdater.self), @@ -59,7 +60,7 @@ public final class DataAssembler: Assembler { } container.register(WidgetTodoSnapshotRepository.self) { - WidgetTodoSnapshotRepositoryImpl(todoService: container.resolve(TodoService.self)) + WidgetTodoSnapshotRepositoryImpl(queryService: container.resolve(TodoQueryService.self)) } container.register(TodoCategoryRepository.self) { diff --git a/Application/Data/Sources/Protocol/TodoCommandService.swift b/Application/Data/Sources/Protocol/TodoCommandService.swift new file mode 100644 index 00000000..2c131d96 --- /dev/null +++ b/Application/Data/Sources/Protocol/TodoCommandService.swift @@ -0,0 +1,14 @@ +// +// TodoCommandService.swift +// Data +// +// Created by opfic on 8/28/26. +// + +import Core + +public protocol TodoCommandService { + func upsertTodo(request: TodoRequest) async throws + func deleteTodo(todoId: String) async throws + func undoDeleteTodo(todoId: String) async throws +} diff --git a/Application/Data/Sources/Protocol/TodoService.swift b/Application/Data/Sources/Protocol/TodoQueryService.swift similarity index 52% rename from Application/Data/Sources/Protocol/TodoService.swift rename to Application/Data/Sources/Protocol/TodoQueryService.swift index d9cdc9ac..2ce81352 100644 --- a/Application/Data/Sources/Protocol/TodoService.swift +++ b/Application/Data/Sources/Protocol/TodoQueryService.swift @@ -1,18 +1,14 @@ // -// TodoService.swift +// TodoQueryService.swift // Data // -// Created by opfic on 5/14/26. +// Created by opfic on 8/28/26. // -import Foundation import Core -public protocol TodoService { +public protocol TodoQueryService { func fetchTodos(_ query: TodoQuery, cursor: TodoCursorDTO?) async throws -> TodoPageResponse - func upsertTodo(request: TodoRequest) async throws - func deleteTodo(todoId: String) async throws - func undoDeleteTodo(todoId: String) async throws func fetchTodo(todoId: String) async throws -> TodoResponse func fetchReferences(_ numbers: [Int]) async throws -> [Int: TodoReferenceResponse] } diff --git a/Application/Data/Sources/Repository/TodoRepositoryImpl.swift b/Application/Data/Sources/Repository/TodoRepositoryImpl.swift index 413f3991..1d65412f 100644 --- a/Application/Data/Sources/Repository/TodoRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/TodoRepositoryImpl.swift @@ -14,20 +14,23 @@ final class TodoRepositoryImpl: TodoRepository { static let preferences = "TodoCategory.preferences" } - private let todoService: TodoService + private let queryService: TodoQueryService + private let commandService: TodoCommandService private let todoCategoryService: TodoCategoryService private let store: MemoryCacheStore private let updater: WidgetSnapshotUpdater private let eventBus: TodoMutationEventBus init( - todoService: TodoService, + queryService: TodoQueryService, + commandService: TodoCommandService, todoCategoryService: TodoCategoryService, store: MemoryCacheStore, updater: WidgetSnapshotUpdater, eventBus: TodoMutationEventBus ) { - self.todoService = todoService + self.queryService = queryService + self.commandService = commandService self.todoCategoryService = todoCategoryService self.store = store self.updater = updater @@ -38,7 +41,7 @@ final class TodoRepositoryImpl: TodoRepository { let responseCursor = cursor.map { TodoCursorDTO.fromDomain($0) } do { - async let todos = todoService.fetchTodos(query, cursor: responseCursor) + async let todos = queryService.fetchTodos(query, cursor: responseCursor) async let preferences = todoCategoryPreferenceResponses() let (todoResponse, todoPreferenceResponses) = try await ( @@ -70,7 +73,7 @@ final class TodoRepositoryImpl: TodoRepository { func fetchTodo(_ todoId: String) async throws -> Todo { do { - async let response = todoService.fetchTodo(todoId: todoId) + async let response = queryService.fetchTodo(todoId: todoId) async let preferences = todoCategoryPreferenceResponses() let (todoResponse, todoPreferenceResponses) = try await ( @@ -95,7 +98,7 @@ final class TodoRepositoryImpl: TodoRepository { func fetchReferences(_ numbers: [Int]) async throws -> [Int: TodoReference] { do { - async let responseTask = todoService.fetchReferences(numbers) + async let responseTask = queryService.fetchReferences(numbers) async let preferencesTask = todoCategoryPreferenceResponses() let (responses, preferenceResponses) = try await ( @@ -148,7 +151,7 @@ final class TodoRepositoryImpl: TodoRepository { private func upsertTodo(_ todoRequest: TodoRequest) async throws { do { - try await todoService.upsertTodo(request: todoRequest) + try await commandService.upsertTodo(request: todoRequest) } catch { throw error.toDomain() } @@ -156,7 +159,7 @@ final class TodoRepositoryImpl: TodoRepository { func deleteTodo(_ todoId: String) async throws { do { - try await todoService.deleteTodo(todoId: todoId) + try await commandService.deleteTodo(todoId: todoId) let now = Date() updater.deleteTodoSnapshot(todoId: todoId, deletedAt: now, now: now) eventBus.publish(.deleted(todoId)) @@ -167,7 +170,7 @@ final class TodoRepositoryImpl: TodoRepository { func undoDeleteTodo(_ todoId: String) async throws { do { - try await todoService.undoDeleteTodo(todoId: todoId) + try await commandService.undoDeleteTodo(todoId: todoId) let now = Date() updater.restoreTodoSnapshot(todoId: todoId, now: now) eventBus.publish(.restored(todoId)) diff --git a/Application/Data/Sources/Repository/WidgetTodoSnapshotRepositoryImpl.swift b/Application/Data/Sources/Repository/WidgetTodoSnapshotRepositoryImpl.swift index d19bf5ad..66a65b20 100644 --- a/Application/Data/Sources/Repository/WidgetTodoSnapshotRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/WidgetTodoSnapshotRepositoryImpl.swift @@ -10,10 +10,10 @@ import Core import Domain final class WidgetTodoSnapshotRepositoryImpl: WidgetTodoSnapshotRepository { - private let todoService: TodoService + private let queryService: TodoQueryService - init(todoService: TodoService) { - self.todoService = todoService + init(queryService: TodoQueryService) { + self.queryService = queryService } func fetchTodayTodos( @@ -32,7 +32,7 @@ final class WidgetTodoSnapshotRepositoryImpl: WidgetTodoSnapshotRepository { ) do { - let todoPage = try await todoService.fetchTodos(query, cursor: nil) + let todoPage = try await queryService.fetchTodos(query, cursor: nil) return todoPage.items.map(WidgetTodoSnapshot.fromResponse) } catch { throw error.toDomain() @@ -55,7 +55,7 @@ final class WidgetTodoSnapshotRepositoryImpl: WidgetTodoSnapshotRepository { ) do { - let todoPage = try await todoService.fetchTodos(query, cursor: nil) + let todoPage = try await queryService.fetchTodos(query, cursor: nil) return todoPage.items.map(WidgetTodoSnapshot.fromResponse) } catch { throw error.toDomain() diff --git a/Application/Data/Tests/Repository/TodoRepositoryImplTestSupport.swift b/Application/Data/Tests/Repository/TodoRepositoryImplTestSupport.swift new file mode 100644 index 00000000..06932912 --- /dev/null +++ b/Application/Data/Tests/Repository/TodoRepositoryImplTestSupport.swift @@ -0,0 +1,153 @@ +// +// TodoRepositoryImplTestSupport.swift +// DataTests +// +// Created by opfic on 8/28/26. +// + +import Combine +import Foundation +import Core +import Domain +@testable import Data + +actor TodoRepositoryQueryServiceSpy: TodoQueryService { + private var recordedTodoQueries = [TodoQuery]() + + func fetchTodos(_ query: TodoQuery, cursor: TodoCursorDTO?) async throws -> TodoPageResponse { + recordedTodoQueries.append(query) + return .init(items: [makeTodoRepositoryResponse()], nextCursor: nil) + } + + func fetchTodo(todoId: String) async throws -> TodoResponse { + makeTodoRepositoryResponse() + } + + func fetchReferences(_ numbers: [Int]) async throws -> [Int: TodoReferenceResponse] { + [:] + } + + func fetchTodoQueries() -> [TodoQuery] { + recordedTodoQueries + } +} + +actor TodoRepositoryCommandServiceSpy: TodoCommandService { + private var recordedUpsertRequests = [TodoRequest]() + + func upsertTodo(request: TodoRequest) async throws { + recordedUpsertRequests.append(request) + } + + func deleteTodo(todoId: String) async throws { } + + func undoDeleteTodo(todoId: String) async throws { } + + func upsertRequests() -> [TodoRequest] { + recordedUpsertRequests + } +} + +private actor TodoRepositoryCategoryServiceSpy: TodoCategoryService { + func fetchCategoryPreferences() async throws -> [TodoCategoryPreferenceResponse] { + [] + } + + func updateCategoryPreferences(_ preferences: [TodoCategoryPreferenceResponse]) async throws { } +} + +private final class TodoRepositoryMemoryCacheStoreSpy: MemoryCacheStore { + func value(forKey key: String) -> T? { + nil + } + + func setValue(_ value: T?, forKey key: String) { } +} + +private final class TodoRepositoryWidgetSnapshotUpdaterSpy: WidgetSnapshotUpdater { + func updateTodaySnapshot( + todos: [WidgetTodoSnapshot]?, + displayOptions: TodayDisplayOptions?, + now: Date + ) { } + + func updateHeatmapSnapshot( + createdTodos: [WidgetTodoSnapshot]?, + completedTodos: [WidgetTodoSnapshot]?, + deletedTodos: [WidgetTodoSnapshot]?, + quarterStart: Date?, + now: Date + ) { } + + func upsertTodoSnapshot(_ todo: WidgetTodoSnapshot, now: Date) { } + + func deleteTodoSnapshot(todoId: String, deletedAt: Date, now: Date) { } + + func restoreTodoSnapshot(todoId: String, now: Date) { } + + func clear() { } +} + +private final class TodoRepositoryMutationEventBusSpy: TodoMutationEventBus { + private let subject = PassthroughSubject() + + func publish(_ event: TodoMutationEvent) { + subject.send(event) + } + + func observe() -> AnyPublisher { + subject.eraseToAnyPublisher() + } +} + +func makeTodoRepository( + queryService: TodoQueryService, + commandService: TodoCommandService +) -> TodoRepositoryImpl { + TodoRepositoryImpl( + queryService: queryService, + commandService: commandService, + todoCategoryService: TodoRepositoryCategoryServiceSpy(), + store: TodoRepositoryMemoryCacheStoreSpy(), + updater: TodoRepositoryWidgetSnapshotUpdaterSpy(), + eventBus: TodoRepositoryMutationEventBusSpy() + ) +} + +func makeTodoRepositoryTodo() -> Todo { + Todo( + id: "todo-1", + isPinned: false, + isCompleted: false, + isChecked: false, + number: 1, + title: "Todo", + content: "내용", + createdAt: .distantPast, + updatedAt: .distantPast, + completedAt: nil, + deletedAt: nil, + dueDate: nil, + tags: [], + category: .system(.feature) + ) +} + +func makeTodoRepositoryResponse() -> TodoResponse { + TodoResponse( + id: "todo-1", + isPinned: false, + isCompleted: false, + isChecked: false, + number: 1, + title: "Todo", + content: "내용", + createdAt: .distantPast, + updatedAt: .distantPast, + completedAt: nil, + deletedAt: nil, + dueDate: nil, + tags: [], + category: .raw("feature") + ) +} diff --git a/Application/Data/Tests/Repository/TodoRepositoryImplTests.swift b/Application/Data/Tests/Repository/TodoRepositoryImplTests.swift new file mode 100644 index 00000000..38ccb190 --- /dev/null +++ b/Application/Data/Tests/Repository/TodoRepositoryImplTests.swift @@ -0,0 +1,46 @@ +// +// TodoRepositoryImplTests.swift +// DataTests +// +// Created by opfic on 8/28/26. +// + +import Foundation +import Testing +import Core +import Domain +@testable import Data + +struct TodoRepositoryImplTests { + @Test("Todo 조회는 Query service에만 전달한다") + func Todo_조회는_Query_service에만_전달한다() async throws { + let queryService = TodoRepositoryQueryServiceSpy() + let commandService = TodoRepositoryCommandServiceSpy() + let repository = makeTodoRepository( + queryService: queryService, + commandService: commandService + ) + + let page = try await repository.fetchTodos(.init(), cursor: nil) + + #expect(page.items.map(\.id) == ["todo-1"]) + #expect(await queryService.fetchTodoQueries() == [.init()]) + #expect(await commandService.upsertRequests().isEmpty) + } + + @Test("Todo 저장은 Command service에만 전달한다") + func Todo_저장은_Command_service에만_전달한다() async throws { + let queryService = TodoRepositoryQueryServiceSpy() + let commandService = TodoRepositoryCommandServiceSpy() + let repository = makeTodoRepository( + queryService: queryService, + commandService: commandService + ) + + try await repository.upsertTodo(makeTodoRepositoryTodo()) + + let request = try #require(await commandService.upsertRequests().first) + #expect(request.id == "todo-1") + #expect(await queryService.fetchTodoQueries().isEmpty) + } +} diff --git a/Application/Data/Tests/Repository/WidgetTodoSnapshotRepositoryImplTests.swift b/Application/Data/Tests/Repository/WidgetTodoSnapshotRepositoryImplTests.swift new file mode 100644 index 00000000..4d10b94f --- /dev/null +++ b/Application/Data/Tests/Repository/WidgetTodoSnapshotRepositoryImplTests.swift @@ -0,0 +1,31 @@ +// +// WidgetTodoSnapshotRepositoryImplTests.swift +// DataTests +// +// Created by opfic on 8/28/26. +// + +import Testing +import Core +@testable import Data + +struct WidgetTodoSnapshotRepositoryImplTests { + @Test("Widget 오늘 Todo 조회는 Query service에만 전달한다") + func Widget_오늘_Todo_조회는_Query_service에만_전달한다() async throws { + let queryService = TodoRepositoryQueryServiceSpy() + let repository = WidgetTodoSnapshotRepositoryImpl(queryService: queryService) + + let snapshots = try await repository.fetchTodayTodos( + dueDateFilter: .withDueDate, + sortTarget: .dueDate, + sortOrder: .latest, + pageSize: 10 + ) + + let query = try #require(await queryService.fetchTodoQueries().first) + #expect(snapshots.map(\.id) == ["todo-1"]) + #expect(query.completionFilter == .incomplete) + #expect(query.dueDateFilter == .withDueDate) + #expect(query.fetchAllPages) + } +} diff --git a/Application/Infra/Sources/InfraAssembler.swift b/Application/Infra/Sources/InfraAssembler.swift index 03bafc92..47e22b18 100644 --- a/Application/Infra/Sources/InfraAssembler.swift +++ b/Application/Infra/Sources/InfraAssembler.swift @@ -53,8 +53,12 @@ public final class InfraAssembler: Assembler { AuthServiceImpl() } - container.register(TodoService.self) { - TodoServiceImpl() + container.register(TodoQueryService.self) { + TodoQueryServiceImpl() + } + + container.register(TodoCommandService.self) { + TodoCommandServiceImpl() } container.register(DevelopmentGoalService.self) { diff --git a/Application/Infra/Sources/Mapper/TodoDocumentMapper.swift b/Application/Infra/Sources/Mapper/TodoDocumentMapper.swift new file mode 100644 index 00000000..bc04dcee --- /dev/null +++ b/Application/Infra/Sources/Mapper/TodoDocumentMapper.swift @@ -0,0 +1,87 @@ +// +// TodoDocumentMapper.swift +// Infra +// +// Created by opfic on 8/28/26. +// + +import FirebaseFirestore +import Data + +enum TodoDocumentFieldKey: String { + case id + case goalId + case isPinned + case isCompleted + case isChecked + case number + case title + case content + case createdAt + case updatedAt + case completedAt + case deletedAt + case dueDate + case tags + case category +} + +enum TodoDocumentMapper { + static func makeDocumentData( + from request: TodoRequest, + encoder: Firestore.Encoder = .init() + ) throws -> [String: Any] { + var data = try encoder.encode(request) + data.removeValue(forKey: TodoDocumentFieldKey.id.rawValue) + if request.completedAt == nil { + data[TodoDocumentFieldKey.completedAt.rawValue] = NSNull() + } + if request.deletedAt == nil { + data[TodoDocumentFieldKey.deletedAt.rawValue] = NSNull() + } + if request.dueDate == nil { + data[TodoDocumentFieldKey.dueDate.rawValue] = NSNull() + } + if request.goalId == nil { + data[TodoDocumentFieldKey.goalId.rawValue] = FieldValue.delete() + } + return data + } + + static func makeResponse(_ document: QueryDocumentSnapshot) -> TodoResponse? { + makeResponse(documentID: document.documentID, data: document.data()) + } + + static func makeResponse(documentID: String, data: [String: Any]) -> TodoResponse? { + guard + let number = data[TodoDocumentFieldKey.number.rawValue] as? Int, + let title = data[TodoDocumentFieldKey.title.rawValue] as? String, + let createdAt = data[TodoDocumentFieldKey.createdAt.rawValue] as? Timestamp, + let updatedAt = data[TodoDocumentFieldKey.updatedAt.rawValue] as? Timestamp, + let category = data[TodoDocumentFieldKey.category.rawValue] as? String + else { + return nil + } + + let completedAt = (data[TodoDocumentFieldKey.completedAt.rawValue] as? Timestamp)?.dateValue() + let deletedAt = (data[TodoDocumentFieldKey.deletedAt.rawValue] as? Timestamp)?.dateValue() + let dueDate = (data[TodoDocumentFieldKey.dueDate.rawValue] as? Timestamp)?.dateValue() + return TodoResponse( + id: documentID, + isPinned: data[TodoDocumentFieldKey.isPinned.rawValue] as? Bool ?? false, + isCompleted: data[TodoDocumentFieldKey.isCompleted.rawValue] as? Bool ?? (completedAt != nil), + isChecked: data[TodoDocumentFieldKey.isChecked.rawValue] as? Bool ?? false, + number: number, + title: title, + content: data[TodoDocumentFieldKey.content.rawValue] as? String ?? "", + createdAt: createdAt.dateValue(), + updatedAt: updatedAt.dateValue(), + completedAt: completedAt, + deletedAt: deletedAt, + dueDate: dueDate, + tags: data[TodoDocumentFieldKey.tags.rawValue] as? [String] ?? [], + category: .raw(category), + goalId: data[TodoDocumentFieldKey.goalId.rawValue] as? String + ) + } +} diff --git a/Application/Infra/Sources/Mapper/TodoQueryCursorMapper.swift b/Application/Infra/Sources/Mapper/TodoQueryCursorMapper.swift new file mode 100644 index 00000000..7942ea83 --- /dev/null +++ b/Application/Infra/Sources/Mapper/TodoQueryCursorMapper.swift @@ -0,0 +1,83 @@ +// +// TodoQueryCursorMapper.swift +// Infra +// +// Created by opfic on 8/28/26. +// + +import FirebaseFirestore +import Core +import Data + +enum TodoQueryCursorMapper { + static func fieldName(for target: TodoQuery.SortTarget) -> String { + switch target { + case .createdAt: + "createdAt" + case .completedAt: + "completedAt" + case .deletedAt: + "deletedAt" + case .updatedAt: + "updatedAt" + case .dueDate: + "dueDate" + } + } + + static func isDescending(_ order: TodoQuery.SortOrder) -> Bool { + order == .latest + } + + static func isCompletedValue(for filter: TodoQuery.CompletionFilter) -> Bool? { + switch filter { + case .all: + nil + case .incomplete: + false + case .completed: + true + } + } + + static func makeValues(query: TodoQuery, cursor: TodoCursorDTO) -> [Any]? { + let primaryValue: Any = cursor.primarySortDate.map { Timestamp(date: $0) } ?? NSNull() + switch query.sortTarget { + case .dueDate: + guard let secondaryDate = cursor.secondarySortDate else { return nil } + return [primaryValue, Timestamp(date: secondaryDate), cursor.documentID] + case .createdAt, .completedAt, .deletedAt, .updatedAt: + return [primaryValue, cursor.documentID] + } + } + + static func makeCursor(document: QueryDocumentSnapshot, query: TodoQuery) -> TodoCursorDTO? { + let data = document.data() + let fieldName = fieldName(for: query.sortTarget) + let primarySortDate: Date? + if let timestamp = data[fieldName] as? Timestamp { + primarySortDate = timestamp.dateValue() + } else if data[fieldName] is NSNull { + primarySortDate = nil + } else { + return nil + } + + let secondarySortDate: Date? + switch query.sortTarget { + case .dueDate: + guard let updatedAt = data[TodoDocumentFieldKey.updatedAt.rawValue] as? Timestamp else { + return nil + } + secondarySortDate = updatedAt.dateValue() + case .createdAt, .completedAt, .deletedAt, .updatedAt: + secondarySortDate = nil + } + + return TodoCursorDTO( + primarySortDate: primarySortDate, + secondarySortDate: secondarySortDate, + documentID: document.documentID + ) + } +} diff --git a/Application/Infra/Sources/Service/TodoCommandServiceImpl.swift b/Application/Infra/Sources/Service/TodoCommandServiceImpl.swift new file mode 100644 index 00000000..d07a639a --- /dev/null +++ b/Application/Infra/Sources/Service/TodoCommandServiceImpl.swift @@ -0,0 +1,138 @@ +// +// TodoCommandServiceImpl.swift +// Infra +// +// Created by opfic on 8/28/26. +// + +import FirebaseAuth +import FirebaseFirestore +import Core +import Data + +final class TodoCommandServiceImpl: TodoCommandService { + private enum CrashlyticsError { + static let domain = "DevLogInfra.TodoServiceImpl" + + enum Code: Int { + case upsertTodo = 2 + case deleteTodo = 3 + case undoDeleteTodo = 4 + } + } + + private enum CounterFieldKey: String { + case nextNumber + case updatedAt + } + + private let store = FirebaseConfiguration.firestore + private let encoder = Firestore.Encoder() + private let logger = Logger(category: "TodoServiceImpl") + + func upsertTodo(request: TodoRequest) async throws { + guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } + + logger.info("Upserting todo") + do { + let reference = store.collection(FirestorePath.todos(uid)).document(request.id) + let data = try TodoDocumentMapper.makeDocumentData(from: request, encoder: encoder) + try await upsertTodoWithNumberOnCreate( + data, + for: reference, + counterReference: store.document(FirestorePath.counter(uid, document: .todo)) + ) + logger.info("Successfully upserted todo") + } catch { + logger.error("Failed to upsert todo", error: error) + record(error, code: .upsertTodo) + throw error + } + } + + func deleteTodo(todoId: String) async throws { + guard Auth.auth().currentUser?.uid != nil else { throw DataLayerError.notAuthenticated } + + logger.info("Requesting todo deletion") + do { + try await FunctionAPIClient.shared.send(.requestTodoDeletion(todoId)) + logger.info("Successfully requested todo deletion") + } catch { + logger.error("Failed to request todo deletion", error: error) + record(error, code: .deleteTodo) + throw error + } + } + + func undoDeleteTodo(todoId: String) async throws { + guard Auth.auth().currentUser?.uid != nil else { throw DataLayerError.notAuthenticated } + + logger.info("Undoing todo deletion") + do { + try await FunctionAPIClient.shared.send(.undoTodoDeletion(todoId)) + logger.info("Successfully undone todo deletion") + } catch { + logger.error("Failed to undo todo deletion", error: error) + record(error, code: .undoDeleteTodo) + throw error + } + } + + private func upsertTodoWithNumberOnCreate( + _ data: [String: Any], + for todoReference: DocumentReference, + counterReference: DocumentReference + ) async throws { + _ = try await store.runTransaction { transaction, errorPointer in + do { + let todoSnapshot = try transaction.getDocument(todoReference) + var todoData = data + + if !todoSnapshot.exists { + let counterSnapshot = try transaction.getDocument(counterReference) + let nextNumber: Int + if let storedNumber = counterSnapshot.data()?[CounterFieldKey.nextNumber.rawValue] as? Int { + nextNumber = storedNumber + } else if counterSnapshot.exists { + errorPointer?.pointee = NSError( + domain: "TodoServiceImpl", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Todo counter is invalid."] + ) + return nil + } else { + nextNumber = 1 + } + + todoData[TodoDocumentFieldKey.number.rawValue] = nextNumber + transaction.setData( + [ + CounterFieldKey.nextNumber.rawValue: nextNumber + 1, + CounterFieldKey.updatedAt.rawValue: FieldValue.serverTimestamp() + ], + forDocument: counterReference, + merge: true + ) + } + + transaction.setData(todoData, forDocument: todoReference, merge: true) + return nil + } catch let error as NSError { + errorPointer?.pointee = error + return nil + } + } + } + + private static func record(_ error: Error, code: CrashlyticsError.Code) { + FirebaseCrashlyticsHelper.record( + error, + domain: "\(CrashlyticsError.domain).\(code)", + code: code.rawValue + ) + } + + private func record(_ error: Error, code: CrashlyticsError.Code) { + Self.record(error, code: code) + } +} diff --git a/Application/Infra/Sources/Service/TodoQueryServiceImpl.swift b/Application/Infra/Sources/Service/TodoQueryServiceImpl.swift new file mode 100644 index 00000000..38ac42fd --- /dev/null +++ b/Application/Infra/Sources/Service/TodoQueryServiceImpl.swift @@ -0,0 +1,281 @@ +// +// TodoQueryServiceImpl.swift +// Infra +// +// Created by opfic on 8/28/26. +// + +import FirebaseAuth +import FirebaseFirestore +import Core +import Data + +final class TodoQueryServiceImpl: TodoQueryService { + private enum CrashlyticsError { + static let domain = "DevLogInfra.TodoServiceImpl" + + enum Code: Int { + case fetchTodos = 1 + case fetchTodo = 5 + case fetchReferences = 6 + } + } + + private let store = FirebaseConfiguration.firestore + private let logger = Logger(category: "TodoServiceImpl") + + // swiftlint:disable function_body_length + func fetchTodos( + _ query: TodoQuery, + cursor: TodoCursorDTO? + ) async throws -> TodoPageResponse { + guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } + + let keyword = query.keyword?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + logger.info("Fetching todo page: \(makeLogMessage(query: query, keyword: keyword, cursor: cursor))") + + do { + var firestoreQuery = makeQuery(uid: uid, query: query) + firestoreQuery = makeFilteredQuery(firestoreQuery, queryOptions: query) + + if keyword.isEmpty { + if query.fetchAllPages { + return try await fetchAllPages( + firestoreQuery, + queryOptions: query, + cursor: cursor + ) + } + + if let cursor { + guard let values = TodoQueryCursorMapper.makeValues(query: query, cursor: cursor) else { + logger.error("Failed to build cursor values for todo fetch.") + return TodoPageResponse(items: [], nextCursor: nil) + } + firestoreQuery = firestoreQuery.start(after: values) + } + + let snapshot = try await firestoreQuery.limit(to: query.pageSize).getDocuments() + let items = snapshot.documents.compactMap(TodoDocumentMapper.makeResponse) + let nextCursor = snapshot.documents.last.flatMap { + TodoQueryCursorMapper.makeCursor(document: $0, query: query) + } + return TodoPageResponse(items: items, nextCursor: nextCursor) + } + + let snapshot = try await firestoreQuery.getDocuments() + let todos = snapshot.documents.compactMap(TodoDocumentMapper.makeResponse) + let numberKeyword = TodoSearchMatching.normalizedNumberKeyword(from: keyword) ?? keyword + let items = todos.filter { + TodoSearchMatching.matches($0, keyword: keyword, numberKeyword: numberKeyword) + } + return TodoPageResponse(items: items, nextCursor: nil) + } catch { + logger.error("Failed to fetch todos", error: error) + record(error, code: .fetchTodos) + throw error + } + } + // swiftlint:enable function_body_length + + func fetchTodo(todoId: String) async throws -> TodoResponse { + guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } + + logger.info("Fetching todo") + do { + let snapshot = try await store.collection(FirestorePath.todos(uid)) + .whereField(FieldPath.documentID(), isEqualTo: todoId) + .whereField(TodoDocumentFieldKey.deletedAt.rawValue, isEqualTo: NSNull()) + .limit(to: 1) + .getDocuments() + guard + let document = snapshot.documents.first, + let response = TodoDocumentMapper.makeResponse(document) + else { + throw FirestoreError.dataNotFound("Todo") + } + logger.info("Successfully fetched todo") + return response + } catch { + logger.error("Failed to fetch todo", error: error) + record(error, code: .fetchTodo) + throw error + } + } + + func fetchReferences(_ numbers: [Int]) async throws -> [Int: TodoReferenceResponse] { + guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } + + let uniqueNumbers = Array(Set(numbers)).sorted() + guard !uniqueNumbers.isEmpty else { return [:] } + + do { + let collection = store.collection(FirestorePath.todos(uid)) + let snapshots = try await withThrowingTaskGroup(of: [QueryDocumentSnapshot].self) { group in + for chunk in uniqueNumbers.chunked(maxCount: 10) { + group.addTask { + try await collection + .whereField(TodoDocumentFieldKey.number.rawValue, in: chunk) + .whereField(TodoDocumentFieldKey.deletedAt.rawValue, isEqualTo: NSNull()) + .getDocuments() + .documents + } + } + + var documents = [QueryDocumentSnapshot]() + for try await chunkDocuments in group { + documents.append(contentsOf: chunkDocuments) + } + return documents + } + + return snapshots.reduce(into: [Int: TodoReferenceResponse]()) { result, document in + let data = document.data() + guard + data[TodoDocumentFieldKey.deletedAt.rawValue] is NSNull, + let response = TodoDocumentMapper.makeResponse(document) + else { + return + } + result[response.number] = .init( + id: response.id, + number: response.number, + title: response.title, + category: response.category + ) + } + } catch { + logger.error("Failed to fetch todo references", error: error) + record(error, code: .fetchReferences) + throw error + } + } + + private func makeQuery(uid: String, query: TodoQuery) -> Query { + var collection: Query = store.collection(FirestorePath.todos(uid)) + if !query.includesDeleted { + collection = collection.whereField(TodoDocumentFieldKey.deletedAt.rawValue, isEqualTo: NSNull()) + } + + let fieldName = TodoQueryCursorMapper.fieldName(for: query.sortTarget) + switch query.sortTarget { + case .dueDate: + return collection + .order(by: fieldName, descending: TodoQueryCursorMapper.isDescending(query.sortOrder)) + .order(by: TodoDocumentFieldKey.updatedAt.rawValue, descending: true) + .order(by: FieldPath.documentID()) + case .createdAt, .completedAt, .deletedAt, .updatedAt: + return collection + .order(by: fieldName, descending: TodoQueryCursorMapper.isDescending(query.sortOrder)) + .order(by: FieldPath.documentID()) + } + } + + private func makeFilteredQuery(_ query: Query, queryOptions: TodoQuery) -> Query { + var query = query + if let categoryId = queryOptions.categoryId { + query = query.whereField(TodoDocumentFieldKey.category.rawValue, isEqualTo: categoryId) + } + if queryOptions.isPinned { + query = query.whereField(TodoDocumentFieldKey.isPinned.rawValue, isEqualTo: true) + } + if let isCompleted = TodoQueryCursorMapper.isCompletedValue(for: queryOptions.completionFilter) { + query = query.whereField(TodoDocumentFieldKey.isCompleted.rawValue, isEqualTo: isCompleted) + } + switch queryOptions.dueDateFilter { + case .all: + break + case .withDueDate: + query = query.whereField( + TodoDocumentFieldKey.dueDate.rawValue, + isGreaterThan: Timestamp(date: Date(timeIntervalSince1970: 0)) + ) + case .withoutDueDate: + query = query.whereField(TodoDocumentFieldKey.dueDate.rawValue, isEqualTo: NSNull()) + } + if let sortDateFrom = queryOptions.sortDateFrom { + query = query.whereField( + TodoQueryCursorMapper.fieldName(for: queryOptions.sortTarget), + isGreaterThanOrEqualTo: Timestamp(date: sortDateFrom) + ) + } + if let sortDateTo = queryOptions.sortDateTo { + query = query.whereField( + TodoQueryCursorMapper.fieldName(for: queryOptions.sortTarget), + isLessThan: Timestamp(date: sortDateTo) + ) + } + return query + } + + private func fetchAllPages( + _ query: Query, + queryOptions: TodoQuery, + cursor: TodoCursorDTO? + ) async throws -> TodoPageResponse { + var items = [TodoResponse]() + var pageCursor = cursor + + while true { + var pageQuery = query + if let pageCursor { + guard let values = TodoQueryCursorMapper.makeValues( + query: queryOptions, + cursor: pageCursor + ) else { + logger.error("Failed to build cursor values for paginated todo fetch.") + break + } + pageQuery = pageQuery.start(after: values) + } + + let snapshot = try await pageQuery.limit(to: queryOptions.pageSize).getDocuments() + items.append(contentsOf: snapshot.documents.compactMap(TodoDocumentMapper.makeResponse)) + guard snapshot.documents.count == queryOptions.pageSize else { break } + guard + let document = snapshot.documents.last, + let nextCursor = TodoQueryCursorMapper.makeCursor(document: document, query: queryOptions) + else { + break + } + pageCursor = nextCursor + } + + return TodoPageResponse(items: items, nextCursor: nil) + } + + private func makeLogMessage( + query: TodoQuery, + keyword: String, + cursor: TodoCursorDTO? + ) -> String { + let components: [String?] = [ + "sortTarget=\(TodoQueryCursorMapper.fieldName(for: query.sortTarget))", + "sortOrder=\(query.sortOrder == .latest ? "latest" : "oldest")", + query.keyword != nil ? "keywordLength=\(keyword.count)" : nil, + query.categoryId != nil ? "category=\(query.categoryId!)" : nil, + query.isPinned ? "pinned=true" : nil, + TodoQueryCursorMapper.isCompletedValue(for: query.completionFilter).map { "completed=\($0)" }, + query.dueDateFilter != .all ? "dueDateFilter=\(query.dueDateFilter)" : nil, + query.sortDateFrom != nil ? "sortDateFrom=\(query.sortDateFrom!)" : nil, + query.sortDateTo != nil ? "sortDateTo=\(query.sortDateTo!)" : nil, + query.includesDeleted ? "includesDeleted=true" : nil, + "pageSize=\(query.pageSize)", + query.fetchAllPages ? "fetchAllPages=true" : nil, + cursor != nil ? "cursor=\(cursor!)" : nil + ] + return components.compactMap { $0 }.joined(separator: ", ") + } + + private static func record(_ error: Error, code: CrashlyticsError.Code) { + FirebaseCrashlyticsHelper.record( + error, + domain: "\(CrashlyticsError.domain).\(code)", + code: code.rawValue + ) + } + + private func record(_ error: Error, code: CrashlyticsError.Code) { + Self.record(error, code: code) + } +} diff --git a/Application/Infra/Sources/Service/TodoSearchMatching.swift b/Application/Infra/Sources/Service/TodoSearchMatching.swift new file mode 100644 index 00000000..bcbe215e --- /dev/null +++ b/Application/Infra/Sources/Service/TodoSearchMatching.swift @@ -0,0 +1,34 @@ +// +// TodoSearchMatching.swift +// Infra +// +// Created by opfic on 8/28/26. +// + +import Data + +enum TodoSearchMatching { + static func matches( + _ todo: TodoResponse, + keyword: String, + numberKeyword: String? = nil + ) -> Bool { + let numberKeyword = numberKeyword ?? normalizedNumberKeyword(from: keyword) ?? keyword + if keyword.hasPrefix("#"), + 1 < keyword.count, + "#\(todo.number)".localizedCaseInsensitiveContains(numberKeyword) { + return true + } + return todo.title.localizedCaseInsensitiveContains(keyword) + || todo.content.localizedCaseInsensitiveContains(keyword) + || todo.tags.contains { $0.localizedCaseInsensitiveContains(keyword) } + } + + static func normalizedNumberKeyword(from keyword: String) -> String? { + guard keyword.hasPrefix("#") else { return nil } + let digits = keyword.dropFirst() + guard !digits.isEmpty, digits.allSatisfy(\.isNumber) else { return nil } + let normalizedDigits = digits.drop(while: { $0 == "0" }) + return "#\(normalizedDigits.isEmpty ? "0" : String(normalizedDigits))" + } +} diff --git a/Application/Infra/Sources/Service/TodoServiceImpl.swift b/Application/Infra/Sources/Service/TodoServiceImpl.swift deleted file mode 100644 index 7a72cdf3..00000000 --- a/Application/Infra/Sources/Service/TodoServiceImpl.swift +++ /dev/null @@ -1,624 +0,0 @@ -// -// TodoServiceImpl.swift -// Infra -// -// Created by opfic on 6/2/25. -// - -import FirebaseAuth -import FirebaseFirestore -import Core -import Data - -final class TodoServiceImpl: TodoService { - private enum CrashlyticsError { - static let domain = "DevLogInfra.TodoServiceImpl" - - enum Code: Int { - case fetchTodos = 1 - case upsertTodo - case deleteTodo - case undoDeleteTodo - case fetchTodo - case fetchReferences - } - } - - private let store = FirebaseConfiguration.firestore - private let encoder = Firestore.Encoder() - private let logger = Logger(category: "TodoServiceImpl") - - // swiftlint:disable function_body_length - func fetchTodos( - _ query: TodoQuery, - cursor: TodoCursorDTO? - ) async throws -> TodoPageResponse { - guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } - - let trimmedKeyword = query.keyword?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let logComponents: [String?] = [ - "sortTarget=\(query.sortTarget.fieldName)", - "sortOrder=\(query.sortOrder == .latest ? "latest" : "oldest")", - query.keyword != nil ? "keywordLength=\(trimmedKeyword.count)" : nil, - query.categoryId != nil ? "category=\(query.categoryId!)" : nil, - query.isPinned ? "pinned=true" : nil, - query.completionFilter.isCompletedValue != nil - ? "completed=\(query.completionFilter.isCompletedValue!)" - : nil, - query.dueDateFilter != .all ? "dueDateFilter=\(query.dueDateFilter)" : nil, - query.sortDateFrom != nil ? "sortDateFrom=\(query.sortDateFrom!)" : nil, - query.sortDateTo != nil ? "sortDateTo=\(query.sortDateTo!)" : nil, - query.includesDeleted ? "includesDeleted=true" : nil, - "pageSize=\(query.pageSize)", - query.fetchAllPages ? "fetchAllPages=true" : nil, - cursor != nil ? "cursor=\(cursor!)" : nil - ] - logger.info("Fetching todo page: \(logComponents.compactMap { $0 }.joined(separator: ", "))") - - do { - var firestoreQuery = makeQuery(uid: uid, query: query) - - if let categoryId = query.categoryId { - firestoreQuery = firestoreQuery.whereField( - TodoFieldKey.category.rawValue, - isEqualTo: categoryId - ) - } - - if query.isPinned { - firestoreQuery = firestoreQuery.whereField("isPinned", isEqualTo: true) - } - - if let isCompleted = query.completionFilter.isCompletedValue { - firestoreQuery = firestoreQuery.whereField("isCompleted", isEqualTo: isCompleted) - } - - switch query.dueDateFilter { - case .all: - break - case .withDueDate: - firestoreQuery = firestoreQuery.whereField( - "dueDate", - isGreaterThan: Timestamp(date: Date(timeIntervalSince1970: 0)) - ) - case .withoutDueDate: - firestoreQuery = firestoreQuery.whereField("dueDate", isEqualTo: NSNull()) - } - - if let sortDateFrom = query.sortDateFrom { - firestoreQuery = firestoreQuery.whereField( - query.sortTarget.fieldName, - isGreaterThanOrEqualTo: Timestamp(date: sortDateFrom) - ) - } - - if let sortDateTo = query.sortDateTo { - firestoreQuery = firestoreQuery.whereField( - query.sortTarget.fieldName, - isLessThan: Timestamp(date: sortDateTo) - ) - } - - if trimmedKeyword.isEmpty { - if query.fetchAllPages { - var allItems: [TodoResponse] = [] - var pageCursor = cursor - - while true { - var pageQuery = firestoreQuery - if let pageCursor { - guard let cursorValues = cursorValues( - for: query, - cursor: pageCursor - ) else { - logger.error("Failed to build cursor values for paginated todo fetch.") - break - } - pageQuery = pageQuery.start(after: cursorValues) - } - - pageQuery = pageQuery.limit(to: query.pageSize) - let snapshot = try await pageQuery.getDocuments() - allItems.append(contentsOf: snapshot.documents.compactMap { makeResponse(from: $0) }) - - guard snapshot.documents.count == query.pageSize else { - break - } - - guard let lastDocument = snapshot.documents.last, - let nextCursor = makeCursor( - from: lastDocument, - query: query - ) else { - break - } - - pageCursor = nextCursor - } - - return TodoPageResponse(items: allItems, nextCursor: nil) - } - - if let cursor { - guard let cursorValues = cursorValues(for: query, cursor: cursor) else { - logger.error("Failed to build cursor values for todo fetch.") - return TodoPageResponse(items: [], nextCursor: nil) - } - firestoreQuery = firestoreQuery.start(after: cursorValues) - } - - firestoreQuery = firestoreQuery.limit(to: query.pageSize) - let snapshot = try await firestoreQuery.getDocuments() - let items = snapshot.documents.compactMap { makeResponse(from: $0) } - let nextCursor = snapshot.documents.last.flatMap { - makeCursor(from: $0, query: query) - } - - return TodoPageResponse(items: items, nextCursor: nextCursor) - } - - let snapshot = try await firestoreQuery.getDocuments() - let todos = snapshot.documents.compactMap { makeResponse(from: $0) } - - let numberKeyword = TodoResponse.normalizedNumberKeyword(from: trimmedKeyword) ?? trimmedKeyword - let filtered = todos.filter { todo in - todo.matchesSearchKeyword(trimmedKeyword, numberKeyword: numberKeyword) - } - - return TodoPageResponse(items: filtered, nextCursor: nil) - } catch { - logger.error("Failed to fetch todos", error: error) - record(error, code: .fetchTodos) - throw error - } - } - // swiftlint:enable function_body_length - - func upsertTodo(request: TodoRequest) async throws { - guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } - - logger.info("Upserting todo") - - do { - let collection = store.collection(FirestorePath.todos(uid)) - let docRef = collection.document(request.id) - let data = try Self.makeDocumentData(from: request, encoder: encoder) - try await upsertTodoWithNumberOnCreate( - data, - for: docRef, - counterRef: store.document( - FirestorePath.counter(uid, document: .todo) - ) - ) - - logger.info("Successfully upserted todo") - } catch { - logger.error("Failed to upsert todo", error: error) - record(error, code: .upsertTodo) - throw error - } - } - - func deleteTodo(todoId: String) async throws { - guard Auth.auth().currentUser?.uid != nil else { throw DataLayerError.notAuthenticated } - - logger.info("Requesting todo deletion") - - do { - try await FunctionAPIClient.shared.send( - .requestTodoDeletion(todoId) - ) - - logger.info("Successfully requested todo deletion") - } catch { - logger.error("Failed to request todo deletion", error: error) - record(error, code: .deleteTodo) - throw error - } - } - - func undoDeleteTodo(todoId: String) async throws { - guard Auth.auth().currentUser?.uid != nil else { throw DataLayerError.notAuthenticated } - - logger.info("Undoing todo deletion") - - do { - try await FunctionAPIClient.shared.send( - .undoTodoDeletion(todoId) - ) - - logger.info("Successfully undone todo deletion") - } catch { - logger.error("Failed to undo todo deletion", error: error) - record(error, code: .undoDeleteTodo) - throw error - } - } - - func fetchTodo(todoId: String) async throws -> TodoResponse { - guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } - - logger.info("Fetching todo") - - do { - let snapshot = try await store.collection(FirestorePath.todos(uid)) - .whereField(FieldPath.documentID(), isEqualTo: todoId) - .whereField(TodoFieldKey.deletedAt.rawValue, isEqualTo: NSNull()) - .limit(to: 1) - .getDocuments() - guard let document = snapshot.documents.first, let todo = makeResponse(from: document) else { - throw FirestoreError.dataNotFound("Todo") - } - - logger.info("Successfully fetched todo") - return todo - } catch { - logger.error("Failed to fetch todo", error: error) - record(error, code: .fetchTodo) - throw error - } - } - - func fetchReferences(_ numbers: [Int]) async throws -> [Int: TodoReferenceResponse] { - guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } - - let uniqueNumbers = Array(Set(numbers)).sorted() - if uniqueNumbers.isEmpty { return [:] } - - do { - let collection = store.collection(FirestorePath.todos(uid)) - let snapshots = try await withThrowingTaskGroup(of: [QueryDocumentSnapshot].self) { group in - for chunk in uniqueNumbers.chunked(maxCount: 10) { - group.addTask { - let snapshot = try await collection - .whereField(TodoFieldKey.number.rawValue, in: chunk) - .whereField(TodoFieldKey.deletedAt.rawValue, isEqualTo: NSNull()) - .getDocuments() - return snapshot.documents - } - } - - var documents = [QueryDocumentSnapshot]() - for try await chunkDocuments in group { - documents.append(contentsOf: chunkDocuments) - } - return documents - } - - return snapshots.reduce(into: [Int: TodoReferenceResponse]()) { partialResult, document in - let data = document.data() - guard - data[TodoFieldKey.deletedAt.rawValue] is NSNull, - let response = makeResponse(from: document) - else { - return - } - - partialResult[response.number] = TodoReferenceResponse( - id: response.id, - number: response.number, - title: response.title, - category: response.category - ) - } - } catch { - logger.error("Failed to fetch todo references", error: error) - record(error, code: .fetchReferences) - throw error - } - } -} - -extension TodoResponse { - func matchesSearchKeyword(_ keyword: String, numberKeyword: String? = nil) -> Bool { - let resolvedNumberKeyword = numberKeyword ?? Self.normalizedNumberKeyword(from: keyword) ?? keyword - - if keyword.hasPrefix("#"), - 1 < keyword.count, - "#\(number)".localizedCaseInsensitiveContains(resolvedNumberKeyword) { - return true - } - - return title.localizedCaseInsensitiveContains(keyword) - || content.localizedCaseInsensitiveContains(keyword) - || tags.contains { $0.localizedCaseInsensitiveContains(keyword) } - } - - static func normalizedNumberKeyword(from keyword: String) -> String? { - guard keyword.hasPrefix("#") else { - return nil - } - - let digits = keyword.dropFirst() - guard !digits.isEmpty, digits.allSatisfy(\.isNumber) else { - return nil - } - - let normalizedDigits = digits.drop(while: { $0 == "0" }) - let numberText = normalizedDigits.isEmpty ? "0" : String(normalizedDigits) - - return "#\(numberText)" - } -} - -private extension TodoServiceImpl { - private static func record(_ error: Error, code: CrashlyticsError.Code) { - FirebaseCrashlyticsHelper.record( - error, - domain: "\(CrashlyticsError.domain).\(code)", - code: code.rawValue - ) - } - - private func record(_ error: Error, code: CrashlyticsError.Code) { - Self.record(error, code: code) - } - - func upsertTodoWithNumberOnCreate( - _ data: [String: Any], - for todoRef: DocumentReference, - counterRef: DocumentReference - ) async throws { - _ = try await store.runTransaction { transaction, errorPointer in - let todoSnapshot: DocumentSnapshot - - do { - todoSnapshot = try transaction.getDocument(todoRef) - } catch let error as NSError { - errorPointer?.pointee = error - return nil - } - - var todoData = data - - if !todoSnapshot.exists { - let counterSnapshot: DocumentSnapshot - - do { - counterSnapshot = try transaction.getDocument(counterRef) - } catch let error as NSError { - errorPointer?.pointee = error - return nil - } - - let nextNumberValue = counterSnapshot.data()?[CounterFieldKey.nextNumber.rawValue] - let nextNumber: Int - - if let storedNextNumber = nextNumberValue as? Int { - nextNumber = storedNextNumber - } else if counterSnapshot.exists { - errorPointer?.pointee = NSError( - domain: "TodoServiceImpl", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Todo counter is invalid."] - ) - return nil - } else { - nextNumber = 1 - } - - todoData[TodoFieldKey.number.rawValue] = nextNumber - transaction.setData( - [ - CounterFieldKey.nextNumber.rawValue: nextNumber + 1, - CounterFieldKey.updatedAt.rawValue: FieldValue.serverTimestamp() - ], - forDocument: counterRef, - merge: true - ) - } - - transaction.setData(todoData, forDocument: todoRef, merge: true) - return nil - } - } - - func makeQuery(uid: String, query: TodoQuery) -> Query { - var collection: Query = store.collection(FirestorePath.todos(uid)) - - if !query.includesDeleted { - collection = collection.whereField(TodoFieldKey.deletedAt.rawValue, isEqualTo: NSNull()) - } - - switch query.sortTarget { - case .dueDate: - return collection - .order(by: query.sortTarget.fieldName, descending: query.sortOrder.isDescending) - .order(by: "updatedAt", descending: true) - .order(by: FieldPath.documentID()) - case .createdAt, .completedAt, .deletedAt, .updatedAt: - return collection - .order(by: query.sortTarget.fieldName, descending: query.sortOrder.isDescending) - .order(by: FieldPath.documentID()) - } - } - - func cursorValues( - for query: TodoQuery, - cursor: TodoCursorDTO - ) -> [Any]? { - let primaryValue: Any = cursor.primarySortDate.map { Timestamp(date: $0) } ?? NSNull() - - switch query.sortTarget { - case .dueDate: - guard let sortDate = cursor.secondarySortDate else { return nil } - return [ - primaryValue, - Timestamp(date: sortDate), - cursor.documentID - ] - case .createdAt, .completedAt, .deletedAt, .updatedAt: - return [ - primaryValue, - cursor.documentID - ] - } - } - - func makeCursor( - from document: QueryDocumentSnapshot, - query: TodoQuery - ) -> TodoCursorDTO? { - let data = document.data() - let orderField = query.sortTarget.fieldName - let primarySortDate: Date? - let secondarySortDate: Date? - - if let timestamp = data[orderField] as? Timestamp { - primarySortDate = timestamp.dateValue() - } else if data[orderField] is NSNull { - primarySortDate = nil - } else { - return nil - } - - switch query.sortTarget { - case .dueDate: - guard let updatedAt = data["updatedAt"] as? Timestamp else { - return nil - } - secondarySortDate = updatedAt.dateValue() - case .createdAt, .completedAt, .deletedAt, .updatedAt: - secondarySortDate = nil - } - - return TodoCursorDTO( - primarySortDate: primarySortDate, - secondarySortDate: secondarySortDate, - documentID: document.documentID - ) - } - - func makeResponse(from snapshot: QueryDocumentSnapshot) -> TodoResponse? { - Self.makeResponse(documentID: snapshot.documentID, data: snapshot.data()) - } - - func makeResponse(from snapshot: DocumentSnapshot) -> TodoResponse? { - guard let data = snapshot.data() else { - return nil - } - return Self.makeResponse(documentID: snapshot.documentID, data: data) - } - - enum TodoFieldKey: String { - case id - case goalId - case isPinned - case isCompleted - case isChecked - case number - case title - case content - case createdAt - case updatedAt - case completedAt - case deletedAt - case dueDate - case tags - case category - } - - enum CounterFieldKey: String { - case nextNumber - case updatedAt - } -} - -extension TodoServiceImpl { - static func makeDocumentData( - from request: TodoRequest, - encoder: Firestore.Encoder = .init() - ) throws -> [String: Any] { - var data = try encoder.encode(request) - data.removeValue(forKey: TodoFieldKey.id.rawValue) - if request.completedAt == nil { - data[TodoFieldKey.completedAt.rawValue] = NSNull() - } - if request.deletedAt == nil { - data[TodoFieldKey.deletedAt.rawValue] = NSNull() - } - if request.dueDate == nil { - data[TodoFieldKey.dueDate.rawValue] = NSNull() - } - if request.goalId == nil { - data[TodoFieldKey.goalId.rawValue] = FieldValue.delete() - } - return data - } - - static func makeResponse(documentID: String, data: [String: Any]) -> TodoResponse? { - guard - let number = data[TodoFieldKey.number.rawValue] as? Int, - let title = data[TodoFieldKey.title.rawValue] as? String, - let createdAtTimestamp = data[TodoFieldKey.createdAt.rawValue] as? Timestamp, - let updatedAtTimestamp = data[TodoFieldKey.updatedAt.rawValue] as? Timestamp, - let category = data[TodoFieldKey.category.rawValue] as? String else { - return nil - } - - let completedAt = (data[TodoFieldKey.completedAt.rawValue] as? Timestamp)?.dateValue() - let deletedAt = (data[TodoFieldKey.deletedAt.rawValue] as? Timestamp)?.dateValue() - let dueDate = (data[TodoFieldKey.dueDate.rawValue] as? Timestamp)?.dateValue() - - let isPinned = data[TodoFieldKey.isPinned.rawValue] as? Bool ?? false - let isCompleted = data[TodoFieldKey.isCompleted.rawValue] as? Bool ?? (completedAt != nil) - let isChecked = data[TodoFieldKey.isChecked.rawValue] as? Bool ?? false - let content = data[TodoFieldKey.content.rawValue] as? String ?? "" - let tags = data[TodoFieldKey.tags.rawValue] as? [String] ?? [] - let goalId = data[TodoFieldKey.goalId.rawValue] as? String - - return TodoResponse( - id: documentID, - isPinned: isPinned, - isCompleted: isCompleted, - isChecked: isChecked, - number: number, - title: title, - content: content, - createdAt: createdAtTimestamp.dateValue(), - updatedAt: updatedAtTimestamp.dateValue(), - completedAt: completedAt, - deletedAt: deletedAt, - dueDate: dueDate, - tags: tags, - category: .raw(category), - goalId: goalId - ) - } -} - -private extension TodoQuery.SortTarget { - var fieldName: String { - switch self { - case .createdAt: - return "createdAt" - case .completedAt: - return "completedAt" - case .deletedAt: - return "deletedAt" - case .updatedAt: - return "updatedAt" - case .dueDate: - return "dueDate" - } - } -} - -private extension TodoQuery.SortOrder { - var isDescending: Bool { - self == .latest - } -} - -private extension TodoQuery.CompletionFilter { - var isCompletedValue: Bool? { - switch self { - case .all: - return nil - case .incomplete: - return false - case .completed: - return true - } - } -} diff --git a/Application/Infra/Tests/Service/TodoGoalIDStorageTests.swift b/Application/Infra/Tests/Mapper/TodoDocumentMapperTests.swift similarity index 79% rename from Application/Infra/Tests/Service/TodoGoalIDStorageTests.swift rename to Application/Infra/Tests/Mapper/TodoDocumentMapperTests.swift index a11dc5de..b2151c86 100644 --- a/Application/Infra/Tests/Service/TodoGoalIDStorageTests.swift +++ b/Application/Infra/Tests/Mapper/TodoDocumentMapperTests.swift @@ -1,5 +1,5 @@ // -// TodoGoalIDStorageTests.swift +// TodoDocumentMapperTests.swift // InfraTests // // Created by opfic on 8/28/26. @@ -11,17 +11,17 @@ import FirebaseFirestore import Data @testable import Infra -struct TodoGoalIDStorageTests { +struct TodoDocumentMapperTests { @Test("목표 연결 해제 요청은 goalId 삭제 값을 만든다") func 목표_연결_해제_요청은_goalId_삭제_값을_만든다() throws { - let data = try TodoServiceImpl.makeDocumentData(from: makeRequest(goalId: nil)) + let data = try TodoDocumentMapper.makeDocumentData(from: makeRequest(goalId: nil)) #expect(data["goalId"] is FieldValue) } @Test("목표 연결 요청은 goalId 값을 저장한다") func 목표_연결_요청은_goalId_값을_저장한다() throws { - let data = try TodoServiceImpl.makeDocumentData(from: makeRequest(goalId: "goal-1")) + let data = try TodoDocumentMapper.makeDocumentData(from: makeRequest(goalId: "goal-1")) #expect(data["goalId"] as? String == "goal-1") } @@ -29,10 +29,10 @@ struct TodoGoalIDStorageTests { @Test("goalId 누락과 null은 연결되지 않은 Todo로 읽는다") func goalId_누락과_null은_연결되지_않은_Todo로_읽는다() throws { let missing = try #require( - TodoServiceImpl.makeResponse(documentID: "todo-1", data: makeDocumentData(goalId: nil)) + TodoDocumentMapper.makeResponse(documentID: "todo-1", data: makeDocumentData(goalId: nil)) ) let null = try #require( - TodoServiceImpl.makeResponse(documentID: "todo-2", data: makeDocumentData(goalId: NSNull())) + TodoDocumentMapper.makeResponse(documentID: "todo-2", data: makeDocumentData(goalId: NSNull())) ) #expect(missing.goalId == nil) diff --git a/Application/Infra/Tests/Mapper/TodoQueryCursorMapperTests.swift b/Application/Infra/Tests/Mapper/TodoQueryCursorMapperTests.swift new file mode 100644 index 00000000..fdf5398f --- /dev/null +++ b/Application/Infra/Tests/Mapper/TodoQueryCursorMapperTests.swift @@ -0,0 +1,44 @@ +// +// TodoQueryCursorMapperTests.swift +// InfraTests +// +// Created by opfic on 8/28/26. +// + +import Foundation +import Testing +import FirebaseFirestore +import Core +import Data +@testable import Infra + +struct TodoQueryCursorMapperTests { + @Test("마감일 cursor는 주 정렬 값과 수정일, 문서 ID를 순서대로 만든다") + func 마감일_cursor는_주_정렬_값과_수정일_문서_ID를_순서대로_만든다() throws { + let primaryDate = Date(timeIntervalSince1970: 10) + let secondaryDate = Date(timeIntervalSince1970: 20) + let query = TodoQuery(sortTarget: .dueDate) + let cursor = TodoCursorDTO( + primarySortDate: primaryDate, + secondarySortDate: secondaryDate, + documentID: "todo-1" + ) + + let values = try #require(TodoQueryCursorMapper.makeValues(query: query, cursor: cursor)) + + #expect(values.count == 3) + #expect((values[0] as? Timestamp)?.dateValue() == primaryDate) + #expect((values[1] as? Timestamp)?.dateValue() == secondaryDate) + #expect(values[2] as? String == "todo-1") + } + + @Test("정렬과 완료 필터는 기존 Firestore 필드 규칙으로 변환한다") + func 정렬과_완료_필터는_기존_Firestore_필드_규칙으로_변환한다() { + #expect(TodoQueryCursorMapper.fieldName(for: .updatedAt) == "updatedAt") + #expect(TodoQueryCursorMapper.isDescending(.latest)) + #expect(!TodoQueryCursorMapper.isDescending(.oldest)) + #expect(TodoQueryCursorMapper.isCompletedValue(for: .all) == nil) + #expect(TodoQueryCursorMapper.isCompletedValue(for: .incomplete) == false) + #expect(TodoQueryCursorMapper.isCompletedValue(for: .completed) == true) + } +} diff --git a/Application/Infra/Tests/Service/TodoSearchMatchingTests.swift b/Application/Infra/Tests/Service/TodoSearchMatchingTests.swift index afcadc86..d304cffe 100644 --- a/Application/Infra/Tests/Service/TodoSearchMatchingTests.swift +++ b/Application/Infra/Tests/Service/TodoSearchMatchingTests.swift @@ -14,19 +14,19 @@ struct TodoSearchMatchingTests { @Test("#숫자 검색어는 Todo 번호를 문자열 기반으로 부분 검색한다") func 해시_숫자_검색어는_Todo_번호를_문자열_기반으로_부분_검색한다() { let todo = makeTodo(number: 123) - let numberKeyword = TodoResponse.normalizedNumberKeyword(from: "#0001") + let numberKeyword = TodoSearchMatching.normalizedNumberKeyword(from: "#0001") - #expect(todo.matchesSearchKeyword("#1")) - #expect(todo.matchesSearchKeyword("#12")) - #expect(todo.matchesSearchKeyword("#0001")) - #expect(todo.matchesSearchKeyword("#0001", numberKeyword: numberKeyword)) + #expect(TodoSearchMatching.matches(todo, keyword: "#1")) + #expect(TodoSearchMatching.matches(todo, keyword: "#12")) + #expect(TodoSearchMatching.matches(todo, keyword: "#0001")) + #expect(TodoSearchMatching.matches(todo, keyword: "#0001", numberKeyword: numberKeyword)) } @Test("# 단독 검색어는 Todo 번호로 매칭하지 않는다") func 해시_단독_검색어는_Todo_번호로_매칭하지_않는다() { let todo = makeTodo(number: 123) - #expect(!todo.matchesSearchKeyword("#")) + #expect(!TodoSearchMatching.matches(todo, keyword: "#")) } private func makeTodo(number: Int) -> TodoResponse { From 71a79456e4347040727acfa13764160e2218f6cc Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 28 Aug 2026 15:01:56 +0900 Subject: [PATCH 11/14] =?UTF-8?q?fix:=20Infra=20Test=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Infra/Tests/Mapper/TodoDocumentMapperTests.swift | 8 ++++---- .../Tests/Service/DevelopmentGoalServiceImplTests.swift | 9 +++++---- .../Service/DevelopmentRecordServiceImplTests.swift | 6 +++--- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/Application/Infra/Tests/Mapper/TodoDocumentMapperTests.swift b/Application/Infra/Tests/Mapper/TodoDocumentMapperTests.swift index b2151c86..062cb477 100644 --- a/Application/Infra/Tests/Mapper/TodoDocumentMapperTests.swift +++ b/Application/Infra/Tests/Mapper/TodoDocumentMapperTests.swift @@ -47,8 +47,8 @@ struct TodoDocumentMapperTests { isChecked: false, title: "Todo", content: "내용", - createdAt: .distantPast, - updatedAt: .distantPast, + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 0), completedAt: nil, deletedAt: nil, dueDate: nil, @@ -62,8 +62,8 @@ struct TodoDocumentMapperTests { var data: [String: Any] = [ "number": 1, "title": "Todo", - "createdAt": Timestamp(date: .distantPast), - "updatedAt": Timestamp(date: .distantPast), + "createdAt": Timestamp(date: Date(timeIntervalSince1970: 0)), + "updatedAt": Timestamp(date: Date(timeIntervalSince1970: 0)), "category": "feature" ] if let goalId { diff --git a/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift b/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift index 77147aad..6d86614d 100644 --- a/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift +++ b/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift @@ -55,11 +55,12 @@ struct DevelopmentGoalServiceImplTests { goalId: "goal-1", documentId: "record-1", data: [ - DevelopmentRecordFieldKey.createdAt.rawValue: Timestamp(date: .distantPast), + DevelopmentRecordFieldKey.createdAt.rawValue: Timestamp(date: Date(timeIntervalSince1970: 0)), DevelopmentRecordFieldKey.draft.rawValue: [ DevelopmentRecordDraftFieldKey.title.rawValue: "기록", DevelopmentRecordDraftFieldKey.markdownContent.rawValue: "본문", - DevelopmentRecordDraftFieldKey.updatedAt.rawValue: Timestamp(date: .distantPast) + DevelopmentRecordDraftFieldKey.updatedAt.rawValue: + Timestamp(date: Date(timeIntervalSince1970: 0)) ] ] ) @@ -75,8 +76,8 @@ struct DevelopmentGoalServiceImplTests { "title": "목표", "markdownDescription": "설명", "status": status, - "createdAt": Timestamp(date: .distantPast), - "updatedAt": Timestamp(date: .distantPast) + "createdAt": Timestamp(date: Date(timeIntervalSince1970: 0)), + "updatedAt": Timestamp(date: Date(timeIntervalSince1970: 0)) ] } } diff --git a/Application/Infra/Tests/Service/DevelopmentRecordServiceImplTests.swift b/Application/Infra/Tests/Service/DevelopmentRecordServiceImplTests.swift index 2cd8577d..60820dd0 100644 --- a/Application/Infra/Tests/Service/DevelopmentRecordServiceImplTests.swift +++ b/Application/Infra/Tests/Service/DevelopmentRecordServiceImplTests.swift @@ -143,7 +143,7 @@ struct DevelopmentRecordServiceImplTests { includesDraft: Bool = true ) -> [String: Any] { var data: [String: Any] = [ - DevelopmentRecordFieldKey.createdAt.rawValue: Timestamp(date: .distantPast) + DevelopmentRecordFieldKey.createdAt.rawValue: Timestamp(date: Date(timeIntervalSince1970: 0)) ] if let currentVersionId, let currentVersionNumber { data[DevelopmentRecordFieldKey.currentVersionId.rawValue] = currentVersionId @@ -153,7 +153,7 @@ struct DevelopmentRecordServiceImplTests { var draft: [String: Any] = [ DevelopmentRecordDraftFieldKey.title.rawValue: "기록", DevelopmentRecordDraftFieldKey.markdownContent.rawValue: "본문", - DevelopmentRecordDraftFieldKey.updatedAt.rawValue: Timestamp(date: .distantPast) + DevelopmentRecordDraftFieldKey.updatedAt.rawValue: Timestamp(date: Date(timeIntervalSince1970: 0)) ] if let draftBaseVersionId { draft[DevelopmentRecordDraftFieldKey.baseVersionId.rawValue] = draftBaseVersionId @@ -169,7 +169,7 @@ struct DevelopmentRecordServiceImplTests { DevelopmentRecordVersionFieldKey.title.rawValue: "기록", DevelopmentRecordVersionFieldKey.markdownContent.rawValue: "본문", DevelopmentRecordVersionFieldKey.changeKind.rawValue: "initial", - DevelopmentRecordVersionFieldKey.confirmedAt.rawValue: Timestamp(date: .distantPast) + DevelopmentRecordVersionFieldKey.confirmedAt.rawValue: Timestamp(date: Date(timeIntervalSince1970: 0)) ] } } From 10c23c3eb7eb37eb7bb291a9cb9e5fd141245485 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 28 Aug 2026 15:46:49 +0900 Subject: [PATCH 12/14] =?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=B7=A8=EC=86=8C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Service/DevelopmentGoalServiceImpl.swift | 9 +++++++-- .../DevelopmentGoalServiceImplTests.swift | 19 +++++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift b/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift index 73fe2909..4ad8d743 100644 --- a/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift +++ b/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift @@ -167,13 +167,18 @@ extension DevelopmentGoalServiceImpl { guard let currentStatus = recordData[DevelopmentGoalFieldKey.status.rawValue] as? String, (currentStatus == "inProgress" && request.status == "archived") || - (currentStatus == "archived" && request.status == "inProgress") else { + (currentStatus == "archived" && request.status == "inProgress") || + (currentStatus == "completed" && request.status == "inProgress") else { return nil } - return [ + var data: [String: Any] = [ DevelopmentGoalFieldKey.status.rawValue: request.status, DevelopmentGoalFieldKey.updatedAt.rawValue: FieldValue.serverTimestamp() ] + if currentStatus == "completed" { + data[DevelopmentGoalFieldKey.completedAt.rawValue] = FieldValue.delete() + } + return data } static func makeResponse( diff --git a/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift b/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift index 6d86614d..5af7c03c 100644 --- a/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift +++ b/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift @@ -33,19 +33,30 @@ struct DevelopmentGoalServiceImplTests { #expect(data?["completedAt"] == nil) } - @Test("완료 상태를 포함한 목표 상태 전환은 저장 데이터를 만들지 않는다") - func 완료_상태를_포함한_목표_상태_전환은_저장_데이터를_만들지_않는다() { + @Test("완료 요청과 완료 목표의 보관 전환은 저장 데이터를 만들지 않는다") + func 완료_요청과_완료_목표의_보관_전환은_저장_데이터를_만들지_않는다() { let requestedCompletion = DevelopmentGoalServiceImpl.makeTransitionData( recordData: makeData(), request: .init(status: "completed") ) - let existingCompletion = DevelopmentGoalServiceImpl.makeTransitionData( + let archivedCompletion = DevelopmentGoalServiceImpl.makeTransitionData( recordData: makeData(status: "completed"), request: .init(status: "archived") ) #expect(requestedCompletion == nil) - #expect(existingCompletion == nil) + #expect(archivedCompletion == nil) + } + + @Test("완료 목표는 진행 중으로 되돌리고 완료 시각을 삭제한다") + func 완료_목표는_진행_중으로_되돌리고_완료_시각을_삭제한다() { + let data = DevelopmentGoalServiceImpl.makeTransitionData( + recordData: makeData(status: "completed"), + request: .init(status: "inProgress") + ) + + #expect(data?["status"] as? String == "inProgress") + #expect(data?["completedAt"] is FieldValue) } @Test("개발 기록 문서는 경로 식별값을 응답에 복원한다") From fc883022174b287ed0a31412ceabf2bd381f5b12 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 28 Aug 2026 15:47:21 +0900 Subject: [PATCH 13/14] =?UTF-8?q?refactor:=20=EA=B0=9C=EB=B0=9C=20?= =?UTF-8?q?=EB=AA=A9=ED=91=9C=20=EC=83=81=ED=83=9C=20=EB=B3=80=ED=99=98=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Sources/DTO/DevelopmentGoalDTO.swift | 16 ++--- .../Mapper/DevelopmentGoalMapping.swift | 29 ++++---- .../Sources/Query/DevelopmentGoalQuery.swift | 14 ++++ .../DevelopmentGoalRepositoryImpl.swift | 2 +- .../Sources/Value/DevelopmentGoalStatus.swift | 12 ++++ .../Mapper/DevelopmentGoalMappingTests.swift | 16 ++--- .../DevelopmentGoalRepositoryImplTests.swift | 10 +-- .../Service/DevelopmentGoalServiceImpl.swift | 70 +++++++++++++++---- .../DevelopmentGoalServiceImplTests.swift | 37 ++++++---- 9 files changed, 137 insertions(+), 69 deletions(-) create mode 100644 Application/Data/Sources/Query/DevelopmentGoalQuery.swift create mode 100644 Application/Data/Sources/Value/DevelopmentGoalStatus.swift diff --git a/Application/Data/Sources/DTO/DevelopmentGoalDTO.swift b/Application/Data/Sources/DTO/DevelopmentGoalDTO.swift index bee4c57b..58fe2311 100644 --- a/Application/Data/Sources/DTO/DevelopmentGoalDTO.swift +++ b/Application/Data/Sources/DTO/DevelopmentGoalDTO.swift @@ -17,18 +17,10 @@ public struct DevelopmentGoalCreateRequest: Encodable { } } -public struct DevelopmentGoalQuery { - public let status: String? - - public init(status: String?) { - self.status = status - } -} - public struct DevelopmentGoalStatusRequest { - public let status: String + public let status: DevelopmentGoalStatus - public init(status: String) { + public init(status: DevelopmentGoalStatus) { self.status = status } } @@ -37,7 +29,7 @@ public struct DevelopmentGoalResponse { public let id: String public let title: String public let markdownDescription: String - public let status: String + public let status: DevelopmentGoalStatus public let createdAt: Date public let updatedAt: Date public let completedAt: Date? @@ -46,7 +38,7 @@ public struct DevelopmentGoalResponse { id: String, title: String, markdownDescription: String, - status: String, + status: DevelopmentGoalStatus, createdAt: Date, updatedAt: Date, completedAt: Date? diff --git a/Application/Data/Sources/Mapper/DevelopmentGoalMapping.swift b/Application/Data/Sources/Mapper/DevelopmentGoalMapping.swift index 0175df75..3b6d5356 100644 --- a/Application/Data/Sources/Mapper/DevelopmentGoalMapping.swift +++ b/Application/Data/Sources/Mapper/DevelopmentGoalMapping.swift @@ -9,7 +9,7 @@ import Domain public extension DevelopmentGoalQuery { static func fromDomain(_ query: DevelopmentGoal.Query) -> Self { - Self(status: query.status?.storageValue) + Self(status: query.status.map(DevelopmentGoalStatus.fromDomain)) } } @@ -19,7 +19,7 @@ public extension DevelopmentGoalResponse { id: id, title: title, description: markdownDescription, - status: try .fromStorageValue(status), + status: status.toDomain(), createdAt: createdAt, updatedAt: updatedAt, completedAt: completedAt @@ -36,28 +36,27 @@ public extension DevelopmentGoalCompletionResponse { } } -public extension DevelopmentGoal.Status { - var storageValue: String { - switch self { +public extension DevelopmentGoalStatus { + static func fromDomain(_ status: DevelopmentGoal.Status) -> Self { + switch status { case .inProgress: - "inProgress" + .inProgress case .completed: - "completed" + .completed case .archived: - "archived" + .archived } } - static func fromStorageValue(_ value: String) throws -> Self { - switch value { - case "inProgress": + func toDomain() -> DevelopmentGoal.Status { + switch self { + case .inProgress: .inProgress - case "completed": + case .completed: .completed - case "archived": + case .archived: .archived - default: - throw DataLayerError.invalidData("DevelopmentGoalResponse.status: \(value)") } } + } diff --git a/Application/Data/Sources/Query/DevelopmentGoalQuery.swift b/Application/Data/Sources/Query/DevelopmentGoalQuery.swift new file mode 100644 index 00000000..e7a7e5ec --- /dev/null +++ b/Application/Data/Sources/Query/DevelopmentGoalQuery.swift @@ -0,0 +1,14 @@ +// +// DevelopmentGoalQuery.swift +// Data +// +// Created by opfic on 8/28/26. +// + +public struct DevelopmentGoalQuery { + public let status: DevelopmentGoalStatus? + + public init(status: DevelopmentGoalStatus?) { + self.status = status + } +} diff --git a/Application/Data/Sources/Repository/DevelopmentGoalRepositoryImpl.swift b/Application/Data/Sources/Repository/DevelopmentGoalRepositoryImpl.swift index 3665d721..2f7ad3eb 100644 --- a/Application/Data/Sources/Repository/DevelopmentGoalRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/DevelopmentGoalRepositoryImpl.swift @@ -67,7 +67,7 @@ final class DevelopmentGoalRepositoryImpl: DevelopmentGoalRepository { } try await service.transitionGoalStatus( goalId: goalId, - request: .init(status: status.storageValue) + request: .init(status: .fromDomain(status)) ) } catch { throw error.toDomain() diff --git a/Application/Data/Sources/Value/DevelopmentGoalStatus.swift b/Application/Data/Sources/Value/DevelopmentGoalStatus.swift new file mode 100644 index 00000000..52484e1e --- /dev/null +++ b/Application/Data/Sources/Value/DevelopmentGoalStatus.swift @@ -0,0 +1,12 @@ +// +// DevelopmentGoalStatus.swift +// Data +// +// Created by opfic on 8/28/26. +// + +public enum DevelopmentGoalStatus: Hashable { + case inProgress + case completed + case archived +} diff --git a/Application/Data/Tests/Mapper/DevelopmentGoalMappingTests.swift b/Application/Data/Tests/Mapper/DevelopmentGoalMappingTests.swift index a63a1f1e..ba0a83e6 100644 --- a/Application/Data/Tests/Mapper/DevelopmentGoalMappingTests.swift +++ b/Application/Data/Tests/Mapper/DevelopmentGoalMappingTests.swift @@ -21,18 +21,16 @@ struct DevelopmentGoalMappingTests { #expect(goal.status == .inProgress) } - @Test("상태 조건은 저장 문자열로 변환한다") - func 상태_조건은_저장_문자열로_변환한다() { + @Test("상태 조건은 Data 상태 값으로 변환한다") + func 상태_조건은_Data_상태_값으로_변환한다() { let query = DevelopmentGoalQuery.fromDomain(.init(status: .completed)) - #expect(query.status == "completed") + #expect(query.status == .completed) } - @Test("알 수 없는 저장 상태는 유효하지 않은 데이터 오류를 만든다") - func 알_수_없는_저장_상태는_유효하지_않은_데이터_오류를_만든다() { - #expect(throws: DataLayerError.self) { - _ = try DevelopmentGoal.Status.fromStorageValue("unknown") - } + @Test("Data 상태는 Domain 상태로 변환한다") + func Data_상태는_Domain_상태로_변환한다() { + #expect(DevelopmentGoalStatus.archived.toDomain() == .archived) } } @@ -41,7 +39,7 @@ private func makeGoalResponse() -> DevelopmentGoalResponse { id: "goal-1", title: "목표", markdownDescription: "설명", - status: "inProgress", + status: .inProgress, createdAt: .distantPast, updatedAt: .distantPast, completedAt: nil diff --git a/Application/Data/Tests/Repository/DevelopmentGoalRepositoryImplTests.swift b/Application/Data/Tests/Repository/DevelopmentGoalRepositoryImplTests.swift index b0d56562..b39d8d88 100644 --- a/Application/Data/Tests/Repository/DevelopmentGoalRepositoryImplTests.swift +++ b/Application/Data/Tests/Repository/DevelopmentGoalRepositoryImplTests.swift @@ -40,8 +40,8 @@ struct DevelopmentGoalRepositoryImplTests { #expect(await service.completionSnapshotGoalIds() == ["goal-1"]) } - @Test("목표 상태 전환은 저장 상태 문자열을 서비스에 전달한다") - func 목표_상태_전환은_저장_상태_문자열을_서비스에_전달한다() async throws { + @Test("목표 상태 전환은 Data 상태를 서비스에 전달한다") + func 목표_상태_전환은_Data_상태를_서비스에_전달한다() async throws { let service = DevelopmentGoalServiceSpy() let repository = DevelopmentGoalRepositoryImpl(service: service) @@ -53,7 +53,7 @@ struct DevelopmentGoalRepositoryImplTests { let request = try #require(await service.transitionRequest()) #expect(request.goalId == "goal-1") - #expect(request.status == "archived") + #expect(request.status == .archived) } @Test("목표 완료 전환은 서비스에 전달하지 않는다") @@ -81,14 +81,14 @@ private actor DevelopmentGoalServiceSpy: DevelopmentGoalService { struct TransitionRequest { let goalId: String - let status: String + let status: DevelopmentGoalStatus } private let goal = DevelopmentGoalResponse( id: "goal-1", title: "목표", markdownDescription: "설명", - status: "inProgress", + status: .inProgress, createdAt: .distantPast, updatedAt: .distantPast, completedAt: nil diff --git a/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift b/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift index 4ad8d743..88faa7f7 100644 --- a/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift +++ b/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift @@ -37,7 +37,7 @@ final class DevelopmentGoalServiceImpl: DevelopmentGoalService { do { let reference = store.document(FirestorePath.developmentGoal(uid, goalId: goalId)) var data = try encoder.encode(request) - data[DevelopmentGoalFieldKey.status.rawValue] = "inProgress" + data[DevelopmentGoalFieldKey.status.rawValue] = DevelopmentGoalFirestoreStatus(.inProgress).rawValue data[DevelopmentGoalFieldKey.createdAt.rawValue] = FieldValue.serverTimestamp() data[DevelopmentGoalFieldKey.updatedAt.rawValue] = FieldValue.serverTimestamp() try await reference.setData(data) @@ -69,7 +69,7 @@ final class DevelopmentGoalServiceImpl: DevelopmentGoalService { if let status = query.status { reference = reference.whereField( DevelopmentGoalFieldKey.status.rawValue, - isEqualTo: status + isEqualTo: DevelopmentGoalFirestoreStatus(status).rawValue ) } let snapshot = try await reference @@ -77,7 +77,7 @@ final class DevelopmentGoalServiceImpl: DevelopmentGoalService { .order(by: FieldPath.documentID()) .getDocuments() return try snapshot.documents.map { document in - guard let response = Self.makeResponse( + guard let response = try Self.makeResponse( documentId: document.documentID, data: document.data() ) else { @@ -137,7 +137,7 @@ final class DevelopmentGoalServiceImpl: DevelopmentGoalService { let snapshot = try transaction.getDocument(reference) guard let recordData = snapshot.data(), - let data = Self.makeTransitionData( + let data = try Self.makeTransitionData( recordData: recordData, request: request ) else { @@ -163,19 +163,26 @@ extension DevelopmentGoalServiceImpl { static func makeTransitionData( recordData: [String: Any], request: DevelopmentGoalStatusRequest - ) -> [String: Any]? { + ) throws -> [String: Any]? { + guard let storageValue = recordData[DevelopmentGoalFieldKey.status.rawValue] as? String else { + return nil + } + let currentStatus = try DevelopmentGoalFirestoreStatus( + storageValue: storageValue + ).dataStatus guard - let currentStatus = recordData[DevelopmentGoalFieldKey.status.rawValue] as? String, - (currentStatus == "inProgress" && request.status == "archived") || - (currentStatus == "archived" && request.status == "inProgress") || - (currentStatus == "completed" && request.status == "inProgress") else { + (currentStatus == .inProgress && request.status == .archived) || + (currentStatus == .archived && request.status == .inProgress) || + (currentStatus == .completed && request.status == .inProgress) else { return nil } var data: [String: Any] = [ - DevelopmentGoalFieldKey.status.rawValue: request.status, + DevelopmentGoalFieldKey.status.rawValue: DevelopmentGoalFirestoreStatus( + request.status + ).rawValue, DevelopmentGoalFieldKey.updatedAt.rawValue: FieldValue.serverTimestamp() ] - if currentStatus == "completed" { + if currentStatus == .completed { data[DevelopmentGoalFieldKey.completedAt.rawValue] = FieldValue.delete() } return data @@ -184,7 +191,7 @@ extension DevelopmentGoalServiceImpl { static func makeResponse( documentId: String, data: [String: Any] - ) -> DevelopmentGoalResponse? { + ) throws -> DevelopmentGoalResponse? { guard let title = data[DevelopmentGoalFieldKey.title.rawValue] as? String, let markdownDescription = data[DevelopmentGoalFieldKey.markdownDescription.rawValue] as? String, @@ -197,7 +204,7 @@ extension DevelopmentGoalServiceImpl { id: documentId, title: title, markdownDescription: markdownDescription, - status: status, + status: try DevelopmentGoalFirestoreStatus(storageValue: status).dataStatus, createdAt: createdAt.dateValue(), updatedAt: updatedAt.dateValue(), completedAt: (data[DevelopmentGoalFieldKey.completedAt.rawValue] as? Timestamp)?.dateValue() @@ -231,7 +238,7 @@ private extension DevelopmentGoalServiceImpl { FirestorePath.developmentGoal(uid, goalId: goalId) ) .getDocument() - guard let data = snapshot.data(), let response = Self.makeResponse( + guard let data = snapshot.data(), let response = try Self.makeResponse( documentId: snapshot.documentID, data: data ) else { @@ -249,3 +256,38 @@ private enum DevelopmentGoalFieldKey: String { case updatedAt case completedAt } + +private enum DevelopmentGoalFirestoreStatus: String { + case inProgress + case completed + case archived + + init(_ status: DevelopmentGoalStatus) { + switch status { + case .inProgress: + self = .inProgress + case .completed: + self = .completed + case .archived: + self = .archived + } + } + + init(storageValue: String) throws { + guard let status = Self(rawValue: storageValue) else { + throw DataLayerError.invalidData("DevelopmentGoal.status: \(storageValue)") + } + self = status + } + + var dataStatus: DevelopmentGoalStatus { + switch self { + case .inProgress: + .inProgress + case .completed: + .completed + case .archived: + .archived + } + } +} diff --git a/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift b/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift index 5af7c03c..1f552312 100644 --- a/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift +++ b/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift @@ -8,25 +8,26 @@ import Foundation import Testing import FirebaseFirestore +import Data @testable import Infra struct DevelopmentGoalServiceImplTests { @Test("개발 목표 문서는 Data 응답으로 변환한다") func 개발_목표_문서는_Data_응답으로_변환한다() throws { let response = try #require( - DevelopmentGoalServiceImpl.makeResponse(documentId: "goal-1", data: makeData()) + try DevelopmentGoalServiceImpl.makeResponse(documentId: "goal-1", data: makeData()) ) #expect(response.id == "goal-1") #expect(response.markdownDescription == "설명") - #expect(response.status == "inProgress") + #expect(response.status == .inProgress) } @Test("목표 상태 전환은 진행 중과 보관 사이에서만 저장 데이터를 만든다") - func 목표_상태_전환은_진행_중과_보관_사이에서만_저장_데이터를_만든다() { - let data = DevelopmentGoalServiceImpl.makeTransitionData( + func 목표_상태_전환은_진행_중과_보관_사이에서만_저장_데이터를_만든다() throws { + let data = try DevelopmentGoalServiceImpl.makeTransitionData( recordData: makeData(), - request: .init(status: "archived") + request: .init(status: .archived) ) #expect(data?["status"] as? String == "archived") @@ -34,14 +35,14 @@ struct DevelopmentGoalServiceImplTests { } @Test("완료 요청과 완료 목표의 보관 전환은 저장 데이터를 만들지 않는다") - func 완료_요청과_완료_목표의_보관_전환은_저장_데이터를_만들지_않는다() { - let requestedCompletion = DevelopmentGoalServiceImpl.makeTransitionData( + func 완료_요청과_완료_목표의_보관_전환은_저장_데이터를_만들지_않는다() throws { + let requestedCompletion = try DevelopmentGoalServiceImpl.makeTransitionData( recordData: makeData(), - request: .init(status: "completed") + request: .init(status: .completed) ) - let archivedCompletion = DevelopmentGoalServiceImpl.makeTransitionData( + let archivedCompletion = try DevelopmentGoalServiceImpl.makeTransitionData( recordData: makeData(status: "completed"), - request: .init(status: "archived") + request: .init(status: .archived) ) #expect(requestedCompletion == nil) @@ -49,16 +50,26 @@ struct DevelopmentGoalServiceImplTests { } @Test("완료 목표는 진행 중으로 되돌리고 완료 시각을 삭제한다") - func 완료_목표는_진행_중으로_되돌리고_완료_시각을_삭제한다() { - let data = DevelopmentGoalServiceImpl.makeTransitionData( + func 완료_목표는_진행_중으로_되돌리고_완료_시각을_삭제한다() throws { + let data = try DevelopmentGoalServiceImpl.makeTransitionData( recordData: makeData(status: "completed"), - request: .init(status: "inProgress") + request: .init(status: .inProgress) ) #expect(data?["status"] as? String == "inProgress") #expect(data?["completedAt"] is FieldValue) } + @Test("유효하지 않은 저장 상태는 전환 데이터를 만들지 않고 오류를 던진다") + func 유효하지_않은_저장_상태는_전환_데이터를_만들지_않고_오류를_던진다() { + #expect(throws: DataLayerError.self) { + _ = try DevelopmentGoalServiceImpl.makeTransitionData( + recordData: makeData(status: "inProgres"), + request: .init(status: .archived) + ) + } + } + @Test("개발 기록 문서는 경로 식별값을 응답에 복원한다") func 개발_기록_문서는_경로_식별값을_응답에_복원한다() throws { let response = try #require( From bf1bcdaf2d4ae8249f8395464c8013a44569ff1b Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 28 Aug 2026 15:54:19 +0900 Subject: [PATCH 14/14] =?UTF-8?q?fix:=20=EA=B0=9C=EB=B0=9C=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=20=EC=A0=9C=EB=AA=A9=20=EC=A0=80=EC=9E=A5=20=EC=A0=84?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Sources/Common/DataLayerError.swift | 1 + .../Data/Sources/Mapper/ErrorMapping.swift | 2 ++ .../Data/Tests/Mapper/ErrorMappingTests.swift | 7 +++++++ .../Service/DevelopmentGoalServiceImpl.swift | 7 +++++++ .../DevelopmentGoalServiceImplTests.swift | 16 ++++++++++++++++ 5 files changed, 33 insertions(+) diff --git a/Application/Data/Sources/Common/DataLayerError.swift b/Application/Data/Sources/Common/DataLayerError.swift index fed15492..6a33fefd 100644 --- a/Application/Data/Sources/Common/DataLayerError.swift +++ b/Application/Data/Sources/Common/DataLayerError.swift @@ -18,6 +18,7 @@ public enum DataLayerError: Error { case notAuthenticated case failedToUnlinkLastProvider case linkCredentialAlreadyInUse + case invalidDevelopmentGoalTitle case developmentRecordDraftConflict case invalidData(context: String) diff --git a/Application/Data/Sources/Mapper/ErrorMapping.swift b/Application/Data/Sources/Mapper/ErrorMapping.swift index 7c3ebc50..79755486 100644 --- a/Application/Data/Sources/Mapper/ErrorMapping.swift +++ b/Application/Data/Sources/Mapper/ErrorMapping.swift @@ -16,6 +16,8 @@ extension Error { return AuthError.failedToUnlinkLastProvider case .linkCredentialAlreadyInUse: return AuthError.linkCredentialAlreadyInUse + case .invalidDevelopmentGoalTitle: + return DomainLayerError.invalidDevelopmentGoalTitle case .developmentRecordDraftConflict: return DomainLayerError.developmentRecordDraftConflict case .invalidData(let context): diff --git a/Application/Data/Tests/Mapper/ErrorMappingTests.swift b/Application/Data/Tests/Mapper/ErrorMappingTests.swift index eadfc638..1282d430 100644 --- a/Application/Data/Tests/Mapper/ErrorMappingTests.swift +++ b/Application/Data/Tests/Mapper/ErrorMappingTests.swift @@ -16,4 +16,11 @@ struct ErrorMappingTests { #expect(error as? DomainLayerError == .developmentRecordDraftConflict) } + + @Test("유효하지 않은 목표 제목은 Domain 제목 오류로 변환한다") + func 유효하지_않은_목표_제목은_Domain_제목_오류로_변환한다() { + let error = DataLayerError.invalidDevelopmentGoalTitle.toDomain() + + #expect(error as? DomainLayerError == .invalidDevelopmentGoalTitle) + } } diff --git a/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift b/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift index 88faa7f7..5dcf8f01 100644 --- a/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift +++ b/Application/Infra/Sources/Service/DevelopmentGoalServiceImpl.swift @@ -35,6 +35,7 @@ final class DevelopmentGoalServiceImpl: DevelopmentGoalService { guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } do { + try Self.validateTitle(request.title) let reference = store.document(FirestorePath.developmentGoal(uid, goalId: goalId)) var data = try encoder.encode(request) data[DevelopmentGoalFieldKey.status.rawValue] = DevelopmentGoalFirestoreStatus(.inProgress).rawValue @@ -160,6 +161,12 @@ final class DevelopmentGoalServiceImpl: DevelopmentGoalService { } extension DevelopmentGoalServiceImpl { + static func validateTitle(_ title: String) throws { + guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw DataLayerError.invalidDevelopmentGoalTitle + } + } + static func makeTransitionData( recordData: [String: Any], request: DevelopmentGoalStatusRequest diff --git a/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift b/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift index 1f552312..742355d7 100644 --- a/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift +++ b/Application/Infra/Tests/Service/DevelopmentGoalServiceImplTests.swift @@ -12,6 +12,22 @@ import Data @testable import Infra struct DevelopmentGoalServiceImplTests { + @Test("공백뿐인 목표 제목은 저장 전에 거부한다") + func 공백뿐인_목표_제목은_저장_전에_거부한다() { + do { + try DevelopmentGoalServiceImpl.validateTitle(" \n ") + Issue.record("DataLayerError.invalidDevelopmentGoalTitle이 필요") + } catch DataLayerError.invalidDevelopmentGoalTitle { + } catch { + Issue.record("예상하지 않은 오류: \(error)") + } + } + + @Test("의미 있는 목표 제목은 저장 전에 허용한다") + func 의미_있는_목표_제목은_저장_전에_허용한다() throws { + try DevelopmentGoalServiceImpl.validateTitle(" 목표 ") + } + @Test("개발 목표 문서는 Data 응답으로 변환한다") func 개발_목표_문서는_Data_응답으로_변환한다() throws { let response = try #require(