1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
@testable import Grain
import XCTest
@MainActor
final class ProfileDetailViewModelTests: GrainTestCase {
private var client: XRPCClient!
private var vm: ProfileDetailViewModel!
/// `TokenStorage.userDID` is Keychain-backed and the test host shares that
/// Keychain with the installed app, so writing it here would leave the
/// simulator's signed-in account pointing at a bogus DID. Stash the real
/// value and put it back.
private var savedUserDID: String?
override func setUp() {
super.setUp()
savedUserDID = TokenStorage.userDID
client = XRPCClient(baseURL: URL(string: "https://test.local")!, session: MockURLProtocol.mockSession())
vm = ProfileDetailViewModel(client: client)
}
override func tearDown() {
TokenStorage.userDID = savedUserDID
MockURLProtocol.handler = nil
super.tearDown()
}
// MARK: - Helpers
private func makeProfile(
did: String = "did:plc:target",
followersCount: Int? = 10,
following: String? = nil
) -> GrainProfileDetailed {
GrainProfileDetailed(
cid: "cid",
did: did,
handle: "target.test",
followersCount: followersCount,
viewer: ActorViewerState(following: following)
)
}
private func makeDummyAuth() -> AuthContext {
// DPoP needs a real key for signing — create one for tests
do {
let key = try CryptoKit.P256.Signing.PrivateKey()
let dpop = DPoP(privateKey: key)
return AuthContext(accessToken: "test-token", dpop: dpop, did: "did:plc:test", pds: URL(string: "https://test.local")!)
} catch {
XCTFail("Failed to create P256 private key: \(error)")
fatalError("Unreachable")
}
}
// MARK: - toggleFollow (follow)
func testFollowOptimisticallyUpdatesState() async {
vm.profile = makeProfile(followersCount: 10, following: nil)
TokenStorage.userDID = "did:plc:me"
// Mock createRecord response
MockURLProtocol.respondWithJSON("""
{"uri": "at://did:plc:me/social.grain.graph.follow/abc123", "cid": "newcid"}
""")
await vm.toggleFollow(auth: makeDummyAuth())
XCTAssertEqual(vm.profile?.followersCount, 11)
XCTAssertEqual(vm.profile?.viewer?.following, "at://did:plc:me/social.grain.graph.follow/abc123")
}
func testFollowRollsBackOnFailure() async {
vm.profile = makeProfile(followersCount: 10, following: nil)
TokenStorage.userDID = "did:plc:me"
MockURLProtocol.respondWithError(statusCode: 500)
await vm.toggleFollow(auth: makeDummyAuth())
// Should roll back to original state
XCTAssertEqual(vm.profile?.followersCount, 10)
XCTAssertNil(vm.profile?.viewer?.following)
}
// MARK: - toggleFollow (unfollow)
func testUnfollowOptimisticallyUpdatesState() async {
vm.profile = makeProfile(followersCount: 10, following: "at://did:plc:me/social.grain.graph.follow/xyz")
MockURLProtocol.respondWithJSON("{}")
await vm.toggleFollow(auth: makeDummyAuth())
XCTAssertEqual(vm.profile?.followersCount, 9)
XCTAssertNil(vm.profile?.viewer?.following)
}
func testUnfollowRollsBackOnFailure() async {
let followUri = "at://did:plc:me/social.grain.graph.follow/xyz"
vm.profile = makeProfile(followersCount: 10, following: followUri)
MockURLProtocol.respondWithError(statusCode: 500)
await vm.toggleFollow(auth: makeDummyAuth())
XCTAssertEqual(vm.profile?.followersCount, 10)
XCTAssertEqual(vm.profile?.viewer?.following, followUri)
}
func testUnfollowClampsCountAtZero() async {
vm.profile = makeProfile(followersCount: 0, following: "at://did:plc:me/social.grain.graph.follow/xyz")
MockURLProtocol.respondWithJSON("{}")
await vm.toggleFollow(auth: makeDummyAuth())
XCTAssertEqual(vm.profile?.followersCount, 0)
}
// MARK: - toggleFollow guards
func testToggleFollowBailsWithoutAuth() async {
vm.profile = makeProfile()
let initialCount = vm.profile?.followersCount
await vm.toggleFollow(auth: nil)
XCTAssertEqual(vm.profile?.followersCount, initialCount)
}
func testToggleFollowBailsWithoutProfile() async {
vm.profile = nil
await vm.toggleFollow(auth: makeDummyAuth())
// Should not crash
}
}
import CryptoKit