~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
214 lines · 9.6 KB Raw History
import CryptoKit
@testable import Grain
import XCTest

@MainActor
final class StoryCommentsViewModelTests: GrainTestCase {
    private var client: XRPCClient!
    private var vm: StoryCommentsViewModel!

    private let storyA = "at://did:plc:test/social.grain.story/a"
    private let storyB = "at://did:plc:test/social.grain.story/b"

    override func setUp() {
        super.setUp()
        client = XRPCClient(baseURL: URL(string: "https://test.local")!, session: MockURLProtocol.mockSession())
        vm = StoryCommentsViewModel(client: client)
    }

    override func tearDown() {
        MockURLProtocol.handler = nil
        super.tearDown()
    }

    private func makeDummyAuth() -> AuthContext {
        let key = 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")!)
    }

    // MARK: - Preview Loading

    func testLoadPreviewSetsLatestCommentAndCount() async {
        MockURLProtocol.respondWithJSON("""
        {
            "comments": [
                {
                    "uri": "at://did:plc:a/social.grain.comment/1",
                    "cid": "c1",
                    "author": {"cid": "ca", "did": "did:plc:a", "handle": "alice.test"},
                    "text": "Great shot!",
                    "createdAt": "2024-06-15T12:00:00Z"
                }
            ],
            "totalCount": 5
        }
        """)

        await vm.loadPreview(storyUri: storyA)
        XCTAssertEqual(vm.firstComment?.text, "Great shot!")
        XCTAssertEqual(vm.totalCount, 5)
    }

    func testLoadPreviewCacheHitDoesNotFetchAgain() async {
        var requestCount = 0
        MockURLProtocol.handler = { request in
            requestCount += 1
            let json = """
            {"comments": [{"uri": "at://did:plc:a/social.grain.comment/1", "cid": "c1", "author": {"cid": "ca", "did": "did:plc:a", "handle": "alice.test"}, "text": "Cached", "createdAt": "2024-06-15T12:00:00Z"}], "totalCount": 1}
            """
            return (json.data(using: .utf8)!, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!)
        }

        await vm.loadPreview(storyUri: storyA)
        XCTAssertEqual(requestCount, 1)

        await vm.loadPreview(storyUri: storyA)
        XCTAssertEqual(requestCount, 1, "Second call should hit cache, not network")
    }

    func testLoadPreviewCacheMissFetchesBothStories() async {
        var requestCount = 0
        MockURLProtocol.handler = { request in
            requestCount += 1
            let json = """
            {"comments": [{"uri": "at://did:plc:a/social.grain.comment/\\(requestCount)", "cid": "c\\(requestCount)", "author": {"cid": "ca", "did": "did:plc:a", "handle": "alice.test"}, "text": "Comment \\(requestCount)", "createdAt": "2024-06-15T12:00:00Z"}], "totalCount": \\(requestCount)}
            """
            return (json.data(using: .utf8)!, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!)
        }

        await vm.loadPreview(storyUri: storyA)
        await vm.loadPreview(storyUri: storyB)
        XCTAssertEqual(requestCount, 2, "Different URIs should each trigger a request")
    }

    // MARK: - Full Comment Loading

    func testLoadCommentsPopulatesArray() async {
        MockURLProtocol.respondWithJSON("""
        {
            "comments": [
                {"uri": "at://did:plc:a/social.grain.comment/1", "cid": "c1", "author": {"cid": "ca", "did": "did:plc:a", "handle": "alice.test"}, "text": "Root comment", "createdAt": "2024-06-15T12:00:00Z"},
                {"uri": "at://did:plc:a/social.grain.comment/2", "cid": "c2", "author": {"cid": "cb", "did": "did:plc:b", "handle": "bob.test"}, "text": "Reply", "replyTo": "at://did:plc:a/social.grain.comment/1", "createdAt": "2024-06-15T12:01:00Z"},
                {"uri": "at://did:plc:a/social.grain.comment/3", "cid": "c3", "author": {"cid": "cc", "did": "did:plc:c", "handle": "carol.test"}, "text": "Another root", "createdAt": "2024-06-15T12:02:00Z"}
            ],
            "totalCount": 3
        }
        """)

        await vm.loadComments(storyUri: storyA)
        XCTAssertEqual(vm.comments.count, 3)
        XCTAssertEqual(vm.totalCount, 3)
        XCTAssertFalse(vm.isLoading)

        // Verify threading structure
        let roots = vm.comments.filter { $0.replyTo == nil }
        let replies = vm.comments.filter { $0.replyTo != nil }
        XCTAssertEqual(roots.count, 2)
        XCTAssertEqual(replies.count, 1)
    }

