[Library] iTunes library (#5)
This PR contains the work done to implement the `iTunesService` service that fetches the reviews from the **Apple App Store**. Reviewed-on: #5 Co-authored-by: Javier Cicchelli <javier@rock-n-code.com> Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
parent
4359f53a19
commit
7c016b50d6
@ -6,7 +6,7 @@
|
|||||||
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
|
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
public enum EndpointError: Error {
|
public enum EndpointError: Error, Equatable {
|
||||||
case inputParametersEmpty
|
case inputParametersEmpty
|
||||||
case requestFailed(statusCode: Int)
|
case requestFailed(statusCode: Int)
|
||||||
case responseNotFound
|
case responseNotFound
|
||||||
|
@ -39,3 +39,22 @@ public struct Review {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Equatable
|
||||||
|
extension Review: Equatable {
|
||||||
|
|
||||||
|
// MARK: Functions
|
||||||
|
public static func == (
|
||||||
|
lhs: Review,
|
||||||
|
rhs: Review
|
||||||
|
) -> Bool {
|
||||||
|
lhs.author == rhs.author
|
||||||
|
&& lhs.content == rhs.content
|
||||||
|
&& lhs.id == rhs.id
|
||||||
|
&& lhs.rating == rhs.rating
|
||||||
|
&& lhs.title == rhs.title
|
||||||
|
&& lhs.version == rhs.version
|
||||||
|
&& lhs.updated.description == rhs.updated.description
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
@ -0,0 +1,90 @@
|
|||||||
|
//
|
||||||
|
// MockURLProtocol.swift
|
||||||
|
// ReviewsFoundationKit
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 17/03/2024.
|
||||||
|
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public class MockURLProtocol: URLProtocol {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
public static var response: Response?
|
||||||
|
|
||||||
|
// MARK: Functions
|
||||||
|
public override class func canInit(with task: URLSessionTask) -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
public override class func canInit(with request: URLRequest) -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
public override class func canonicalRequest(for request: URLRequest) -> URLRequest {
|
||||||
|
request
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func startLoading() {
|
||||||
|
guard
|
||||||
|
let url = request.url,
|
||||||
|
let response = Self.response
|
||||||
|
else {
|
||||||
|
client?.urlProtocolDidFinishLoading(self)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let data = try? response.data {
|
||||||
|
client?.urlProtocol(self, didLoad: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let httpResponse = HTTPURLResponse(
|
||||||
|
url: url,
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
httpVersion: nil,
|
||||||
|
headerFields: nil
|
||||||
|
) {
|
||||||
|
client?.urlProtocol(
|
||||||
|
self,
|
||||||
|
didReceive: httpResponse,
|
||||||
|
cacheStoragePolicy: .allowedInMemoryOnly
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
client?.urlProtocolDidFinishLoading(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func stopLoading() {}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Structs
|
||||||
|
|
||||||
|
extension MockURLProtocol {
|
||||||
|
public struct Response {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
public let statusCode: Int
|
||||||
|
public let object: (any Encodable)?
|
||||||
|
|
||||||
|
// MARK: Initialisers
|
||||||
|
public init(
|
||||||
|
statusCode: Int,
|
||||||
|
object: (any Codable)? = nil
|
||||||
|
) {
|
||||||
|
self.statusCode = statusCode
|
||||||
|
self.object = object
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
var data: Data? {
|
||||||
|
get throws {
|
||||||
|
guard let object else { return nil }
|
||||||
|
|
||||||
|
return try JSONEncoder.default.encode(object)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// JSONDecoder+Constants.swift
|
||||||
|
// ReviewsiTunesKit
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 17/03/2024.
|
||||||
|
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension JSONDecoder {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
public static let `default` = {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
|
||||||
|
return decoder
|
||||||
|
}()
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// JSONEncoder+Constants.swift
|
||||||
|
// ReviewsiTunesKit
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 17/03/2024.
|
||||||
|
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension JSONEncoder {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
public static let `default` = {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
|
||||||
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
|
|
||||||
|
return encoder
|
||||||
|
}()
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
//
|
||||||
|
// URL+Constants.swift
|
||||||
|
// ReviewsFoundationKit
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 17/03/2024.
|
||||||
|
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension URL {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
public static let bitBucket: URL = {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
.init(filePath: .FilePath.bitBucket)
|
||||||
|
} else {
|
||||||
|
.init(fileURLWithPath: .FilePath.bitBucket)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - String+Constants
|
||||||
|
private extension String {
|
||||||
|
enum FilePath {
|
||||||
|
static let bitBucket = "/dev/null"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// URLSessionConfiguration+Constants.swift
|
||||||
|
// ReviewsFoundationKit
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 17/03/2024.
|
||||||
|
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension URLSessionConfiguration {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
public static let mock = {
|
||||||
|
let configuration: URLSessionConfiguration = .ephemeral
|
||||||
|
|
||||||
|
configuration.protocolClasses = [MockURLProtocol.self]
|
||||||
|
|
||||||
|
return configuration
|
||||||
|
}()
|
||||||
|
|
||||||
|
}
|
@ -6,7 +6,7 @@
|
|||||||
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
|
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
import ReviewsFoundation
|
import ReviewsFoundationKit
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class String_ConstantsTests: XCTestCase {
|
final class String_ConstantsTests: XCTestCase {
|
||||||
@ -15,7 +15,7 @@ final class String_ConstantsTests: XCTestCase {
|
|||||||
private var sut: String!
|
private var sut: String!
|
||||||
|
|
||||||
// MARK: Constants tests
|
// MARK: Constants tests
|
||||||
func testEmpty() throws {
|
func testEmpty() {
|
||||||
// GIVEN
|
// GIVEN
|
||||||
sut = .empty
|
sut = .empty
|
||||||
|
|
||||||
|
@ -0,0 +1,27 @@
|
|||||||
|
//
|
||||||
|
// URL+ConstantsTests.swift
|
||||||
|
// ReviewsFoundationTest
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 17/03/2024.
|
||||||
|
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ReviewsFoundationKit
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class URL_ConstantsTests: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
private var sut: URL!
|
||||||
|
|
||||||
|
// MARK: Constants tests
|
||||||
|
func testBitBucket() {
|
||||||
|
sut = .bitBucket
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
// THEN
|
||||||
|
XCTAssertEqual(sut.absoluteString, "file:///dev/null")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
70
Libraries/iTunes/Kit/Endpoints/GetReviewsAPIEndpoint.swift
Normal file
70
Libraries/iTunes/Kit/Endpoints/GetReviewsAPIEndpoint.swift
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
//
|
||||||
|
// GetReviewsAPIEndpoint.swift
|
||||||
|
// ReviewsiTunesKit
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 17/03/2024.
|
||||||
|
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ReviewsFoundationKit
|
||||||
|
import ReviewsFeedKit
|
||||||
|
|
||||||
|
struct GetReviewsAPIEndpoint: GetReviewsEndpoint {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
let host: URL
|
||||||
|
let decoder: JSONDecoder
|
||||||
|
let session: URLSession
|
||||||
|
|
||||||
|
// MARK: Functions
|
||||||
|
@discardableResult func callAsFunction(
|
||||||
|
_ input: ReviewsFeedKit.GetReviewsInput
|
||||||
|
) async throws -> ReviewsFeedKit.GetReviewsOutput {
|
||||||
|
let path = try makePath(with: input)
|
||||||
|
let url = {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
host.appending(path: path)
|
||||||
|
} else {
|
||||||
|
host.appendingPathComponent(path)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
let (data, response) = try await session.data(from: url)
|
||||||
|
|
||||||
|
guard let urlResponse = response as? HTTPURLResponse else {
|
||||||
|
throw EndpointError.responseNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
guard urlResponse.statusCode == 200 else {
|
||||||
|
throw EndpointError.requestFailed(statusCode: urlResponse.statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
let feed = try decoder.decode(Feed.self, from: data)
|
||||||
|
|
||||||
|
return .init(reviews: feed.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makePath(with input: GetReviewsInput) throws -> String {
|
||||||
|
guard
|
||||||
|
!input.appID.isEmpty,
|
||||||
|
!input.countryCode.isEmpty
|
||||||
|
else{
|
||||||
|
throw EndpointError.inputParametersEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
format: .Format.recentReviewsPath,
|
||||||
|
input.countryCode,
|
||||||
|
input.appID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - String+Formats
|
||||||
|
private extension String {
|
||||||
|
enum Format {
|
||||||
|
static let recentReviewsPath = "/%@/rss/customerreviews/id=%@/sortby=mostrecent/json"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
//
|
||||||
|
// ServiceConfiguration+Inits.swift
|
||||||
|
// ReviewsiTunesKit
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 17/03/2024.
|
||||||
|
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ReviewsFoundationKit
|
||||||
|
import ReviewsFeedKit
|
||||||
|
|
||||||
|
extension ServiceConfiguration {
|
||||||
|
|
||||||
|
// MARK: Initialisers
|
||||||
|
init(session: URLSessionConfiguration = .ephemeral) {
|
||||||
|
self.init(
|
||||||
|
host: .iTunes,
|
||||||
|
session: session,
|
||||||
|
decoder: .default
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
17
Libraries/iTunes/Kit/Extensions/URL+Constants.swift
Normal file
17
Libraries/iTunes/Kit/Extensions/URL+Constants.swift
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// URL+Constants.swift
|
||||||
|
// ReviewsiTunesKit
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 17/03/2024.
|
||||||
|
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ReviewsFoundationKit
|
||||||
|
|
||||||
|
extension URL {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
static let iTunes: URL = .init(string: "https://itunes.apple.com") ?? .bitBucket
|
||||||
|
|
||||||
|
}
|
51
Libraries/iTunes/Kit/Models/Feed.swift
Normal file
51
Libraries/iTunes/Kit/Models/Feed.swift
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
//
|
||||||
|
// Feed.swift
|
||||||
|
// ReviewsiTunesKit
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 17/03/2024.
|
||||||
|
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import ReviewsFeedKit
|
||||||
|
|
||||||
|
struct Feed {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
let entries: [Review]
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Decodable
|
||||||
|
extension Feed: Decodable {
|
||||||
|
|
||||||
|
// MARK: Enumerations
|
||||||
|
enum FeedKeys: String, CodingKey {
|
||||||
|
case feed
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EntryKeys: String, CodingKey {
|
||||||
|
case entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Initialisers
|
||||||
|
init(from decoder: any Decoder) throws {
|
||||||
|
let feed = try decoder.container(keyedBy: FeedKeys.self)
|
||||||
|
let feedEntry = try feed.nestedContainer(keyedBy: EntryKeys.self, forKey: .feed)
|
||||||
|
|
||||||
|
self.entries = try feedEntry.decode([Review].self, forKey: .entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Encodable
|
||||||
|
extension Feed: Encodable {
|
||||||
|
|
||||||
|
// MARK: Functions
|
||||||
|
func encode(to encoder: any Encoder) throws {
|
||||||
|
var feed = encoder.container(keyedBy: FeedKeys.self)
|
||||||
|
var feedEntry = feed.nestedContainer(keyedBy: EntryKeys.self, forKey: .feed)
|
||||||
|
|
||||||
|
try feedEntry.encode(entries, forKey: .entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
84
Libraries/iTunes/Kit/Models/Review+Codable.swift
Normal file
84
Libraries/iTunes/Kit/Models/Review+Codable.swift
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
//
|
||||||
|
// Review+Codable.swift
|
||||||
|
// ReviewsiTunesKit
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 17/03/2024.
|
||||||
|
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ReviewsFeedKit
|
||||||
|
|
||||||
|
// MARK: - Decodable
|
||||||
|
extension Review: Decodable {
|
||||||
|
|
||||||
|
// MARK: Enumerations
|
||||||
|
enum ReviewKeys: String, CodingKey {
|
||||||
|
case author
|
||||||
|
case content
|
||||||
|
case id
|
||||||
|
case rating = "im:rating"
|
||||||
|
case title
|
||||||
|
case updated
|
||||||
|
case version = "im:version"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NameKeys: String, CodingKey {
|
||||||
|
case name
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LabelKeys: String, CodingKey {
|
||||||
|
case label
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Initialisers
|
||||||
|
public init(from decoder: any Decoder) throws {
|
||||||
|
let review = try decoder.container(keyedBy: ReviewKeys.self)
|
||||||
|
let authorName = try review.nestedContainer(keyedBy: NameKeys.self, forKey: .author)
|
||||||
|
let authorLabel = try authorName.nestedContainer(keyedBy: LabelKeys.self, forKey: .name)
|
||||||
|
let contentLabel = try review.nestedContainer(keyedBy: LabelKeys.self, forKey: .content)
|
||||||
|
let idLabel = try review.nestedContainer(keyedBy: LabelKeys.self, forKey: .id)
|
||||||
|
let ratingLabel = try review.nestedContainer(keyedBy: LabelKeys.self, forKey: .rating)
|
||||||
|
let titleLabel = try review.nestedContainer(keyedBy: LabelKeys.self, forKey: .title)
|
||||||
|
let versionLabel = try review.nestedContainer(keyedBy: LabelKeys.self, forKey: .version)
|
||||||
|
let updatedLabel = try review.nestedContainer(keyedBy: LabelKeys.self, forKey: .updated)
|
||||||
|
|
||||||
|
self.init(
|
||||||
|
id: Int(try idLabel.decode(String.self, forKey: .label)) ?? 0,
|
||||||
|
author: try authorLabel.decode(String.self, forKey: .label),
|
||||||
|
title: try titleLabel.decode(String.self, forKey: .label),
|
||||||
|
content: try contentLabel.decode(String.self, forKey: .label),
|
||||||
|
rating: Int(try ratingLabel.decode(String.self, forKey: .label)) ?? 0,
|
||||||
|
version: try versionLabel.decode(String.self, forKey: .label),
|
||||||
|
updated: try updatedLabel.decode(Date.self, forKey: .label)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Encodable
|
||||||
|
extension Review: Encodable {
|
||||||
|
|
||||||
|
// MARK: Functions
|
||||||
|
|
||||||
|
public func encode(to encoder: any Encoder) throws {
|
||||||
|
var review = encoder.container(keyedBy: ReviewKeys.self)
|
||||||
|
var authorName = review.nestedContainer(keyedBy: NameKeys.self, forKey: .author)
|
||||||
|
var authorLabel = authorName.nestedContainer(keyedBy: LabelKeys.self, forKey: .name)
|
||||||
|
var contentLabel = review.nestedContainer(keyedBy: LabelKeys.self, forKey: .content)
|
||||||
|
var idLabel = review.nestedContainer(keyedBy: LabelKeys.self, forKey: .id)
|
||||||
|
var ratingLabel = review.nestedContainer(keyedBy: LabelKeys.self, forKey: .rating)
|
||||||
|
var titleLabel = review.nestedContainer(keyedBy: LabelKeys.self, forKey: .title)
|
||||||
|
var versionLabel = review.nestedContainer(keyedBy: LabelKeys.self, forKey: .version)
|
||||||
|
var updatedLabel = review.nestedContainer(keyedBy: LabelKeys.self, forKey: .updated)
|
||||||
|
|
||||||
|
try authorLabel.encode(author, forKey: .label)
|
||||||
|
try contentLabel.encode(content, forKey: .label)
|
||||||
|
try idLabel.encode(String(id), forKey: .label)
|
||||||
|
try ratingLabel.encode(String(rating), forKey: .label)
|
||||||
|
try titleLabel.encode(title, forKey: .label)
|
||||||
|
try versionLabel.encode(version, forKey: .label)
|
||||||
|
try updatedLabel.encode(updated, forKey: .label)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
43
Libraries/iTunes/Kit/Services/iTunesService.swift
Normal file
43
Libraries/iTunes/Kit/Services/iTunesService.swift
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
//
|
||||||
|
// iTunesService.swift
|
||||||
|
// ReviewsiTunes
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 17/03/2024.
|
||||||
|
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ReviewsFeedKit
|
||||||
|
|
||||||
|
public actor iTunesService: Service {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
public let configuration: ServiceConfiguration
|
||||||
|
public let decoder: JSONDecoder
|
||||||
|
public let session: URLSession
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
private lazy var getReviewsEndpoint: GetReviewsAPIEndpoint = {
|
||||||
|
.init(
|
||||||
|
host: configuration.host,
|
||||||
|
decoder: decoder,
|
||||||
|
session: session
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// MARK: Initialisers
|
||||||
|
public init(configuration: ServiceConfiguration) {
|
||||||
|
self.configuration = configuration
|
||||||
|
self.decoder = configuration.decoder
|
||||||
|
self.session = .init(configuration: configuration.session)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Functions
|
||||||
|
@discardableResult public func getReviews(
|
||||||
|
_ input: GetReviewsInput
|
||||||
|
) async throws -> GetReviewsOutput {
|
||||||
|
try await getReviewsEndpoint(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
58
Libraries/iTunes/Test/Helpers/Extensions/Array+Reviews.swift
Normal file
58
Libraries/iTunes/Test/Helpers/Extensions/Array+Reviews.swift
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
//
|
||||||
|
// Array+Reviews.swift
|
||||||
|
// ReviewsiTunesTest
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 17/03/2024.
|
||||||
|
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import ReviewsFeedKit
|
||||||
|
|
||||||
|
extension Array where Element == Review {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
static let empty: [Review] = []
|
||||||
|
|
||||||
|
static let many: [Review] = [
|
||||||
|
.init(
|
||||||
|
id: 1,
|
||||||
|
author: "Some author name #1 goes here...",
|
||||||
|
title: "Some title #1 goes here...",
|
||||||
|
content: "Some content #1 goes here...",
|
||||||
|
rating: 1,
|
||||||
|
version: "Some version #1 goes here...",
|
||||||
|
updated: .init()
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
id: 2,
|
||||||
|
author: "Some author name #2 goes here...",
|
||||||
|
title: "Some title #2 goes here...",
|
||||||
|
content: "Some content #2 goes here...",
|
||||||
|
rating: 5,
|
||||||
|
version: "Some version #2 goes here...",
|
||||||
|
updated: .init()
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
id: 3,
|
||||||
|
author: "Some author name #3 goes here...",
|
||||||
|
title: "Some title #3 goes here...",
|
||||||
|
content: "Some content #3 goes here...",
|
||||||
|
rating: 3,
|
||||||
|
version: "Some version #3 goes here...",
|
||||||
|
updated: .init()
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
static let one: [Review] = [
|
||||||
|
.init(
|
||||||
|
id: 1,
|
||||||
|
author: "Some author name goes here...",
|
||||||
|
title: "Some title goes here...",
|
||||||
|
content: "Some content goes here...",
|
||||||
|
rating: 3,
|
||||||
|
version: "Some version goes here...",
|
||||||
|
updated: .init()
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,161 @@
|
|||||||
|
//
|
||||||
|
// GetReviewsAPIEndpointTests.swift
|
||||||
|
// ReviewsiTunesTest
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 17/03/2024.
|
||||||
|
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import ReviewsFeedKit
|
||||||
|
import ReviewsFoundationKit
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import ReviewsiTunesKit
|
||||||
|
|
||||||
|
final class GetReviewsAPIEndpointTests: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
private var input: GetReviewsAPIEndpoint.Input!
|
||||||
|
private var sut: GetReviewsAPIEndpoint!
|
||||||
|
|
||||||
|
// MARK: Setup
|
||||||
|
override func setUp() async throws {
|
||||||
|
sut = .init(
|
||||||
|
host: .iTunes,
|
||||||
|
decoder: .default,
|
||||||
|
session: .init(configuration: .mock)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Functions tests
|
||||||
|
func testCallAsFunction_whenResponseOK_withOneReview() async throws {
|
||||||
|
// GIVEN
|
||||||
|
let reviews: [Review] = .one
|
||||||
|
|
||||||
|
input = .init(
|
||||||
|
appID: "1234567890",
|
||||||
|
countryCode: "abc"
|
||||||
|
)
|
||||||
|
|
||||||
|
MockURLProtocol.response = .init(
|
||||||
|
statusCode: 200,
|
||||||
|
object: Feed(entries: reviews)
|
||||||
|
)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
let output = try await sut(input)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
XCTAssertFalse(output.reviews.isEmpty)
|
||||||
|
XCTAssertEqual(output.reviews, reviews)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCallAsFunction_whenResponseOK_withManyReviews() async throws {
|
||||||
|
// GIVEN
|
||||||
|
let reviews: [Review] = .many
|
||||||
|
|
||||||
|
input = .init(
|
||||||
|
appID: "1234567890",
|
||||||
|
countryCode: "abc"
|
||||||
|
)
|
||||||
|
|
||||||
|
MockURLProtocol.response = .init(
|
||||||
|
statusCode: 200,
|
||||||
|
object: Feed(entries: reviews)
|
||||||
|
)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
let output = try await sut(input)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
XCTAssertFalse(output.reviews.isEmpty)
|
||||||
|
XCTAssertEqual(output.reviews, reviews)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCallAsFunction_whenResponseOK_withEmptyReviews() async throws {
|
||||||
|
// GIVEN
|
||||||
|
let reviews: [Review] = .empty
|
||||||
|
|
||||||
|
input = .init(
|
||||||
|
appID: "1234567890",
|
||||||
|
countryCode: "abc"
|
||||||
|
)
|
||||||
|
|
||||||
|
MockURLProtocol.response = .init(
|
||||||
|
statusCode: 200,
|
||||||
|
object: Feed(entries: reviews)
|
||||||
|
)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
let output = try await sut(input)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
XCTAssertTrue(output.reviews.isEmpty)
|
||||||
|
XCTAssertEqual(output.reviews, reviews)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCallAsFunction_whenResponseNotOK() async throws {
|
||||||
|
// GIVEN
|
||||||
|
let statusCode = 404
|
||||||
|
|
||||||
|
input = .init(
|
||||||
|
appID: "1234567890",
|
||||||
|
countryCode: "abc"
|
||||||
|
)
|
||||||
|
|
||||||
|
MockURLProtocol.response = .init(statusCode: statusCode)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
// THEN
|
||||||
|
do {
|
||||||
|
try await sut(input)
|
||||||
|
} catch EndpointError.requestFailed(statusCode: statusCode) {
|
||||||
|
XCTAssertTrue(true)
|
||||||
|
} catch {
|
||||||
|
XCTAssertTrue(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMakePath_withFilledInput() throws {
|
||||||
|
// GIVEN
|
||||||
|
input = .init(
|
||||||
|
appID: "1234567890",
|
||||||
|
countryCode: "abc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
let path = try sut.makePath(with: input)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
XCTAssertEqual(path, "/\(input.countryCode)/rss/customerreviews/id=\(input.appID)/sortby=mostrecent/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMakePath_withPartiallyFilledInput() throws {
|
||||||
|
// GIVEN
|
||||||
|
input = .init(
|
||||||
|
appID: .empty,
|
||||||
|
countryCode: "abc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
// THEN
|
||||||
|
XCTAssertThrowsError(try sut.makePath(with: input)) { error in
|
||||||
|
XCTAssertEqual(error as? EndpointError, .inputParametersEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMakePath_withEmptyInput() throws {
|
||||||
|
// GIVEN
|
||||||
|
input = .init(
|
||||||
|
appID: .empty,
|
||||||
|
countryCode: .empty
|
||||||
|
)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
// THEN
|
||||||
|
XCTAssertThrowsError(try sut.makePath(with: input)) { error in
|
||||||
|
XCTAssertEqual(error as? EndpointError, .inputParametersEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
108
Libraries/iTunes/Test/Tests/Services/iTunesServiceTests.swift
Normal file
108
Libraries/iTunes/Test/Tests/Services/iTunesServiceTests.swift
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
//
|
||||||
|
// iTunesServiceTests.swift
|
||||||
|
// ReviewsiTunesTest
|
||||||
|
//
|
||||||
|
// Created by Javier Cicchelli on 17/03/2024.
|
||||||
|
// Copyright © 2024 Röck+Cöde VoF. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import ReviewsFeedKit
|
||||||
|
import ReviewsFoundationKit
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import ReviewsiTunesKit
|
||||||
|
|
||||||
|
final class iTunesServiceTests: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
private var sut: iTunesService!
|
||||||
|
|
||||||
|
// MARK: Setup
|
||||||
|
override func setUp() async throws {
|
||||||
|
sut = .init(configuration: .init(session: .mock))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Functions
|
||||||
|
func testGetReviews_whenResponseOK_withOneReview() async throws {
|
||||||
|
// GIVEN
|
||||||
|
let reviews: [Review] = .one
|
||||||
|
|
||||||
|
MockURLProtocol.response = .init(
|
||||||
|
statusCode: 200,
|
||||||
|
object: Feed(entries: reviews)
|
||||||
|
)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
let output = try await sut.getReviews(.init(
|
||||||
|
appID: "1234567890",
|
||||||
|
countryCode: "abc"
|
||||||
|
))
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
XCTAssertFalse(output.reviews.isEmpty)
|
||||||
|
XCTAssertEqual(output.reviews.count, reviews.count)
|
||||||
|
XCTAssertEqual(output.reviews, reviews)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGetReviews_whenResponseOK_withManyReview() async throws {
|
||||||
|
// GIVEN
|
||||||
|
let reviews: [Review] = .many
|
||||||
|
|
||||||
|
MockURLProtocol.response = .init(
|
||||||
|
statusCode: 200,
|
||||||
|
object: Feed(entries: reviews)
|
||||||
|
)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
let output = try await sut.getReviews(.init(
|
||||||
|
appID: "1234567890",
|
||||||
|
countryCode: "abc"
|
||||||
|
))
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
XCTAssertFalse(output.reviews.isEmpty)
|
||||||
|
XCTAssertEqual(output.reviews.count, reviews.count)
|
||||||
|
XCTAssertEqual(output.reviews, reviews)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGetReviews_whenResponseOK_withEmptyReview() async throws {
|
||||||
|
// GIVEN
|
||||||
|
let reviews: [Review] = .empty
|
||||||
|
|
||||||
|
MockURLProtocol.response = .init(
|
||||||
|
statusCode: 200,
|
||||||
|
object: Feed(entries: reviews)
|
||||||
|
)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
let output = try await sut.getReviews(.init(
|
||||||
|
appID: "1234567890",
|
||||||
|
countryCode: "abc"
|
||||||
|
))
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
XCTAssertTrue(output.reviews.isEmpty)
|
||||||
|
XCTAssertEqual(output.reviews, reviews)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGetReviews_whenResponseNotOK() async throws {
|
||||||
|
// GIVEN
|
||||||
|
let statusCode = 404
|
||||||
|
|
||||||
|
MockURLProtocol.response = .init(statusCode: statusCode)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
// THEN
|
||||||
|
do {
|
||||||
|
try await sut.getReviews(.init(
|
||||||
|
appID: "1234567890",
|
||||||
|
countryCode: "abc"
|
||||||
|
))
|
||||||
|
} catch EndpointError.requestFailed(statusCode: statusCode) {
|
||||||
|
XCTAssertTrue(true)
|
||||||
|
} catch {
|
||||||
|
XCTAssertTrue(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user