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
140
141
142
143
144
145
import CryptoKit
@testable import Grain
import XCTest
/// The strip is where the server's viewed state arrives, and the endpoint is
/// how ours goes back. Both ends of the round trip, against the mock transport.
@MainActor
final class StoryViewSyncTests: GrainTestCase {
private var client: XRPCClient!
private var storage: ViewedStoryStorage!
private let testDID = "did:plc:storyviewsynctests"
override func setUp() {
super.setUp()
AccountScopedStorage.purge(did: testDID)
client = XRPCClient(baseURL: URL(string: "https://test.local")!, session: MockURLProtocol.mockSession())
storage = ViewedStoryStorage(did: testDID)
}
override func tearDown() {
MockURLProtocol.handler = nil
AccountScopedStorage.purge(did: testDID)
storage = nil
super.tearDown()
}
func testLoadingTheStripTakesOnWhatWasWatchedElsewhere() async {
MockURLProtocol.respondWithJSON("""
{
"authors": [
{
"profile": {"cid": "c1", "did": "did:plc:alice", "handle": "alice.test"},
"storyCount": 2,
"latestAt": "2026-09-04T12:00:00.000Z",
"lastViewedAt": "2026-09-04T12:00:00.000Z",
"unviewedCount": 0
},
{
"profile": {"cid": "c2", "did": "did:plc:bob", "handle": "bob.test"},
"storyCount": 1,
"latestAt": "2026-09-04T11:00:00.000Z",
"unviewedCount": 1
}
]
}
""")
let cache = StoryStatusCache()
let vm = StoryStripViewModel(client: client)
await vm.load(auth: nil, storyStatusCache: cache, viewedStories: storage)
XCTAssertEqual(vm.authors.map(\.profile.did), ["did:plc:alice", "did:plc:bob"])
XCTAssertEqual(vm.authors.first?.unviewedCount, 0)
XCTAssertTrue(storage.hasViewedAll(authorDid: "did:plc:alice", latestAt: "2026-09-04T12:00:00.000Z"))
XCTAssertFalse(storage.hasViewedAll(authorDid: "did:plc:bob", latestAt: "2026-09-04T11:00:00.000Z"))
XCTAssertEqual(storage.pendingUploadCount, 0, "Nothing the server said goes back up to it")
}
func testAStoryDecodesItsViewedFlag() throws {
let json = """
{
"uri": "at://did:plc:alice/social.grain.story/s1",
"cid": "c",
"creator": {"cid": "c1", "did": "did:plc:alice", "handle": "alice.test"},
"thumb": "", "fullsize": "",
"aspectRatio": {"width": 9, "height": 16},
"createdAt": "2026-09-04T12:00:00.000Z",
"viewer": {"viewed": true}
}
"""
let story = try JSONDecoder().decode(GrainStory.self, from: Data(json.utf8))
XCTAssertEqual(story.viewer?.viewed, true)
XCTAssertNil(story.viewer?.fav)
}
func testMarkingSendsTheUrisToTheEndpoint() async throws {
let recorder = RequestRecorder()
MockURLProtocol.handler = { request in
recorder.record(path: request.url!.path, body: RequestRecorder.body(of: request))
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (Data("{}".utf8), response)
}
try await client.markStoriesViewed(
uris: ["at://did:plc:alice/social.grain.story/s1"],
auth: AuthContext(accessToken: "t", dpop: DPoP(privateKey: P256.Signing.PrivateKey()), did: "did:plc:test", pds: XCTUnwrap(URL(string: "https://test.local")))
)
XCTAssertEqual(recorder.paths, ["/xrpc/social.grain.story.markViewed"])
let body = try XCTUnwrap(recorder.bodies.first)
let decoded = try JSONDecoder().decode([String: [String]].self, from: body)
XCTAssertEqual(decoded, ["stories": ["at://did:plc:alice/social.grain.story/s1"]])
}
}
/// Requests seen by the mock transport, safe to read from the test after the
/// handler ran on a URLSession thread.
private final class RequestRecorder: @unchecked Sendable {
private let lock = NSLock()
private var storedPaths: [String] = []
private var storedBodies: [Data] = []
func record(path: String, body: Data?) {
lock.lock()
defer { lock.unlock() }
storedPaths.append(path)
if let body {
storedBodies.append(body)
}
}
var paths: [String] {
lock.lock()
defer { lock.unlock() }
return storedPaths
}
var bodies: [Data] {
lock.lock()
defer { lock.unlock() }
return storedBodies
}
/// `URLProtocol` moves the body into a stream before the handler sees it.
static func body(of request: URLRequest) -> Data? {
if let body = request.httpBody {
return body
}
guard let stream = request.httpBodyStream else { return nil }
stream.open()
defer { stream.close() }
var data = Data()
let bufferSize = 4096
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
defer { buffer.deallocate() }
while stream.hasBytesAvailable {
let read = stream.read(buffer, maxLength: bufferSize)
if read <= 0 {
break
}
data.append(buffer, count: read)
}
return data
}
}