    func testLoadMoreCommentsAppends() async {
        // First load
        MockURLProtocol.respondWithJSON("""
        {"comments": [{"uri": "at://did:plc:a/social.grain.comment/1", "cid": "c1", "author": {"cid": "ca", "did": "did:plc:a", "handle": "alice.test"}, "text": "First", "createdAt": "2024-06-15T12:00:00Z"}], "cursor": "page2", "totalCount": 2}
        """)
        await vm.loadComments(storyUri: storyA)
        XCTAssertEqual(vm.comments.count, 1)

        // Paginate
        MockURLProtocol.respondWithJSON("""
        {"comments": [{"uri": "at://did:plc:a/social.grain.comment/2", "cid": "c2", "author": {"cid": "cb", "did": "did:plc:b", "handle": "bob.test"}, "text": "Second", "createdAt": "2024-06-15T12:01:00Z"}]}
        """)
        await vm.loadMoreComments(storyUri: storyA)
        XCTAssertEqual(vm.comments.count, 2)
    }

    // MARK: - CRUD

    func testPostCommentRefreshesAndInvalidatesCache() async {
        // Seed cache
        MockURLProtocol.respondWithJSON("""
        {"comments": [{"uri": "at://did:plc:a/social.grain.comment/old", "cid": "cold", "author": {"cid": "ca", "did": "did:plc:a", "handle": "alice.test"}, "text": "Old", "createdAt": "2024-06-15T12:00:00Z"}], "totalCount": 1}
        """)
        await vm.loadPreview(storyUri: storyA)
        XCTAssertEqual(vm.firstComment?.text, "Old")

        // Post triggers createRecord then loadComments refresh
        var requestPaths: [String] = []
        MockURLProtocol.handler = { request in
            requestPaths.append(request.url?.lastPathComponent ?? "")
            let json = if request.httpMethod == "POST" {
                """
                {"uri": "at://did:plc:test/social.grain.comment/new", "cid": "cnew"}
                """
            } else {
                """
                {"comments": [{"uri": "at://did:plc:a/social.grain.comment/new", "cid": "cnew", "author": {"cid": "ca", "did": "did:plc:a", "handle": "alice.test"}, "text": "Fresh", "createdAt": "2024-06-15T13:00:00Z"}], "totalCount": 2}
                """
            }
            return (json.data(using: .utf8)!, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!)
        }

        let auth = makeDummyAuth()
        vm.switchToStory(uri: storyA)
        await vm.postComment(text: "Hello", storyUri: storyA, auth: auth)
        XCTAssertEqual(vm.firstComment?.text, "Fresh")
        XCTAssertEqual(vm.totalCount, 2)
    }

    func testDeleteCommentRemovesFromArray() async {
        // Switch first so activeStoryUri is set; the background preview task will fail silently without a mock
        vm.switchToStory(uri: storyA)
        try? await Task.sleep(for: .milliseconds(50))

        MockURLProtocol.respondWithJSON("""
        {"comments": [
            {"uri": "at://did:plc:a/social.grain.comment/1", "cid": "c1", "author": {"cid": "ca", "did": "did:plc:a", "handle": "alice.test"}, "text": "Keep", "createdAt": "2024-06-15T12:00:00Z"},
            {"uri": "at://did:plc:a/social.grain.comment/2", "cid": "c2", "author": {"cid": "cb", "did": "did:plc:b", "handle": "bob.test"}, "text": "Delete me", "createdAt": "2024-06-15T12:01:00Z"}
        ], "totalCount": 2}
        """)
        await vm.loadComments(storyUri: storyA)
        XCTAssertEqual(vm.comments.count, 2)

        // Delete the second comment
        MockURLProtocol.respondWithJSON("{}")
        let toDelete = vm.comments[1]
        let auth = makeDummyAuth()
        await vm.deleteComment(toDelete, storyUri: storyA, auth: auth)
        XCTAssertEqual(vm.comments.count, 1)
        XCTAssertEqual(vm.comments.first?.text, "Keep")
        XCTAssertEqual(vm.totalCount, 1)
    }

    // MARK: - Cache Switching

    func testSwitchToStoryCachedRestoresWithoutFetch() async {
        // Load preview for story A
        var requestCount = 0
        MockURLProtocol.handler = { request in
            requestCount += 1
            let json = """
            {"comments": [{"uri": "at://did:plc:a/social.grain.comment/1", "cid": "c1", "author": {"cid": "ca", "did": "did:plc:a", "handle": "alice.test"}, "text": "Story A comment", "createdAt": "2024-06-15T12:00:00Z"}], "totalCount": 1}
            """
            return (json.data(using: .utf8)!, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!)
        }

        vm.switchToStory(uri: storyA)
        // Wait for the background task to complete
        try? await Task.sleep(for: .milliseconds(100))
        XCTAssertEqual(requestCount, 1)
        XCTAssertEqual(vm.firstComment?.text, "Story A comment")

        // Switch to story B
        vm.switchToStory(uri: storyB)
        try? await Task.sleep(for: .milliseconds(100))
        XCTAssertEqual(requestCount, 2)

        // Switch back to A — should use cache
        vm.switchToStory(uri: storyA)
        // No sleep needed — cache is synchronous
        XCTAssertEqual(requestCount, 2, "Switching back to A should use cache")
        XCTAssertEqual(vm.firstComment?.text, "Story A comment")
    }
}