Skip to content

Commit a82a1d0

Browse files
authored
Extra profile refresh for the new users (#615)
2 parents 26a9066 + 43091a0 commit a82a1d0

File tree

2 files changed

+99
-25
lines changed

2 files changed

+99
-25
lines changed

Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,24 +29,7 @@ class AvatarPickerViewModel: ObservableObject {
2929
@Published private(set) var backendSelectedAvatarURL: URL?
3030
@Published private(set) var gridResponseStatus: Result<Void, Error>?
3131
@Published private(set) var grid: AvatarGridModel = .init(avatars: [])
32-
33-
private var profileResult: Result<ProfileSummaryModel, Error>? {
34-
didSet {
35-
switch profileResult {
36-
case .success(let value):
37-
profileModel = .init(
38-
displayName: value.displayName,
39-
location: value.location,
40-
profileURL: value.profileURL,
41-
pronunciation: value.pronunciation,
42-
pronouns: value.pronouns
43-
)
44-
default:
45-
profileModel = nil
46-
}
47-
}
48-
}
49-
32+
@Published private(set) var profileResult: Result<ProfileSummaryModel, Error>?
5033
@Published var isProfileLoading: Bool = false
5134
@Published private(set) var isAvatarsLoading: Bool = false
5235
@Published var avatarIdentifier: AvatarIdentifier?
@@ -55,6 +38,7 @@ class AvatarPickerViewModel: ObservableObject {
5538
@Published var shouldDisplayNoSelectedAvatarWarning: Bool = false
5639
@ObservedObject var toastManager: ToastManager = .init()
5740
private var cancellables = Set<AnyCancellable>()
41+
private(set) var compensatingFetchProfileTask: Task<Void, Never>? // for unit testing
5842

5943
init(
6044
email: Email,
@@ -128,6 +112,52 @@ class AvatarPickerViewModel: ObservableObject {
128112
}
129113
.assign(to: \.shouldDisplayNoSelectedAvatarWarning, on: self)
130114
.store(in: &cancellables)
115+
116+
$profileResult
117+
.combineLatest($gridResponseStatus)
118+
.map { profileResult, gridResponseStatus in
119+
let isProfileStatus404 = switch profileResult {
120+
case .failure(APIError.responseError(let .invalidHTTPStatusCode(response, _))) where response.statusCode == HTTPStatus.notFound.rawValue:
121+
true
122+
default:
123+
false
124+
}
125+
126+
let isGridResponseSuccess = switch gridResponseStatus {
127+
case .success:
128+
true
129+
default:
130+
false
131+
}
132+
return isProfileStatus404 && isGridResponseSuccess
133+
}
134+
.filter { $0 == true }
135+
.removeDuplicates()
136+
.sink { [weak self] _ in
137+
self?.compensatingFetchProfileTask = Task {
138+
// Profile does not exist but `/v3/me/avatars` is success. This means it's a new account. Backend creates a new
139+
// profile during the first GET `/v3/me/avatars` of a new user. So we refresh the profile to fetch it.
140+
// Happens only when the token is passed externally.
141+
await self?.fetchProfile()
142+
}
143+
}
144+
.store(in: &cancellables)
145+
146+
$profileResult.sink { [weak self] profileResult in
147+
switch profileResult {
148+
case .success(let value):
149+
self?.profileModel = .init(
150+
displayName: value.displayName,
151+
location: value.location,
152+
profileURL: value.profileURL,
153+
pronunciation: value.pronunciation,
154+
pronouns: value.pronouns
155+
)
156+
default:
157+
self?.profileModel = nil
158+
}
159+
}
160+
.store(in: &cancellables)
131161
}
132162

133163
func selectAvatar(with id: String) async -> Avatar? {

Tests/GravatarUITests/AvatarPickerViewModelTests.swift

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ final class AvatarPickerViewModelTests {
1515
}
1616

1717
static func createModel(
18-
session: URLSessionAvatarPickerMock = .init(),
18+
session: URLSessionProtocol = URLSessionAvatarPickerMock(),
1919
imageDownloader: ImageDownloader = TestImageFetcher(result: .success)
2020
) -> AvatarPickerViewModel {
2121
.init(
@@ -209,7 +209,7 @@ final class AvatarPickerViewModelTests {
209209

210210
@Test
211211
func testUploadErrorTooLarge() async throws {
212-
model = Self.createModel(session: .init(returnErrorCode: HTTPStatus.payloadTooLarge.rawValue))
212+
model = Self.createModel(session: URLSessionAvatarPickerMock(returnErrorCode: HTTPStatus.payloadTooLarge.rawValue))
213213
model.grid.setAvatars([])
214214

215215
await confirmation { confirmation in
@@ -277,7 +277,7 @@ final class AvatarPickerViewModelTests {
277277
@Test("Test success deletion when the response is a 404 error")
278278
func testDeleteError404() async throws {
279279
let avatarToDelete = Self.createImageModel(id: "1", source: .remote(url: ""))
280-
model = Self.createModel(session: .init(returnErrorCode: HTTPStatus.notFound.rawValue))
280+
model = Self.createModel(session: URLSessionAvatarPickerMock(returnErrorCode: HTTPStatus.notFound.rawValue))
281281
model.grid.setAvatars([avatarToDelete])
282282

283283
#expect(await model.delete(avatarToDelete))
@@ -287,7 +287,7 @@ final class AvatarPickerViewModelTests {
287287
@Test("Test success deletion of selected avatar when the response is a 404 error")
288288
func testDeleteSelectedAvatarError404() async throws {
289289
let avatarToDelete = Self.createImageModel(id: "1", source: .remote(url: ""), isSelected: true)
290-
model = Self.createModel(session: .init(returnErrorCode: HTTPStatus.notFound.rawValue))
290+
model = Self.createModel(session: URLSessionAvatarPickerMock(returnErrorCode: HTTPStatus.notFound.rawValue))
291291
model.grid.setAvatars([avatarToDelete])
292292
#expect(model.grid.selectedAvatar != nil)
293293

@@ -308,7 +308,7 @@ final class AvatarPickerViewModelTests {
308308
@Test("Test error deletion when the response is an error different to 404")
309309
func testDeleteErrorFails() async throws {
310310
let avatarToDelete = Self.createImageModel(id: "1", source: .remote(url: ""))
311-
model = Self.createModel(session: .init(returnErrorCode: HTTPStatus.unauthorized.rawValue))
311+
model = Self.createModel(session: URLSessionAvatarPickerMock(returnErrorCode: HTTPStatus.unauthorized.rawValue))
312312
model.grid.setAvatars([avatarToDelete])
313313

314314
#expect(await model.delete(avatarToDelete) == false, "Delete request should fail")
@@ -345,7 +345,7 @@ final class AvatarPickerViewModelTests {
345345
)
346346
func changeAvatarRatingReturnsError(httpStatus: HTTPStatus) async throws {
347347
let testAvatarID = "991a7b71cf9f34..."
348-
model = Self.createModel(session: .init(returnErrorCode: httpStatus.rawValue))
348+
model = Self.createModel(session: URLSessionAvatarPickerMock(returnErrorCode: httpStatus.rawValue))
349349

350350
await model.refresh()
351351
let avatar = try #require(model.grid.avatars.first(where: { $0.id == testAvatarID }), "No avatar found")
@@ -381,12 +381,30 @@ final class AvatarPickerViewModelTests {
381381
#expect(updatedAvatar.altText == newAltText)
382382
}
383383

384+
@Test
385+
func testNewAccountProfileRefresh() async throws {
386+
let session = URLSessionAvatarPickerMockNewAccount()
387+
model = Self.createModel(session: session)
388+
await model.refresh()
389+
let task = try #require(model.compensatingFetchProfileTask, "No profile task found")
390+
// `await confirmation { ... }` doesn't await the unstructured `Task` so we need to await it.
391+
// For context: https://forums.swift.org/t/testing-closure-based-asynchronous-apis/73705/9
392+
await task.value
393+
let isProfileFetched = switch model.profileResult {
394+
case .success:
395+
true
396+
default:
397+
false
398+
}
399+
#expect(isProfileFetched)
400+
}
401+
384402
@Test(
385403
"Handle avatar alt text change: Failure",
386404
arguments: [HTTPStatus.unauthorized, .forbidden]
387405
)
388406
func testUpdateAltTextError(httpStatus: HTTPStatus) async throws {
389-
model = Self.createModel(session: .init(returnErrorCode: httpStatus.rawValue))
407+
model = Self.createModel(session: URLSessionAvatarPickerMock(returnErrorCode: httpStatus.rawValue))
390408
await model.refresh()
391409
let avatar = model.grid.avatars[0]
392410
let originalAltText = avatar.altText
@@ -540,3 +558,29 @@ extension Data? {
540558
return (try? decoder.decode(T.self, from: self)) != nil
541559
}
542560
}
561+
562+
// Simulates profile creation at the BE side on the very first avatar request.
563+
actor URLSessionAvatarPickerMockNewAccount: URLSessionProtocol {
564+
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
565+
if request.isProfilesRequest {
566+
if profileRequestCount == 0 {
567+
profileRequestCount += 1
568+
// profile is missing on the first call
569+
return ("{\"error\":\"error\"".data(using: .utf8)!, HTTPURLResponse.errorResponse(code: 404))
570+
} else {
571+
return (Bundle.fullProfileJsonData, HTTPURLResponse.successResponse()) // Profile data
572+
}
573+
} else if request.isAvatarsRequest == true {
574+
return (Bundle.getAvatarsJsonData, HTTPURLResponse.successResponse()) // Avatars data
575+
}
576+
throw TestURLSessionError(message: "Unexpected request")
577+
}
578+
579+
func upload(for request: URLRequest, from bodyData: Data) async throws -> (Data, URLResponse) {
580+
(Bundle.postAvatarUploadJsonData, HTTPURLResponse.successResponse())
581+
}
582+
583+
private var profileRequestCount: Int = 0
584+
585+
init() {}
586+
}

0 commit comments

Comments
 (0)