Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalUI/LinkPreview/LinkPreviewFetchStateTest.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import XCTest
@testable import SignalServiceKit
@testable import SignalUI

private extension LinkPreviewFetchState.State {
    var isNone: Bool {
        if case .none = self {
            return true
        }
        return false
    }

    var isLoaded: Bool {
        if case .loaded = self {
            return true
        }
        return false
    }

    var isFailed: Bool {
        if case .failed = self {
            return true
        }
        return false
    }

    var isLoading: Bool {
        if case .loading = self {
            return true
        }
        return false
    }
}

class LinkPreviewFetchStateTest: XCTestCase {

    private var mockLinkPreviewFetcher: MockLinkPreviewFetcher!
    private var mockDB: InMemoryDB!

    override func setUp() {
        super.setUp()

        mockDB = InMemoryDB()
        mockLinkPreviewFetcher = MockLinkPreviewFetcher()
    }

    private func linkPreviewFetchState(
        areLinkPreviewsEnabled: Bool = true,
        onlyParseIfEnabled: Bool = false,
    ) -> LinkPreviewFetchState {
        let linkPreviewSettingStore = LinkPreviewSettingStore.mock()
        mockDB.write { tx in
            linkPreviewSettingStore.setAreLinkPreviewsEnabled(areLinkPreviewsEnabled, tx: tx)
        }
        return LinkPreviewFetchState(
            db: mockDB,
            linkPreviewFetcher: mockLinkPreviewFetcher,
            linkPreviewSettingStore: linkPreviewSettingStore,
            onlyParseIfEnabled: onlyParseIfEnabled,
            linkPreviewDraft: nil,
        )
    }

    func testUpdateLoaded() async throws {
        let linkPreviewFetchState = self.linkPreviewFetchState()

        // Non-URL text shouldn't issue any fetches.
        for textValue in ["a", "ab", "abc"] {
            await linkPreviewFetchState.update(.init(text: textValue, ranges: .empty))?.value
            XCTAssert(linkPreviewFetchState.currentState.isNone)
            XCTAssertNil(linkPreviewFetchState.linkPreviewDraftIfLoaded)
            XCTAssertNil(linkPreviewFetchState.currentUrl)
            XCTAssertEqual(mockLinkPreviewFetcher.fetchedURLs, [])
        }

        // A valid URL should fetch only once, even if the text is modified.
        let validURL = try XCTUnwrap(URL(string: "https://signal.org"))
        mockLinkPreviewFetcher.fetchLinkPreviewBlock = { fetchedURL in
            XCTAssertEqual(fetchedURL, validURL)
            return OWSLinkPreviewDraft(url: fetchedURL, title: "Website Title", isForwarded: false)
        }
        for textValue in ["Check ou https://signal.org", "Check out https://signal.org"] {
            await linkPreviewFetchState.update(.init(text: textValue, ranges: .empty))?.value
            XCTAssert(linkPreviewFetchState.currentState.isLoaded)
            XCTAssertEqual(linkPreviewFetchState.linkPreviewDraftIfLoaded?.url, validURL)
            XCTAssertEqual(linkPreviewFetchState.currentUrl, validURL)
            XCTAssertEqual(mockLinkPreviewFetcher.fetchedURLs, [validURL])
        }

        // An invalid URL should fetch only once, even if the text is modified.
        let invalidURL = try XCTUnwrap(URL(string: "https://signal.org/not_found"))
        mockLinkPreviewFetcher.fetchLinkPreviewBlock = { fetchedURL in
            XCTAssertEqual(fetchedURL, invalidURL)
            throw OWSGenericError("Not found.")
        }
        for textValue in ["Check ou https://signal.org/not_found", "Check out https://signal.org/not_found"] {
            await linkPreviewFetchState.update(.init(text: textValue, ranges: .empty))?.value
            XCTAssert(linkPreviewFetchState.currentState.isFailed)
            XCTAssertNil(linkPreviewFetchState.linkPreviewDraftIfLoaded)
            XCTAssertEqual(linkPreviewFetchState.currentUrl, invalidURL)
            XCTAssertEqual(mockLinkPreviewFetcher.fetchedURLs, [validURL, invalidURL])
        }

        // Removing the URL should clear the link preview.
        for textValue in ["Check out", "Check ou"] {
            await linkPreviewFetchState.update(.init(text: textValue, ranges: .empty))?.value
            XCTAssert(linkPreviewFetchState.currentState.isNone)
            XCTAssertNil(linkPreviewFetchState.linkPreviewDraftIfLoaded)
            XCTAssertNil(linkPreviewFetchState.currentUrl)
            XCTAssertEqual(mockLinkPreviewFetcher.fetchedURLs, [validURL, invalidURL])
        }
    }

