Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions NativeAppTemplate/Sessions/SessionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import Observation
shopLimitCount = 0
// URLSession.api keeps no cache, but versions before it used
// URLCache.shared, which holds their API responses on disk
URLCache.shared.removeAllCachedResponses()
urlCache.removeAllCachedResponses()
}
}
}
Expand All @@ -60,6 +60,8 @@ import Observation
private(set) var permissionsService: PermissionsService
private(set) var meService: MeService
private let notificationCenter: NotificationCenter
/// Emptied on sign-out; URLCache.shared outside tests
let urlCache: URLCache
/// Removed in deinit, which is nonisolated
@ObservationIgnored private nonisolated(unsafe) var sessionExpiryObserver: (any NSObjectProtocol)?

Expand All @@ -81,15 +83,19 @@ import Observation
/// `connectionChanged(to:)` themselves, so no permission requests go out on their own.
/// - environment: The API the client talks to. Tests point it at a local server.
/// - notificationCenter: Where `Service` announces `.apiSessionDidExpire`
/// - urlCache: The cache emptied on sign-out. Tests pass their own, so another
/// test's sign-out can't empty it meanwhile.
init(
loginRepository: LoginRepositoryProtocol,
monitorsConnection: Bool = true,
environment: NativeAppTemplateEnvironment = .prod,
notificationCenter: NotificationCenter = .default
notificationCenter: NotificationCenter = .default,
urlCache: URLCache = .shared
) {
self.loginRepository = loginRepository
self.environment = environment
self.notificationCenter = notificationCenter
self.urlCache = urlCache

let shopkeeper = loginRepository.currentShopkeeper
let client = Self.makeClient(shopkeeper: shopkeeper, environment: environment)
Expand Down
1 change: 0 additions & 1 deletion NativeAppTemplateTests/App/InfoPlistTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import Testing
import UIKit

/// Checks the built app's Info.plist (Bundle.main is the app the tests are hosted in)
@Suite
struct InfoPlistTest {
/// Without NSLocalNetworkUsageDescription, iOS denied the app local network
/// access to a development API server on the LAN (NSURLErrorNotConnectedToInternet,
Expand Down
1 change: 0 additions & 1 deletion NativeAppTemplateTests/App/LocalizationTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import Testing
/// The app ships in English and Japanese. Every string catalog key must have a
/// Japanese translation, so a string added without one fails here instead of
/// showing up in English on a Japanese device.
@Suite
struct LocalizationTest {
/// The catalog tests read the .xcstrings sources from the Mac through #filePath,
/// which a simulator can do and a device cannot. They run on the simulator (and CI);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
@testable import NativeAppTemplate
import Testing

@Suite
struct EntityAdapterErrorTest {
@Test
func errorCodes() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import Foundation
import Testing

@Suite
struct KeychainStoreErrorTest {
@Test
func errorCodes() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//
// AccountPasswordRepositoryTest.swift
// NativeAppTemplate
//

import Foundation
@testable import NativeAppTemplate
import Testing

/// AccountPasswordRepository with the real AccountPasswordService and
/// UpdateAccountPasswordRequest, against a local server
@MainActor
@Suite(.serialized)
struct AccountPasswordRepositoryTest {
private let updatePassword = UpdatePassword(
currentPassword: "current-password",
password: "new-password",
passwordConfirmation: "new-password"
)

private func makeRepository(_ server: TestHTTPServer) -> AccountPasswordRepository {
AccountPasswordRepository(
accountPasswordService: AccountPasswordService(networkClient: server.client(accountId: "acc-1"))
)
}

/// UpdateAccountPasswordRequest: PATCH /shopkeeper/account/password under Service's
/// /{accountId}/api/v1, with UpdatePassword.toJson's body
@Test
func updatePatchesThePasswordWithTheSession() async throws {
let server = try await TestHTTPServer.start { _ in TestHTTPServer.json([String: Any]()) }
defer { server.stop() }

try await makeRepository(server).update(updatePassword: updatePassword)

let request = try #require(server.requests.first)
#expect(server.requests.count == 1)
#expect(request.method == "PATCH")
#expect(request.path == "/acc-1/api/v1/shopkeeper/account/password")
// The value TestHTTPServer.client sets
#expect(request.headers["access-token"] == "token")
let body = try #require(try JSONSerialization.jsonObject(with: request.body) as? [String: [String: String]])
#expect(body == ["shopkeeper": [
"current_password": "current-password",
"password": "new-password",
"password_confirmation": "new-password"
]])
}

/// The API's error reaches the caller (PasswordEditViewModel shows it)
@Test
func failedUpdateThrowsTheAPIError() async throws {
let server = try await TestHTTPServer.start { _ in
TestHTTPServer.json(status: 422, ["error_message": "Current password is invalid"])
}
defer { server.stop() }

let error = try await requestFailed {
try await makeRepository(server).update(updatePassword: updatePassword)
}

guard case let .requestFailed(_, statusCode, message) = error else {
Issue.record("Expected requestFailed, got \(error)")
return
}
#expect(statusCode == 422)
#expect(message == "Current password is invalid")
}
}
176 changes: 176 additions & 0 deletions NativeAppTemplateTests/Data/Repositories/ShopRepositoryTest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
//
// ShopRepositoryTest.swift
// NativeAppTemplate
//

import Foundation
@testable import NativeAppTemplate
import Testing

/// ShopRepository with the real ShopsService and requests, against a local server.
/// Bodies follow ShopSerializer (name, description, time_zone, item_tags_count,
/// completed_item_tags_count) and ShopsController#index's meta (limit_count, created_shops_count).
@MainActor
@Suite(.serialized)
struct ShopRepositoryTest {
private nonisolated static func shop(id: String, name: String = "Shop") -> [String: Any] {
[
"id": id,
"type": "shop",
"attributes": [
"name": name,
"description": "",
"time_zone": "Tokyo",
"item_tags_count": 3,
"completed_item_tags_count": 1
] as [String: Any]
]
}

private func makeRepository(_ server: TestHTTPServer) -> ShopRepository {
ShopRepository(shopsService: ShopsService(networkClient: server.client(accountId: "acc-1")))
}

@Test
func reloadLoadsTheShopsAndTheLimits() async throws {
let server = try await TestHTTPServer.start { _ in
TestHTTPServer.json([
"data": [Self.shop(id: "s1", name: "First"), Self.shop(id: "s2", name: "Second")],
"meta": ["limit_count": 5, "created_shops_count": 2]
])
}
defer { server.stop() }
let repository = makeRepository(server)

repository.reload()
await waitUntil { repository.state != .loading }

#expect(repository.state == .hasData)
#expect(repository.shops.map(\.name) == ["First", "Second"])
#expect(repository.shops.first?.itemTagsCount == 3)
#expect(repository.shops.first?.completedItemTagsCount == 1)
#expect(repository.limitCount == 5)
#expect(repository.createdShopsCount == 2)
#expect(server.requests.first?.method == "GET")
#expect(server.requests.first?.path == "/acc-1/api/v1/shopkeeper/shops")
}

/// GetShopsRequest requires both meta fields; without them the whole reload fails
@Test
func reloadWithoutTheMetaFails() async throws {
let server = try await TestHTTPServer.start { _ in
TestHTTPServer.json(["data": [Self.shop(id: "s1")], "meta": ["limit_count": 5]])
}
defer { server.stop() }
let repository = makeRepository(server)

repository.reload()
await waitUntil { repository.state != .loading }

#expect(repository.state == .failed)
#expect(repository.shops.isEmpty)
}

/// fetchDetail upserts: appends a shop it does not have, replaces one it has
@Test
func fetchDetailAppendsOrReplaces() async throws {
let server = try await TestHTTPServer.start { request in
request.path.hasSuffix("/s1")
? TestHTTPServer.json(["data": Self.shop(id: "s1", name: "Renamed")])
: TestHTTPServer.json(["data": Self.shop(id: "s9", name: "New")])
}
defer { server.stop() }
let repository = makeRepository(server)
repository.shops = [Shop(id: "s1", name: "Old", description: "", timeZone: "Tokyo")]

_ = try await repository.fetchDetail(id: "s1")
_ = try await repository.fetchDetail(id: "s9")

#expect(repository.shops.map(\.id) == ["s1", "s9"])
#expect(repository.shops.map(\.name) == ["Renamed", "New"])
}

/// create POSTs Shop.toJsonForCreate and returns the created shop, but does not
/// add it to `shops` (ShopListView reloads after creating)
@Test
func createSendsTheShopAndLeavesTheListAlone() async throws {
let server = try await TestHTTPServer.start { _ in
TestHTTPServer.json(status: 201, ["data": Self.shop(id: "s2", name: "Created")])
}
defer { server.stop() }
let repository = makeRepository(server)
repository.shops = [Shop(id: "s1", name: "Existing", description: "", timeZone: "Tokyo")]

let created = try await repository.create(
shop: Shop(id: "", name: "Created", description: "Desc", timeZone: "Osaka")
)

#expect(created.id == "s2")
#expect(repository.shops.map(\.id) == ["s1"])
let request = try #require(server.requests.first)
#expect(request.method == "POST")
#expect(request.path == "/acc-1/api/v1/shopkeeper/shops")
let body = try #require(try JSONSerialization.jsonObject(with: request.body) as? [String: [String: String]])
#expect(body == ["shop": ["name": "Created", "description": "Desc", "time_zone": "Osaka"]])
}

/// update replaces the shop in `shops` when it is there
@Test
func updateReplacesTheListedShop() async throws {
let server = try await TestHTTPServer.start { _ in
TestHTTPServer.json(["data": Self.shop(id: "s1", name: "Updated")])
}
defer { server.stop() }
let repository = makeRepository(server)
repository.shops = [Shop(id: "s1", name: "Old", description: "", timeZone: "Tokyo")]

_ = try await repository.update(
id: "s1",
shop: Shop(id: "s1", name: "Updated", description: "", timeZone: "Tokyo")
)

#expect(repository.shops.map(\.name) == ["Updated"])
#expect(server.requests.first?.method == "PATCH")
#expect(server.requests.first?.path == "/acc-1/api/v1/shopkeeper/shops/s1")
}

/// destroy sends DELETE but keeps `shops` as is (the list reloads afterwards)
@Test
func destroyLeavesTheListAlone() async throws {
let server = try await TestHTTPServer.start { _ in TestHTTPServer.response(status: 204) }
defer { server.stop() }
let repository = makeRepository(server)
repository.shops = [Shop(id: "s1", name: "Shop", description: "", timeZone: "Tokyo")]

try await repository.destroy(id: "s1")

#expect(server.requests.first?.method == "DELETE")
#expect(server.requests.first?.path == "/acc-1/api/v1/shopkeeper/shops/s1")
#expect(repository.shops.map(\.id) == ["s1"])
}

@Test
func failedUpdateThrowsAndKeepsTheShop() async throws {
let server = try await TestHTTPServer.start { _ in
TestHTTPServer.json(status: 422, ["error_message": "Name can't be blank"])
}
defer { server.stop() }
let repository = makeRepository(server)
repository.shops = [Shop(id: "s1", name: "Old", description: "", timeZone: "Tokyo")]

let error = try await requestFailed {
_ = try await repository.update(
id: "s1",
shop: Shop(id: "s1", name: "", description: "", timeZone: "Tokyo")
)
}

guard case let .requestFailed(_, statusCode, message) = error else {
Issue.record("Expected requestFailed, got \(error)")
return
}
#expect(statusCode == 422)
#expect(message == "Name can't be blank")
#expect(repository.shops.map(\.name) == ["Old"])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import Foundation
import Testing

@MainActor
@Suite
struct TabViewModelTest {
@Test
func initialState() {
Expand Down

This file was deleted.

Loading
Loading