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

grain.social/grain-ios

1 collaborator
0
1
main
196 lines · 6.2 KB Raw History
@testable import Grain
import XCTest

@MainActor
final class NotificationsViewModelTests: GrainTestCase {
    private var client: XRPCClient!
    private var vm: NotificationsViewModel!

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

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

    // MARK: - loadInitial

    func testLoadInitialPopulatesNotifications() async {
        MockURLProtocol.respondByPath([
            "listNotifications": """
            {
                "notifications": [
                    {
                        "uri": "at://did:plc:a/notif/1",
                        "reason": "follow",
                        "createdAt": "2024-06-15T12:00:00Z",
                        "indexedAt": "2024-06-15T12:00:00Z",
                        "isRead": false,
                        "author": {"cid": "c1", "did": "did:plc:a", "handle": "alice.test"}
                    }
                ],
                "cursor": "next"
            }
            """,
            "getUnreadCount": #"{"count": 3}"#,
        ])

        await vm.loadInitial()
        XCTAssertEqual(vm.notifications.count, 1)
        XCTAssertEqual(vm.unseenCount, 3)
        XCTAssertFalse(vm.isLoading)
    }

    /// Without a count, the page's unread ones are the count.
    func testLoadInitialCountsThePageWhenTheCountFails() async {
        MockURLProtocol.respondWithJSON("""
        {
            "notifications": [
                {
                    "uri": "at://did:plc:a/notif/1",
                    "reason": "follow",
                    "createdAt": "2024-06-15T12:00:00Z",
                    "indexedAt": "2024-06-15T12:00:00Z",
                    "isRead": false,
                    "author": {"cid": "c1", "did": "did:plc:a", "handle": "alice.test"}
                },
                {
                    "uri": "at://did:plc:a/notif/2",
                    "reason": "follow",
                    "createdAt": "2024-06-14T12:00:00Z",
                    "indexedAt": "2024-06-14T12:00:00Z",
                    "isRead": true,
                    "author": {"cid": "c1", "did": "did:plc:a", "handle": "alice.test"}
                }
            ]
        }
        """)

        await vm.loadInitial()
        XCTAssertEqual(vm.notifications.count, 2)
        XCTAssertEqual(vm.unseenCount, 1)
    }

    func testFetchUnseenCountAsksForTheUnreadCount() async {
        let seen = PathLog()
        MockURLProtocol.handler = { request in
            seen.append(request.url?.path ?? "")
            let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
            return (Data(#"{"count": 4}"#.utf8), response)
        }

        await vm.fetchUnseenCount()
        XCTAssertEqual(seen.paths, ["/xrpc/social.grain.notification.getUnreadCount"])
        XCTAssertEqual(vm.unseenCount, 4)
    }

    func testMarkAsSeenUpdatesSeenAt() async {
        vm.unseenCount = 2
        let seen = PathLog()
        MockURLProtocol.handler = { request in
            seen.append(request.url?.path ?? "")
            let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
            return (Data("{}".utf8), response)
        }

        await vm.markAsSeen()
        XCTAssertEqual(seen.paths, ["/xrpc/social.grain.notification.updateSeen"])
    }

    func testLoadInitialGuardsAgainstConcurrent() async {
        // If already loading, loadInitial should bail
        vm.isLoading = true
        MockURLProtocol.respondWithJSON("""
        {"notifications": [], "cursor": null}
        """)
        await vm.loadInitial()
        // Should still be "loading" since we skipped
        XCTAssertTrue(vm.isLoading)
        XCTAssertTrue(vm.notifications.isEmpty)
    }

    // MARK: - markAsSeen

    func testMarkAsSeenOptimisticallyZerosCount() async {
        vm.unseenCount = 5
        MockURLProtocol.respondWithJSON("{}")

        await vm.markAsSeen()
        XCTAssertEqual(vm.unseenCount, 0)
    }

    func testMarkAsSeenSkipsWhenAlreadyZero() async {
        vm.unseenCount = 0
        var requestMade = false
        MockURLProtocol.handler = { _ in
            requestMade = true
            return (Data("{}".utf8), HTTPURLResponse(url: URL(string: "https://test.local")!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
        }
        await vm.markAsSeen()
        XCTAssertFalse(requestMade)
    }

    func testMarkAsSeenRollsBackOnFailure() async {
        vm.unseenCount = 7
        MockURLProtocol.respondWithError(statusCode: 500)

        await vm.markAsSeen()
        XCTAssertEqual(vm.unseenCount, 7)
    }

    // MARK: - Pagination

    func testLoadMoreAppendsResults() async {
        // Set up initial state
        MockURLProtocol.respondWithJSON("""
        {
            "notifications": [
                {
                    "uri": "at://did:plc:a/notif/1",
                    "reason": "follow",
                    "createdAt": "2024-06-15T12:00:00Z",
                    "author": {"cid": "c1", "did": "did:plc:a", "handle": "alice.test"}
                }
            ],
            "cursor": "page2"
        }
        """)
        await vm.loadInitial()
        XCTAssertEqual(vm.notifications.count, 1)

        // Load more
        MockURLProtocol.respondWithJSON("""
        {
            "notifications": [
                {
                    "uri": "at://did:plc:b/notif/2",
                    "reason": "gallery-favorite",
                    "createdAt": "2024-06-15T13:00:00Z",
                    "author": {"cid": "c2", "did": "did:plc:b", "handle": "bob.test"}
                }
            ],
            "cursor": null
        }
        """)
        await vm.loadMore()
        XCTAssertEqual(vm.notifications.count, 2)
    }
}

/// The paths the mock was asked for, from outside the main actor.
private final class PathLog: @unchecked Sendable {
    private let lock = NSLock()
    private var all: [String] = []

    func append(_ path: String) {
        lock.withLock { all.append(path) }
    }

    var paths: [String] {
        lock.withLock { all }
    }
}