    private struct PendingFetchState {
        var isReady = false
        var deferredBlocks = [() -> Void]()
        var expectedCount: Int

        mutating func resolveIfReady() {
            guard self.isReady, self.deferredBlocks.count == self.expectedCount else {
                return
            }
            self.deferredBlocks.forEach { $0() }
            self.deferredBlocks.removeAll()
        }
    }

    func testUpdateLoading() async throws {
        let linkPreviewFetchState = self.linkPreviewFetchState()

        let validURL = try XCTUnwrap(URL(string: "https://signal.org"))
        let pendingFetchState = AtomicValue(PendingFetchState(expectedCount: 1), lock: .init())
        mockLinkPreviewFetcher.fetchLinkPreviewBlock = { fetchedURL in
            return try await withCheckedThrowingContinuation { continuation in
                pendingFetchState.update {
                    $0.deferredBlocks.append {
                        continuation.resume(returning: OWSLinkPreviewDraft(url: fetchedURL, title: "Website Title", isForwarded: false))
                    }
                    $0.resolveIfReady()
                }
            }
        }

        let task1 = linkPreviewFetchState.update(.init(text: "https://signal.org is a grea", ranges: .empty))
        XCTAssert(linkPreviewFetchState.currentState.isLoading)

        // If there's a request in flight, we shouldn't send a new request.
        let task2 = linkPreviewFetchState.update(.init(text: "https://signal.org is a great", ranges: .empty))
        XCTAssert(linkPreviewFetchState.currentState.isLoading)

        pendingFetchState.update {
            $0.isReady = true
            $0.resolveIfReady()
        }

        await task1?.value
        await task2?.value

        XCTAssert(linkPreviewFetchState.currentState.isLoaded)
        XCTAssertEqual(mockLinkPreviewFetcher.fetchedURLs, [validURL])
    }

    func testUpdateObsolete() async throws {
        let linkPreviewFetchState = self.linkPreviewFetchState()

        let pendingFetchState = AtomicValue(PendingFetchState(expectedCount: 2), lock: .init())
        mockLinkPreviewFetcher.fetchLinkPreviewBlock = { fetchedURL in
            return try await withCheckedThrowingContinuation { continuation in
                pendingFetchState.update {
                    $0.deferredBlocks.append {
                        continuation.resume(returning: OWSLinkPreviewDraft(url: fetchedURL, title: "Website Title", isForwarded: false))
                    }
                    $0.resolveIfReady()
                }
            }
        }

        let url1 = try XCTUnwrap(URL(string: "https://signal.org/one"))
        let task1 = linkPreviewFetchState.update(.init(text: "https://signal.org/one", ranges: .empty))
        XCTAssert(linkPreviewFetchState.currentState.isLoading)

        // If there's a request in flight & we change the URL, drop the original request.
        let url2 = try XCTUnwrap(URL(string: "https://signal.org/two"))
        let task2 = linkPreviewFetchState.update(.init(text: "https://signal.org/two", ranges: .empty))
        XCTAssert(linkPreviewFetchState.currentState.isLoading)

        pendingFetchState.update {
            $0.isReady = true
            $0.resolveIfReady()
        }

        await task1?.value
        await task2?.value

        XCTAssert(linkPreviewFetchState.currentState.isLoaded)
        XCTAssertEqual(linkPreviewFetchState.linkPreviewDraftIfLoaded?.url, url2)
        XCTAssertEqual(linkPreviewFetchState.currentUrl, url2)
        XCTAssertEqual(Set(mockLinkPreviewFetcher.fetchedURLs), [url1, url2])
    }

