diff --git a/Package.resolved b/Package.resolved index d0206c97..1eea750a 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,7 +6,7 @@ "repositoryURL": "https://github.com/ittybittyapps/appstoreconnect-swift-sdk.git", "state": { "branch": "master", - "revision": "e3a5e2b820f88b8e4236257fcae03c890b6362eb", + "revision": "65d95d0979734597e7fb7d2d30028c659594ac53", "version": null } }, diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/BetaGroups/BetaGroupCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/BetaGroups/BetaGroupCommand.swift index f677c506..405af792 100644 --- a/Sources/AppStoreConnectCLI/Commands/TestFlight/BetaGroups/BetaGroupCommand.swift +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/BetaGroups/BetaGroupCommand.swift @@ -18,6 +18,7 @@ struct TestFlightBetaGroupCommand: ParsableCommand { ReadBetaGroupCommand.self, RemoveTestersFromGroupCommand.self, AddTestersToGroupCommand.self, + SyncBetaGroupsCommand.self, ], defaultSubcommand: ListBetaGroupsCommand.self ) diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/BetaGroups/Sync/PullBetaGroupsCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/BetaGroups/Sync/PullBetaGroupsCommand.swift new file mode 100644 index 00000000..4a0849d4 --- /dev/null +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/BetaGroups/Sync/PullBetaGroupsCommand.swift @@ -0,0 +1,30 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import ArgumentParser +import FileSystem + +struct PullBetaGroupsCommand: CommonParsableCommand { + + static var configuration = CommandConfiguration( + commandName: "pull", + abstract: "Pull down server beta groups, refresh local beta group config files" + ) + + @OptionGroup() + var common: CommonOptions + + @Option( + default: "./config/betagroups", + help: "Path to the Folder containing the information about beta groups. (default: './config/betagroups')" + ) var outputPath: String + + func run() throws { + let service = try makeService() + + let betaGroupWithTesters = try service.pullBetaGroups() + + try BetaGroupProcessor(path: .folder(path: outputPath)) + .write(groupsWithTesters: betaGroupWithTesters) + } + +} diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/BetaGroups/Sync/PushBetaGroupsCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/BetaGroups/Sync/PushBetaGroupsCommand.swift new file mode 100644 index 00000000..5ace9557 --- /dev/null +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/BetaGroups/Sync/PushBetaGroupsCommand.swift @@ -0,0 +1,137 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import ArgumentParser +import FileSystem +import Foundation +import struct Model.BetaGroup +import struct Model.BetaTester + +struct PushBetaGroupsCommand: CommonParsableCommand { + + static var configuration = CommandConfiguration( + commandName: "push", + abstract: "Push local beta group config files to server, update server beta groups" + ) + + @OptionGroup() + var common: CommonOptions + + @Option( + default: "./config/betagroups", + help: "Path to the Folder containing the information about beta groups. (default: './config/betagroups')" + ) var inputPath: String + + @Flag(help: "Perform a dry run.") + var dryRun: Bool + + func run() throws { + let service = try makeService() + + let resourceProcessor = BetaGroupProcessor(path: .folder(path: inputPath)) + + let serverGroupsWithTesters = try service.pullBetaGroups() + let localGroups = try resourceProcessor.read() + + // Sync Beta Groups + let strategies = SyncResourceComparator( + localResources: localGroups, + serverResources: serverGroupsWithTesters.map { $0.betaGroup } + ) + .compare() + + let renderer = Renderers.SyncResultRenderer() + + if dryRun { + renderer.render(strategies, isDryRun: true) + } else { + try strategies.forEach { (strategy: SyncStrategy) in + try syncBetaGroup(strategy: strategy, with: service) + renderer.render(strategy, isDryRun: false) + } + } + + // Sync Beta Testers + let localGroupWithTesters = try resourceProcessor.readGroupAndTesters() + + try localGroupWithTesters.forEach { + let localGroup = $0.betaGroup + let localTesters = $0.testers + + let serverTesters = serverGroupsWithTesters.first { + $0.betaGroup.id == localGroup.id + }?.testers ?? [] + + let testerStrategies = SyncResourceComparator( + localResources: localTesters, + serverResources: serverTesters + ) + .compare() + + let renderer = Renderers.SyncResultRenderer() + + if testerStrategies.count > 0 { + print("\(localGroup.groupName): ") + } + + if dryRun { + renderer.render(testerStrategies, isDryRun: true) + } else { + try testerStrategies.forEach { + try syncTester(with: service, + bundleId: localGroup.app.bundleId!, + groupName: localGroup.groupName, + strategies: $0) + + renderer.render($0, isDryRun: false) + } + } + } + + // After all operations, sync group and testers + if !dryRun { + try resourceProcessor.write(groupsWithTesters: try service.pullBetaGroups()) + } + } + + func syncBetaGroup( + strategy: SyncStrategy, + with service: AppStoreConnectService + ) throws { + switch strategy { + case .create(let group): + _ = try service.createBetaGroup( + appBundleId: group.app.bundleId!, + groupName: group.groupName, + publicLinkEnabled: group.publicLinkEnabled ?? false, + publicLinkLimit: group.publicLinkLimit + ) + case .delete(let group): + try service.deleteBetaGroup(with: group.id!) + case .update(let group): + try service.updateBetaGroup(betaGroup: group) + } + } + + func syncTester( + with service: AppStoreConnectService, + bundleId: String, + groupName: String, + strategies: SyncStrategy + ) throws { + switch strategies { + case .create(let tester): + _ = try service.inviteBetaTesterToGroups( + firstName: tester.firstName, + lastName: tester.lastName, + email: tester.email!, + bundleId: bundleId, + groupNames: [groupName] + ) + case .update: + print("Update single beta tester is not supported.") + case .delete(let tester): + try service.removeTesterFromGroups(email: tester.email!, groupNames: [groupName]) + } + } + +} diff --git a/Sources/AppStoreConnectCLI/Commands/TestFlight/BetaGroups/Sync/SyncBetaGroupsCommand.swift b/Sources/AppStoreConnectCLI/Commands/TestFlight/BetaGroups/Sync/SyncBetaGroupsCommand.swift new file mode 100644 index 00000000..8c4166b2 --- /dev/null +++ b/Sources/AppStoreConnectCLI/Commands/TestFlight/BetaGroups/Sync/SyncBetaGroupsCommand.swift @@ -0,0 +1,17 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import ArgumentParser + +struct SyncBetaGroupsCommand: ParsableCommand { + static var configuration = CommandConfiguration( + commandName: "sync", + abstract: """ + Sync information about beta groups with provided configuration file. + """, + subcommands: [ + PullBetaGroupsCommand.self, + PushBetaGroupsCommand.self, + ], + defaultSubcommand: PullBetaGroupsCommand.self + ) +} diff --git a/Sources/AppStoreConnectCLI/Model/App.swift b/Sources/AppStoreConnectCLI/Model/App.swift index 9b12fab9..946fc576 100644 --- a/Sources/AppStoreConnectCLI/Model/App.swift +++ b/Sources/AppStoreConnectCLI/Model/App.swift @@ -38,7 +38,7 @@ extension App: TableInfoProvider { var tableRow: [CustomStringConvertible] { return [ - id, + id ?? "", bundleId ?? "", name ?? "", primaryLocale ?? "", diff --git a/Sources/AppStoreConnectCLI/Model/BetaGroup.swift b/Sources/AppStoreConnectCLI/Model/BetaGroup.swift index 979f1982..b3785ce5 100755 --- a/Sources/AppStoreConnectCLI/Model/BetaGroup.swift +++ b/Sources/AppStoreConnectCLI/Model/BetaGroup.swift @@ -26,10 +26,10 @@ extension BetaGroup: TableInfoProvider, ResultRenderable { var tableRow: [CustomStringConvertible] { [ - app.id, + app.id ?? "", app.bundleId ?? "", app.name ?? "", - groupName ?? "", + groupName, isInternal ?? "", publicLink ?? "", publicLinkEnabled ?? "", @@ -41,13 +41,29 @@ extension BetaGroup: TableInfoProvider, ResultRenderable { } extension BetaGroup { + enum Error: LocalizedError { + case invalidName + + var errorDescription: String? { + switch self { + case .invalidName: + return "Beta group doesn't have a valid group name." + } + } + } + init( _ apiApp: AppStoreConnect_Swift_SDK.App, _ apiBetaGroup: AppStoreConnect_Swift_SDK.BetaGroup - ) { + ) throws { + guard let groupName = apiBetaGroup.attributes?.name else { + throw Error.invalidName + } + self.init( app: App(apiApp), - groupName: apiBetaGroup.attributes?.name, + id: apiBetaGroup.id, + groupName: groupName, isInternal: apiBetaGroup.attributes?.isInternalGroup, publicLink: apiBetaGroup.attributes?.publicLink, publicLinkEnabled: apiBetaGroup.attributes?.publicLinkEnabled, @@ -57,3 +73,15 @@ extension BetaGroup { ) } } + +extension BetaGroup: SyncResultRenderable { + var syncResultText: String { + "\(app.bundleId ?? "" )_\(groupName)" + } +} + +extension BetaGroup: SyncResourceProcessable { + var compareIdentity: String { + id ?? "" + } +} diff --git a/Sources/AppStoreConnectCLI/Model/BetaTester.swift b/Sources/AppStoreConnectCLI/Model/BetaTester.swift index 18b2930b..535af14f 100644 --- a/Sources/AppStoreConnectCLI/Model/BetaTester.swift +++ b/Sources/AppStoreConnectCLI/Model/BetaTester.swift @@ -58,3 +58,13 @@ extension BetaTester: ResultRenderable, TableInfoProvider { ] } } + +extension BetaTester: SyncResourceProcessable { + var syncResultText: String { + email! + } + + var compareIdentity: String { + email! + } +} diff --git a/Sources/AppStoreConnectCLI/Readers and Renderers/Renderers.swift b/Sources/AppStoreConnectCLI/Readers and Renderers/Renderers.swift index bf37fcdf..2dafbecf 100644 --- a/Sources/AppStoreConnectCLI/Readers and Renderers/Renderers.swift +++ b/Sources/AppStoreConnectCLI/Readers and Renderers/Renderers.swift @@ -113,3 +113,42 @@ extension ResultRenderable where Self: TableInfoProvider { return table.render() } } + +protocol SyncResultRenderable: Equatable { + var syncResultText: String { get } +} + +enum SyncStrategy { + case delete(T) + case create(T) + case update(T) +} + +extension Renderers { + + struct SyncResultRenderer { + + func render(_ strategy: [SyncStrategy], isDryRun: Bool) { + strategy.forEach { renderResultText($0, isDryRun) } + } + + func render(_ strategy: SyncStrategy, isDryRun: Bool) { + renderResultText(strategy, isDryRun) + } + + private func renderResultText(_ strategy: SyncStrategy, _ isDryRun: Bool) { + let resultText: String + switch strategy { + case .create(let input): + resultText = "➕ \(input.syncResultText)" + case .delete(let input): + resultText = "➖ \(input.syncResultText)" + case .update(let input): + resultText = "⬆️ \(input.syncResultText)" + } + + print("\(isDryRun ? "" : "✅") \(resultText)") + } + } + +} diff --git a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift index cc3085f9..6d1c4496 100644 --- a/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift +++ b/Sources/AppStoreConnectCLI/Services/AppStoreConnectService.swift @@ -410,7 +410,7 @@ class AppStoreConnectService { .execute(with: requestor) .await() - return Model.BetaGroup(app, betaGroup) + return try Model.BetaGroup(app, betaGroup) } func createBetaGroup( @@ -432,7 +432,7 @@ class AppStoreConnectService { ) let betaGroupResponse = createBetaGroupOperation.execute(with: requestor) - return try betaGroupResponse.map(Model.BetaGroup.init).await() + return try betaGroupResponse.tryMap(Model.BetaGroup.init).await() } func deleteBetaGroup(appBundleId: String, betaGroupName: String) throws { @@ -507,7 +507,34 @@ class AppStoreConnectService { let modifyBetaGroupOperation = ModifyBetaGroupOperation(options: modifyBetaGroupOptions) let modifiedBetaGroup = try modifyBetaGroupOperation.execute(with: requestor).await() - return Model.BetaGroup(app, modifiedBetaGroup) + return try Model.BetaGroup(app, modifiedBetaGroup) + } + + func pullBetaGroups() throws -> [(betaGroup: Model.BetaGroup, testers: [Model.BetaTester])] { + let groupOutputs = try ListBetaGroupsOperation(options: .init(appIds: [], names: [], sort: nil)).execute(with: requestor).await() + + return try groupOutputs.map { + let testers = try ListBetaTestersOperation( + options: .init(groupIds: [$0.betaGroup.id]) + ) + .execute(with: requestor) + .await() + .map(BetaTester.init) + + return (try BetaGroup($0.app, $0.betaGroup), testers) + } + } + + func updateBetaGroup(betaGroup: Model.BetaGroup) throws { + _ = try UpdateBetaGroupOperation(options: .init(betaGroup: betaGroup)) + .execute(with: requestor) + .await() + } + + func deleteBetaGroup(with id: String) throws { + try DeleteBetaGroupOperation(options: .init(betaGroupId: id)) + .execute(with: requestor) + .await() } func readBuild(bundleId: String, buildNumber: String, preReleaseVersion: String) throws -> Model.Build { diff --git a/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersOperation.swift b/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersOperation.swift index 1f246525..7943901f 100644 --- a/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersOperation.swift +++ b/Sources/AppStoreConnectCLI/Services/Operations/ListBetaTestersOperation.swift @@ -7,26 +7,15 @@ import Foundation struct ListBetaTestersOperation: APIOperation { struct Options { - let email: String? - let firstName: String? - let lastName: String? - let inviteType: BetaInviteType? - let appIds: [String]? - let groupIds: [String]? - let sort: ListBetaTesters.Sort? - let limit: Int? - let relatedResourcesLimit: Int? - } - - enum Error: LocalizedError { - case notFound - - var errorDescription: String? { - switch self { - case .notFound: - return "Beta testers with provided filters not found." - } - } + var email: String? + var firstName: String? + var lastName: String? + var inviteType: BetaInviteType? + var appIds: [String]? + var groupIds: [String]? + var sort: ListBetaTesters.Sort? + var limit: Int? + var relatedResourcesLimit: Int? } private let options: Options @@ -107,13 +96,9 @@ struct ListBetaTestersOperation: APIOperation { next: $0 ) } - .tryMap { (responses: [BetaTestersResponse]) throws -> Output in - try responses.flatMap { (response: BetaTestersResponse) -> Output in - guard !response.data.isEmpty else { - throw Error.notFound - } - - return response.data.map { + .map { + $0.flatMap { (response: BetaTestersResponse) -> Output in + response.data.map { .init(betaTester: $0, includes: response.included) } } diff --git a/Sources/AppStoreConnectCLI/Services/Operations/UpdateBetaGroupOperation.swift b/Sources/AppStoreConnectCLI/Services/Operations/UpdateBetaGroupOperation.swift new file mode 100644 index 00000000..abe72722 --- /dev/null +++ b/Sources/AppStoreConnectCLI/Services/Operations/UpdateBetaGroupOperation.swift @@ -0,0 +1,34 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import AppStoreConnect_Swift_SDK +import Combine +import Foundation +import struct Model.BetaGroup + +struct UpdateBetaGroupOperation: APIOperation { + + struct Options { + let betaGroup: BetaGroup + } + + private let options: Options + + init(options: Options) { + self.options = options + } + + func execute(with requestor: EndpointRequestor) throws -> AnyPublisher { + let betaGroup = options.betaGroup + + let endpoint = APIEndpoint.modify( + betaGroupWithId: betaGroup.id!, + name: betaGroup.groupName, + publicLinkEnabled: betaGroup.publicLinkEnabled, + publicLinkLimit: betaGroup.publicLinkLimit, + publicLinkLimitEnabled: betaGroup.publicLinkLimitEnabled + ) + + return requestor.request(endpoint).eraseToAnyPublisher() + } + +} diff --git a/Sources/AppStoreConnectCLI/Services/SyncService.swift b/Sources/AppStoreConnectCLI/Services/SyncService.swift new file mode 100644 index 00000000..c4ccdc9e --- /dev/null +++ b/Sources/AppStoreConnectCLI/Services/SyncService.swift @@ -0,0 +1,42 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import Foundation + +protocol SyncResourceProcessable: SyncResourceComparable, SyncResultRenderable { } + +protocol SyncResourceComparable: Hashable { + associatedtype T: Comparable + + var compareIdentity: T { get } +} + +struct SyncResourceComparator { + + let localResources: [T] + let serverResources: [T] + + private var localResourcesSet: Set { Set(localResources) } + private var serverResourcesSet: Set { Set(serverResources) } + + func compare() -> [SyncStrategy] { + serverResourcesSet + .subtracting(localResourcesSet) + .compactMap { resource -> SyncStrategy? in + localResources + .contains(where: { resource.compareIdentity == $0.compareIdentity }) + ? nil + : .delete(resource) + } + + + localResourcesSet + .subtracting(serverResourcesSet) + .compactMap { resource -> SyncStrategy? in + serverResourcesSet + .contains( + where: { resource.compareIdentity == $0.compareIdentity } + ) + ? .update(resource) + : .create(resource) + } + } +} diff --git a/Sources/FileSystem/Beta Group/BetaGroupProcessor.swift b/Sources/FileSystem/Beta Group/BetaGroupProcessor.swift new file mode 100644 index 00000000..6616d725 --- /dev/null +++ b/Sources/FileSystem/Beta Group/BetaGroupProcessor.swift @@ -0,0 +1,79 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import Files +import Foundation +import Model +import Yams + +public struct BetaGroupProcessor: ResourceProcessor { + + var path: ResourcePath + + public init(path: ResourcePath) { + self.path = path + } + + public func read() throws -> [BetaGroup] { + try getFolder().files.compactMap { (file: File) -> BetaGroup? in + if file.extension == "yml" { + return Readers + .FileReader(format: .yaml) + .read(filePath: file.path) + } + + return nil + } + } + + public func readGroupAndTesters() throws -> [(betaGroup: BetaGroup, testers: [BetaTester])] { + try read().map { group -> (BetaGroup, [BetaTester]) in + guard let testercsv = group.testers else { + return (group, []) + } + + let testers = Readers.FileReader<[BetaTester]>(format: .csv) + .read(filePath: testercsv) + + return (group, testers) + } + } + + public func write(groupsWithTesters: [(betaGroup: BetaGroup, testers: [BetaTester])]) throws { + deleteFile() + + let betagroups = try groupsWithTesters + .map { try write(betaTesters: $0.testers, into: $0.betaGroup) } + + try write(betagroups) + } + + @discardableResult + func write(_ betaGroups: [BetaGroup]) throws -> [File] { + try betaGroups.map { try write($0) } + } + + @discardableResult + func write(_ betaGroup: BetaGroup) throws -> File { + try writeFile(betaGroup) + } + + private func write(betaTesters: [BetaTester], into betaGroup: BetaGroup) throws -> BetaGroup { + + var group = betaGroup + group.testers = try BetaTesterProcessor(folder: try getFolder()) + .write(group: group, testers: betaTesters) + + return group + } + +} + +extension BetaGroup: FileProvider { + var fileName: String { + "\(app.bundleId ?? "")_\(groupName).yml" + } + + func fileContent() throws -> FileContent { + .string(try YAMLEncoder().encode(self)) + } +} diff --git a/Sources/FileSystem/Beta Tester/BetaTesterProcessor.swift b/Sources/FileSystem/Beta Tester/BetaTesterProcessor.swift new file mode 100644 index 00000000..0b0a5ed6 --- /dev/null +++ b/Sources/FileSystem/Beta Tester/BetaTesterProcessor.swift @@ -0,0 +1,45 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import CodableCSV +import Files +import Foundation +import Model + +struct BetaTesterProcessor { + + let folder: Folder + + typealias FilePath = String + + func write(group: BetaGroup, testers: [BetaTester]) throws -> FilePath { + let file = try folder.createFile(named: "\(group.app.bundleId ?? "")_\(group.groupName)_beta-testers.csv") + + try file.write(testers.renderAsCSV()) + + return file.path(relativeTo: try Folder(path: FileManager.default.currentDirectoryPath)) + } +} + +// TODO: merge this with ResultRenderable in main module +protocol CSVRenderable: Codable { + var headers: [String] { get } + var rows: [[String]] { get } +} + +extension CSVRenderable { + func renderAsCSV() -> String { + let wholeTable = [headers] + rows + + return try! CSVWriter.encode(rows: wholeTable, into: String.self) // swiftlint:disable:this force_try + } +} + +extension Array: CSVRenderable where Element == BetaTester { + var headers: [String] { + ["Email", "First Name", "Last Name", "Invite Type"] + } + + var rows: [[String]] { + self.map { [$0.email, $0.firstName, $0.lastName, $0.inviteType].compactMap { $0 } } + } +} diff --git a/Sources/FileSystem/Certificates/CertificateProcessor.swift b/Sources/FileSystem/Certificates/CertificateProcessor.swift index 89d9e92f..e52d7b64 100644 --- a/Sources/FileSystem/Certificates/CertificateProcessor.swift +++ b/Sources/FileSystem/Certificates/CertificateProcessor.swift @@ -36,7 +36,7 @@ extension Certificate: FileProvider { } } - func fileContent() throws -> Data { + func fileContent() throws -> FileContent { guard let content = content, let data = Data(base64Encoded: content) @@ -44,7 +44,7 @@ extension Certificate: FileProvider { throw Error.noContent } - return data + return .data(data) } var fileName: String { diff --git a/Sources/FileSystem/FileProvider.swift b/Sources/FileSystem/FileProvider.swift new file mode 100644 index 00000000..a2b085f2 --- /dev/null +++ b/Sources/FileSystem/FileProvider.swift @@ -0,0 +1,18 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +import Foundation + +protocol FileProvider: FileNameProvider, FileContentProvider { } + +protocol FileNameProvider { + var fileName: String { get } +} + +enum FileContent { + case data(Data) + case string(String) +} + +protocol FileContentProvider { + func fileContent() throws -> FileContent +} diff --git a/Sources/FileSystem/Profile/ProfileProcessor.swift b/Sources/FileSystem/Profile/ProfileProcessor.swift index 310dcc8c..eb34912c 100644 --- a/Sources/FileSystem/Profile/ProfileProcessor.swift +++ b/Sources/FileSystem/Profile/ProfileProcessor.swift @@ -38,14 +38,14 @@ extension Profile: FileProvider { } } - func fileContent() throws -> Data { + func fileContent() throws -> FileContent { guard let content = profileContent, let data = Data(base64Encoded: content) else { throw Error.noContent } - return data + return .data(data) } var fileName: String { diff --git a/Sources/FileSystem/Readers.swift b/Sources/FileSystem/Readers.swift index c792bccc..c10eb647 100644 --- a/Sources/FileSystem/Readers.swift +++ b/Sources/FileSystem/Readers.swift @@ -60,8 +60,7 @@ public enum Readers { } guard - let url = URL(string: "file://\(filePath)"), - let result = try? decoder.decode(T.self, from: url) else { + let result = try? decoder.decode(T.self, from: URL(fileURLWithPath: filePath)) else { fatalError("Could not read CSV file: \(filePath)") } diff --git a/Sources/FileSystem/ResourceProcessor.swift b/Sources/FileSystem/ResourceProcessor.swift index db675b8f..418e29ef 100644 --- a/Sources/FileSystem/ResourceProcessor.swift +++ b/Sources/FileSystem/ResourceProcessor.swift @@ -33,37 +33,65 @@ protocol PathProvider { var path: ResourcePath { get } } +extension PathProvider { + func getFolder() throws -> Folder { + var folder: Folder + switch path { + case .file(let path): + let standardizedPath = path as NSString + folder = try Folder(path: "").createSubfolderIfNeeded(at: standardizedPath.deletingLastPathComponent) + case .folder(let folderPath): + folder = try Folder(path: "").createSubfolderIfNeeded(at: folderPath) + } + + return folder + } +} + extension ResourceWriter { func writeFile(_ resource: FileProvider) throws -> File { var file: File + var fileName: String switch path { case .file(let path): let standardizedPath = path as NSString - file = try Folder(path: standardizedPath.deletingLastPathComponent) - .createFile( - named: standardizedPath.lastPathComponent, - contents: resource.fileContent() - ) - case .folder(let folderPath): - file = try Folder(path: folderPath) - .createFile( - named: resource.fileName, - contents: resource.fileContent() - ) + fileName = standardizedPath.lastPathComponent + case .folder: + fileName = resource.fileName } - return file - } -} + let folder: Folder = try getFolder() -protocol FileProvider: FileNameProvider, FileContentProvider { } + switch try resource.fileContent() { + case .data(let data): + file = try folder.createFileIfNeeded(withName: fileName, contents: data) + case .string(let string): + file = try folder.createFileIfNeeded(at: fileName) + try file.write(string) + } -protocol FileNameProvider { - var fileName: String { get } -} + return file + } -protocol FileContentProvider { - func fileContent() throws -> Data + func deleteFile() { + do { + switch path { + case .file(let filePath): + let standardizedPath = filePath as NSString + try Folder(path: standardizedPath.deletingLastPathComponent) + .files.forEach { + if $0.name == standardizedPath.lastPathComponent { + try $0.delete() + } + } + case .folder(let folderPath): + try Folder(path: folderPath) + .files.forEach { try $0.delete() } + } + } catch { + // Skip delete failed error, if folder is missing. + } + } } diff --git a/Sources/Model/App.swift b/Sources/Model/App.swift index b68cd27e..474425e8 100644 --- a/Sources/Model/App.swift +++ b/Sources/Model/App.swift @@ -3,14 +3,14 @@ import Foundation public struct App: Codable, Equatable { - public let id: String + public let id: String? public var bundleId: String? public var name: String? public var primaryLocale: String? public var sku: String? public init( - id: String, + id: String?, bundleId: String?, name: String?, primaryLocale: String?, diff --git a/Sources/Model/BetaGroup.swift b/Sources/Model/BetaGroup.swift index 13cfc074..c360094c 100644 --- a/Sources/Model/BetaGroup.swift +++ b/Sources/Model/BetaGroup.swift @@ -4,25 +4,30 @@ import Foundation public struct BetaGroup: Codable, Equatable { public let app: App - public let groupName: String? + public let id: String? + public let groupName: String public let isInternal: Bool? public let publicLink: String? public let publicLinkEnabled: Bool? public let publicLinkLimit: Int? public let publicLinkLimitEnabled: Bool? public let creationDate: String? + public var testers: String? // tester csv file path public init( app: App, - groupName: String?, + id: String?, + groupName: String, isInternal: Bool?, publicLink: String?, publicLinkEnabled: Bool?, publicLinkLimit: Int?, publicLinkLimitEnabled: Bool?, - creationDate: String? + creationDate: String?, + testers: String? = nil ) { self.app = app + self.id = id self.groupName = groupName self.isInternal = isInternal self.publicLink = publicLink @@ -30,5 +35,24 @@ public struct BetaGroup: Codable, Equatable { self.publicLinkLimit = publicLinkLimit self.publicLinkLimitEnabled = publicLinkLimitEnabled self.creationDate = creationDate + self.testers = testers + } +} + +extension BetaGroup: Hashable { + public static func == (lhs: BetaGroup, rhs: BetaGroup) -> Bool { + return lhs.id == rhs.id && + lhs.groupName == rhs.groupName && + lhs.publicLinkEnabled == rhs.publicLinkEnabled && + lhs.publicLinkLimit == rhs.publicLinkLimit && + lhs.publicLinkLimitEnabled == rhs.publicLinkLimitEnabled + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(groupName) + hasher.combine(publicLinkEnabled) + hasher.combine(publicLinkLimit) + hasher.combine(publicLinkLimitEnabled) } } diff --git a/Sources/Model/BetaTester.swift b/Sources/Model/BetaTester.swift index 9f3e65bb..5ec68b78 100644 --- a/Sources/Model/BetaTester.swift +++ b/Sources/Model/BetaTester.swift @@ -26,3 +26,32 @@ public struct BetaTester: Codable, Equatable { self.apps = apps } } + +extension BetaTester: Hashable { + + public static func == (lhs: BetaTester, rhs: BetaTester) -> Bool { + return lhs.email == rhs.email && + lhs.firstName == rhs.firstName && + lhs.lastName == rhs.lastName + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(email) + hasher.combine(firstName) + hasher.combine(lastName) + } + +} + +extension BetaTester { + + private enum CodingKeys: String, CodingKey { + case email = "Email" + case firstName = "First Name" + case lastName = "Last Name" + case inviteType = "Invite Type" + case betaGroups + case apps + } + +} diff --git a/Tests/appstoreconnect-cliTests/Serivces/SyncResourceComparatorTests.swift b/Tests/appstoreconnect-cliTests/Serivces/SyncResourceComparatorTests.swift new file mode 100644 index 00000000..3c7fdc93 --- /dev/null +++ b/Tests/appstoreconnect-cliTests/Serivces/SyncResourceComparatorTests.swift @@ -0,0 +1,107 @@ +// Copyright 2020 Itty Bitty Apps Pty Ltd + +@testable import AppStoreConnectCLI +import Model +import Foundation +import XCTest + +final class SyncResourceComparatorTests: XCTestCase { + func testCompare_returnCreateStrategies() throws { + let localGroups = [generateGroup(name: "a new group")] + + let strategies = SyncResourceComparator( + localResources: localGroups, + serverResources: [] + ) + .compare() + + XCTAssertEqual(strategies.count, 1) + + XCTAssertEqual(strategies.first!, .create(localGroups.first!)) + } + + func testCompare_returnUpdateStrategies() throws { + let localGroups = [generateGroup(id: "123", name: "foo")] + let serverGroups = [generateGroup(id: "123", name: "bar")] + + let strategies = SyncResourceComparator( + localResources: localGroups, + serverResources: serverGroups + ) + .compare() + + XCTAssertEqual(strategies.count, 1) + XCTAssertEqual(strategies.first!, .update(localGroups.first!)) + } + + func testCompare_returnDelete() { + let serverGroups = [generateGroup(id: "123"), generateGroup(id: "456")] + + let strategies = SyncResourceComparator( + localResources: [], + serverResources: serverGroups + ) + .compare() + + XCTAssertEqual(strategies.count, 2) + XCTAssertEqual(strategies.contains(.delete(serverGroups.first!)), true) + XCTAssertEqual(strategies.contains(.delete(serverGroups[1])), true) + XCTAssertNotEqual(strategies.contains(.update(generateGroup(id: "1234"))), true) + } + + func testCompare_returnDeleteAndUpdateAndCreate() { + let localGroups = [generateGroup(id: "1", publicLinkEnabled: true), generateGroup(name: "hi")] + let serverGroups = [generateGroup(id: "1", publicLinkEnabled: false), generateGroup(id: "3", name: "there")] + + let strategies = SyncResourceComparator( + localResources: localGroups, + serverResources: serverGroups + ) + .compare() + + XCTAssertEqual(strategies.count, 3) + + XCTAssertEqual(strategies.contains(.delete(serverGroups[1])), true) + XCTAssertEqual(strategies.contains(.create(localGroups[1])), true) + XCTAssertEqual(strategies.contains(.update(localGroups[0])), true) + } +} + +private extension SyncResourceComparatorTests { + func generateGroup( + id: String? = nil, + name: String = "foo", + isInternal: Bool = false, + publicLinkEnabled: Bool = false, + publicLinkLimit: Int = 10, + publicLinkLimitEnabled: Bool = false + ) -> BetaGroup { + BetaGroup( + app: App(id: "", bundleId: "com.example.foo", name: "foo", primaryLocale: "", sku: ""), + id: id, + groupName: name, + isInternal: isInternal, + publicLink: "", + publicLinkEnabled: publicLinkEnabled, + publicLinkLimit: publicLinkLimit, + publicLinkLimitEnabled: publicLinkLimitEnabled, + creationDate: "", + testers: "" + ) + } +} + +extension SyncStrategy: Equatable { + public static func == (lhs: SyncStrategy, rhs: SyncStrategy) -> Bool { + switch (lhs, rhs) { + case (let .create(lhsItem), let .create(rhsItem)): + return lhsItem == rhsItem + case (let .update(lhsItem), let .update(rhsItem)): + return lhsItem == rhsItem + case (let .delete(lhsItem), let .delete(rhsItem)): + return lhsItem == rhsItem + default: + return false + } + } +}