    func testUpdatePrependScheme() async throws {
        let linkPreviewFetchState = self.linkPreviewFetchState()

        mockLinkPreviewFetcher.fetchLinkPreviewBlock = { fetchedURL in
            return OWSLinkPreviewDraft(url: fetchedURL, title: "Signal", isForwarded: false)
        }
        await linkPreviewFetchState.update(.init(text: "signal.org", ranges: .empty), prependSchemeIfNeeded: false)?.value
        XCTAssert(linkPreviewFetchState.currentState.isNone)
        XCTAssertEqual(mockLinkPreviewFetcher.fetchedURLs, [])

        // If we should prepend a scheme, prepend "https://".
        await linkPreviewFetchState.update(.init(text: "signal.org", ranges: .empty), prependSchemeIfNeeded: true)?.value
        XCTAssert(linkPreviewFetchState.currentState.isLoaded)
        XCTAssertEqual(mockLinkPreviewFetcher.fetchedURLs, [URL(string: "https://signal.org")!])
        mockLinkPreviewFetcher._fetchedURLs.update { $0.removeAll() }

        // If there's already a scheme, we don't add "https://". (We require
        // "https://", so specify anything other scheme disables link previews.
        await linkPreviewFetchState.update(.init(text: "http://signal.org", ranges: .empty), prependSchemeIfNeeded: true)?.value
        XCTAssert(linkPreviewFetchState.currentState.isNone)
        XCTAssertEqual(mockLinkPreviewFetcher.fetchedURLs, [])
    }

    func testOnStateChange() async throws {
        let linkPreviewFetchState = self.linkPreviewFetchState()

        mockLinkPreviewFetcher.fetchLinkPreviewBlock = { fetchedURL in
            return OWSLinkPreviewDraft(url: fetchedURL, title: "Signal", isForwarded: false)
        }

        var onStateChangeCount = 0
        linkPreviewFetchState.onStateChange = { onStateChangeCount += 1 }

        await linkPreviewFetchState.update(.init(text: "", ranges: .empty))?.value
        XCTAssertEqual(onStateChangeCount, 0)

        // Redundant updates generally don't result in state updates.
        await linkPreviewFetchState.update(.init(text: "a", ranges: .empty))?.value
        XCTAssertEqual(onStateChangeCount, 0)

        // Fetching a URL should update the state twice: to loading & to loaded.
        await linkPreviewFetchState.update(.init(text: "https://signal.org", ranges: .empty))?.value
        XCTAssertEqual(onStateChangeCount, 2)

        await linkPreviewFetchState.update(.init(text: "https://signal.org", ranges: .empty))?.value
        XCTAssertEqual(onStateChangeCount, 2)

        // Clearing the text should update the link preview.
        await linkPreviewFetchState.update(.init(text: "", ranges: .empty))?.value
        XCTAssertEqual(onStateChangeCount, 3)

        // Assigning the URL again should fetch it again.
        await linkPreviewFetchState.update(.init(text: "https://signal.org", ranges: .empty))?.value
        XCTAssertEqual(onStateChangeCount, 5)

        // Disabling the link preview should trigger an update.
        linkPreviewFetchState.disable()
        XCTAssertEqual(onStateChangeCount, 6)
    }

    func testDisable() async throws {
        let linkPreviewFetchState = self.linkPreviewFetchState()

        mockLinkPreviewFetcher.fetchLinkPreviewBlock = { fetchedURL in
            return OWSLinkPreviewDraft(url: fetchedURL, title: "Signal", isForwarded: false)
        }

        // Fetch the original preview.
        let url = try XCTUnwrap(URL(string: "https://signal.org"))
        await linkPreviewFetchState.update(.init(text: "https://signal.org", ranges: .empty))?.value
        XCTAssertEqual(linkPreviewFetchState.linkPreviewDraftIfLoaded?.url, url)
        XCTAssertEqual(mockLinkPreviewFetcher.fetchedURLs, [url])
        mockLinkPreviewFetcher._fetchedURLs.update { $0.removeAll() }

        // Dismiss the preview; make sure it goes away.
        linkPreviewFetchState.disable()
        XCTAssertNil(linkPreviewFetchState.linkPreviewDraftIfLoaded)
        XCTAssertEqual(mockLinkPreviewFetcher.fetchedURLs, [])

        // Assign the same URL again; make sure it doesn't come back.
        await linkPreviewFetchState.update(.init(text: "https://signal.org", ranges: .empty))?.value
        XCTAssertNil(linkPreviewFetchState.linkPreviewDraftIfLoaded)
        XCTAssertEqual(mockLinkPreviewFetcher.fetchedURLs, [])

        // Clear the URL; make sure it stays away.
        await linkPreviewFetchState.update(.init(text: "", ranges: .empty))?.value
        XCTAssertNil(linkPreviewFetchState.linkPreviewDraftIfLoaded)
        XCTAssertEqual(mockLinkPreviewFetcher.fetchedURLs, [])

        // Enter a different URL; make sure we don't fetch it.
        await linkPreviewFetchState.update(.init(text: "https://signal.org/one", ranges: .empty))?.value
        XCTAssertNil(linkPreviewFetchState.linkPreviewDraftIfLoaded)
        XCTAssertEqual(mockLinkPreviewFetcher.fetchedURLs, [])

        // Ensure "enableIfEmpty" doesn't enable when the text isn't empty.
        await linkPreviewFetchState.update(.init(text: "https://signal.org/one", ranges: .empty), enableIfEmpty: true)?.value
        XCTAssertNil(linkPreviewFetchState.linkPreviewDraftIfLoaded)
        XCTAssertEqual(mockLinkPreviewFetcher.fetchedURLs, [])

        // Clear the text with "enableIfEmpty" to re-enable link previews.
        await linkPreviewFetchState.update(.init(text: "", ranges: .empty), enableIfEmpty: true)?.value
        XCTAssertNil(linkPreviewFetchState.linkPreviewDraftIfLoaded)
        XCTAssertEqual(mockLinkPreviewFetcher.fetchedURLs, [])

        // Set a URL and make sure we fetch it.
        let url2 = try XCTUnwrap(URL(string: "https://signal.org/two"))
        await linkPreviewFetchState.update(.init(text: "https://signal.org/two", ranges: .empty), enableIfEmpty: true)?.value
        XCTAssertEqual(linkPreviewFetchState.linkPreviewDraftIfLoaded?.url, url2)
        XCTAssertEqual(mockLinkPreviewFetcher.fetchedURLs, [url2])
    }

    func testOnlyParseIfEnabled() async throws {
        do {
            let linkPreviewFetchState = self.linkPreviewFetchState(
                areLinkPreviewsEnabled: false,
                onlyParseIfEnabled: true,
            )

            await linkPreviewFetchState.update(.init(text: "https://signal.org", ranges: .empty))?.value
            XCTAssertNil(linkPreviewFetchState.currentUrl)
        }
        do {
            let linkPreviewFetchState = self.linkPreviewFetchState(
                areLinkPreviewsEnabled: false,
                onlyParseIfEnabled: false,
            )

            // If link previews are disabled, we may still want to parse URLs in the
            // text so that they can be attached (without a preview) to text stories.
            mockLinkPreviewFetcher.fetchLinkPreviewBlock = { fetchedURL in
                throw OWSGenericError("Not found.")
            }
            await linkPreviewFetchState.update(.init(text: "https://signal.org", ranges: .empty))?.value
            XCTAssertEqual(linkPreviewFetchState.currentUrl, try XCTUnwrap(URL(string: "https://signal.org")))
        }
    }

    func testDontParseInSpoilers() async throws {
        mockLinkPreviewFetcher.fetchLinkPreviewBlock = { fetchedURL in
            return OWSLinkPreviewDraft(url: fetchedURL, title: "Signal", isForwarded: false)
        }

        let linkPreviewFetchState = self.linkPreviewFetchState(
            onlyParseIfEnabled: true,
        )

        // Bold should have no effect
        await linkPreviewFetchState.update(.init(
            text: "https://signal.org",
            ranges: .init(
                mentions: [:],
                styles: [.init(.bold, range: NSRange(location: 0, length: 18))],
            ),
        ))?.value
        XCTAssertNotNil(linkPreviewFetchState.currentUrl)

        // Spoiler should mean we don't match.
        await linkPreviewFetchState.update(.init(
            text: "https://signal.org",
            ranges: .init(
                mentions: [:],
                styles: [.init(.spoiler, range: NSRange(location: 0, length: 18))],
            ),
        ))?.value
        XCTAssertNil(linkPreviewFetchState.currentUrl)

        // Even if only partially covering.
        await linkPreviewFetchState.update(.init(
            text: "https://signal.org",
            ranges: .init(
                mentions: [:],
                styles: [.init(.spoiler, range: NSRange(location: 3, length: 5))],
            ),
        ))?.value
        XCTAssertNil(linkPreviewFetchState.currentUrl)

        // Including if we prepend a prefix.
        await linkPreviewFetchState.update(.init(
            text: "signal.org",
            ranges: .init(
                mentions: [:],
                styles: [.init(.spoiler, range: NSRange(location: 5, length: 5))],
            ),
        ))?.value
        XCTAssertNil(linkPreviewFetchState.currentUrl)
    }
}