Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/test/util/FTS/GRDBFullTextSearcherTest.swift
1 views
//
// Copyright 2017 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Contacts
import LibSignalClient
import XCTest

@testable import Signal
@testable import SignalServiceKit
@testable import SignalUI

// MARK: -

class GRDBFullTextSearcherTest: SignalBaseTest {

    // MARK: - Dependencies

    var searcher: FullTextSearcher {
        FullTextSearcher.shared
    }

    // MARK: - Test Life Cycle

    private var bobRecipient: SignalRecipient!
    private var aliceRecipient: SignalRecipient!

    override func setUp() {
        super.setUp()

        let localIdentifiers: LocalIdentifiers = .forUnitTests

        SSKEnvironment.shared.setContactManagerForUnitTests(OWSContactsManager(
            appReadiness: AppReadinessMock(),
            nicknameManager: DependenciesBridge.shared.nicknameManager,
            recipientDatabaseTable: DependenciesBridge.shared.recipientDatabaseTable,
            usernameLookupManager: DependenciesBridge.shared.usernameLookupManager,
        ))

        // ensure local client has necessary "registered" state
        SSKEnvironment.shared.databaseStorageRef.write { tx in
            (DependenciesBridge.shared.registrationStateChangeManager as! RegistrationStateChangeManagerImpl).registerForTests(
                localIdentifiers: localIdentifiers,
                tx: tx,
            )
        }

        let profileManager = SSKEnvironment.shared.profileManagerRef as! OWSFakeProfileManager

        self.write { transaction in
            let recipientManager = DependenciesBridge.shared.recipientManager

            let aliceAci = Aci.randomForTesting()
            let aliceProfile = makeUserProfile(for: aliceAci, givenName: "Alice", familyName: "Aliceson")
            aliceProfile.anyInsert(transaction: transaction)
            let alicePni = Pni.randomForTesting()
            let alicePhoneNumber = "+12345550100"
            self.aliceRecipient = DependenciesBridge.shared.recipientMerger.applyMergeFromContactDiscovery(
                localIdentifiers: localIdentifiers,
                phoneNumber: E164(alicePhoneNumber)!,
                pni: alicePni,
                aci: aliceAci,
                tx: transaction,
            )
            recipientManager.markAsRegisteredAndSave(
                &self.aliceRecipient,
                shouldUpdateStorageService: false,
                tx: transaction,
            )

            let bobAci = Aci.randomForTesting()
            let bobProfile = makeUserProfile(for: bobAci, givenName: "Bob", familyName: "Barker")
            bobProfile.anyInsert(transaction: transaction)
            let bobPni = Pni.randomForTesting()
            let bobPhoneNumber = "+4915123456789"
            self.bobRecipient = DependenciesBridge.shared.recipientMerger.applyMergeFromContactDiscovery(
                localIdentifiers: localIdentifiers,
                phoneNumber: E164(bobPhoneNumber)!,
                pni: bobPni,
                aci: bobAci,
                tx: transaction,
            )
            recipientManager.markAsRegisteredAndSave(
                &self.bobRecipient,
                shouldUpdateStorageService: false,
                tx: transaction,
            )

            profileManager.fakeUserProfiles = [
                self.aliceRecipient!.address: aliceProfile,
                self.bobRecipient!.address: bobProfile,
            ]

            let bookClubGroupThread = try! GroupManager.createGroupForTests(
                members: [self.aliceRecipient.address, self.bobRecipient.address, localIdentifiers.aciAddress],
                shouldInsertInfoMessage: true,
                name: "Book Club",
                transaction: transaction,
            )
            self.bookClubThreadViewModel = ThreadViewModel(
                thread: bookClubGroupThread,
                forChatList: true,
                transaction: transaction,
            )

            let snackClubGroupThread = try! GroupManager.createGroupForTests(
                members: [self.aliceRecipient.address, localIdentifiers.aciAddress],
                shouldInsertInfoMessage: true,
                name: "Snack Club",
                transaction: transaction,
            )
            self.snackClubThreadViewModel = ThreadViewModel(
                thread: snackClubGroupThread,
                forChatList: true,
                transaction: transaction,
            )

            let aliceContactThread = TSContactThread.getOrCreateThread(withContactAddress: self.aliceRecipient.address, transaction: transaction)
            self.aliceThreadViewModel = ThreadViewModel(
                thread: aliceContactThread,
                forChatList: true,
                transaction: transaction,
            )

            let bobContactThread = TSContactThread.getOrCreateThread(withContactAddress: self.bobRecipient.address, transaction: transaction)
            self.bobEmptyThreadViewModel = ThreadViewModel(
                thread: bobContactThread,
                forChatList: true,
                transaction: transaction,
            )

            let helloAlice = TSOutgoingMessage(in: aliceContactThread, messageBody: "Hello Alice")
            helloAlice.anyInsert(transaction: transaction)

            let goodbyeAlice = TSOutgoingMessage(in: aliceContactThread, messageBody: "Goodbye Alice")
            goodbyeAlice.anyInsert(transaction: transaction)

            let helloBookClub = TSOutgoingMessage(in: bookClubGroupThread, messageBody: "Hello Book Club")
            helloBookClub.anyInsert(transaction: transaction)

            let goodbyeBookClub = TSOutgoingMessage(in: bookClubGroupThread, messageBody: "Goodbye Book Club")
            goodbyeBookClub.anyInsert(transaction: transaction)

            let bobsPhoneNumber = TSOutgoingMessage(in: bookClubGroupThread, messageBody: "My phone number is: 234-555-0100")
            bobsPhoneNumber.anyInsert(transaction: transaction)

            let bobsFaxNumber = TSOutgoingMessage(in: bookClubGroupThread, messageBody: "My fax is: 234-555-0101")
            bobsFaxNumber.anyInsert(transaction: transaction)
        }
    }

    private func makeUserProfile(for aci: Aci, givenName: String, familyName: String) -> OWSUserProfile {
        return OWSUserProfile(
            id: nil,
            uniqueId: UUID().uuidString,
            serviceIdString: aci.serviceIdUppercaseString,
            phoneNumber: nil,
            avatarFileName: nil,
            avatarUrlPath: nil,
            profileKey: nil,
            givenName: givenName,
            familyName: familyName,
            bio: nil,
            bioEmoji: nil,
            badges: [],
            lastFetchDate: nil,
            lastMessagingDate: nil,
            isPhoneNumberShared: true,
        )
    }

    // MARK: - Fixtures

    var bookClubThreadViewModel: ThreadViewModel!
    var snackClubThreadViewModel: ThreadViewModel!

    var aliceThreadViewModel: ThreadViewModel!
    var bobEmptyThreadViewModel: ThreadViewModel!

    // MARK: Tests

    private func AssertEqualThreadLists(_ left: [ThreadViewModel], _ right: [ThreadViewModel], file: StaticString = #filePath, line: UInt = #line) {
        XCTAssertEqual(left.count, right.count, file: file, line: line)
        guard left.count != right.count else {
            return
        }
        // Only bother comparing uniqueIds.
        let leftIds = left.map { $0.threadRecord.uniqueId }
        let rightIds = right.map { $0.threadRecord.uniqueId }
        XCTAssertEqual(leftIds, rightIds, file: file, line: line)
    }

    func testSearchByGroupName() {
        var threadViewModels: [ThreadViewModel] = []

        // No Match
        threadViewModels = searchConversations(searchText: "asdasdasd")
        XCTAssert(threadViewModels.isEmpty)

        // Partial Match
        threadViewModels = searchConversations(searchText: "Book")
        XCTAssertEqual(1, threadViewModels.count)
        AssertEqualThreadLists([bookClubThreadViewModel], threadViewModels)

        threadViewModels = searchConversations(searchText: "Snack")
        XCTAssertEqual(1, threadViewModels.count)
        AssertEqualThreadLists([snackClubThreadViewModel], threadViewModels)

        // Multiple Partial Matches
        threadViewModels = searchConversations(searchText: "Club")
        XCTAssertEqual(2, threadViewModels.count)
        AssertEqualThreadLists([bookClubThreadViewModel, snackClubThreadViewModel], threadViewModels)

        // Match Name Exactly
        threadViewModels = searchConversations(searchText: "Book Club")
        XCTAssertEqual(1, threadViewModels.count)
        AssertEqualThreadLists([bookClubThreadViewModel], threadViewModels)
    }

    func testSearchContactByNumber() {
        var threadViewModels: [ThreadViewModel] = []

        // No match
        threadViewModels = searchConversations(searchText: "+16505550150")
        XCTAssertEqual(0, threadViewModels.count)

        // Exact match
        threadViewModels = searchConversations(searchText: aliceRecipient.address.phoneNumber!)
        XCTAssertEqual(3, threadViewModels.count)
        AssertEqualThreadLists([bookClubThreadViewModel, aliceThreadViewModel, snackClubThreadViewModel], threadViewModels)

        // Partial match
        threadViewModels = searchConversations(searchText: "+123455")
        XCTAssertEqual(3, threadViewModels.count)
        AssertEqualThreadLists([bookClubThreadViewModel, aliceThreadViewModel, snackClubThreadViewModel], threadViewModels)

        // Prefixes
        threadViewModels = searchConversations(searchText: "12345550100")
        XCTAssertEqual(3, threadViewModels.count)
        AssertEqualThreadLists([bookClubThreadViewModel, aliceThreadViewModel, snackClubThreadViewModel], threadViewModels)

        threadViewModels = searchConversations(searchText: "49")
        XCTAssertEqual(1, threadViewModels.count)
        AssertEqualThreadLists([bookClubThreadViewModel], threadViewModels)

        threadViewModels = searchConversations(searchText: "1-234-55")
        XCTAssertEqual(3, threadViewModels.count)
        AssertEqualThreadLists([bookClubThreadViewModel, aliceThreadViewModel, snackClubThreadViewModel], threadViewModels)

        threadViewModels = searchConversations(searchText: "123455")
        XCTAssertEqual(3, threadViewModels.count)
        AssertEqualThreadLists([bookClubThreadViewModel, aliceThreadViewModel, snackClubThreadViewModel], threadViewModels)

        threadViewModels = searchConversations(searchText: "1.234.55")
        XCTAssertEqual(3, threadViewModels.count)
        AssertEqualThreadLists([bookClubThreadViewModel, aliceThreadViewModel, snackClubThreadViewModel], threadViewModels)

        threadViewModels = searchConversations(searchText: "1 234 55")
        XCTAssertEqual(3, threadViewModels.count)
        AssertEqualThreadLists([bookClubThreadViewModel, aliceThreadViewModel, snackClubThreadViewModel], threadViewModels)

        // Phone Number formatting should be forgiving
        threadViewModels = searchConversations(searchText: "234.55")
        XCTAssertEqual(3, threadViewModels.count)
        AssertEqualThreadLists([bookClubThreadViewModel, aliceThreadViewModel, snackClubThreadViewModel], threadViewModels)

        threadViewModels = searchConversations(searchText: "234 55")
        XCTAssertEqual(3, threadViewModels.count)
        AssertEqualThreadLists([bookClubThreadViewModel, aliceThreadViewModel, snackClubThreadViewModel], threadViewModels)
    }

    func testSearchConversationByContactByName() {
        var threadViewModels: [ThreadViewModel] = []

        threadViewModels = searchConversations(searchText: "Alice")
        XCTAssertEqual(3, threadViewModels.count)
        AssertEqualThreadLists([bookClubThreadViewModel, aliceThreadViewModel, snackClubThreadViewModel], threadViewModels)

        threadViewModels = searchConversations(searchText: "Bob")
        XCTAssertEqual(1, threadViewModels.count)
        AssertEqualThreadLists([bookClubThreadViewModel], threadViewModels)

        threadViewModels = searchConversations(searchText: "Barker")
        XCTAssertEqual(1, threadViewModels.count)
        AssertEqualThreadLists([bookClubThreadViewModel], threadViewModels)

        threadViewModels = searchConversations(searchText: "Bob B")
        XCTAssertEqual(1, threadViewModels.count)
        AssertEqualThreadLists([bookClubThreadViewModel], threadViewModels)
    }

    func testSearchMessageByBodyContent() {
        var resultSet: HomeScreenSearchResultSet = .empty

        resultSet = getResultSet(searchText: "Hello Alice")
        XCTAssertEqual(1, resultSet.messageResults.count)
        AssertEqualThreadLists([aliceThreadViewModel], resultSet.messageResults.map { $0.threadViewModel })

        resultSet = getResultSet(searchText: "Hello")
        XCTAssertEqual(2, resultSet.messageResults.count)
        AssertEqualThreadLists([aliceThreadViewModel, bookClubThreadViewModel], resultSet.messageResults.map { $0.threadViewModel })
    }

    func testSearchEdgeCases() {
        var resultSet: HomeScreenSearchResultSet = .empty

        resultSet = getResultSet(searchText: "Hello Alice")
        XCTAssertEqual(1, resultSet.messageResults.count)
        XCTAssertEqual(["Hello Alice"], bodies(forMessageResults: resultSet.messageResults))

        resultSet = getResultSet(searchText: "hello alice")
        XCTAssertEqual(1, resultSet.messageResults.count)
        XCTAssertEqual(["Hello Alice"], bodies(forMessageResults: resultSet.messageResults))

        resultSet = getResultSet(searchText: "Hel")
        XCTAssertEqual(2, resultSet.messageResults.count)
        XCTAssertEqual(["Hello Alice", "Hello Book Club"], bodies(forMessageResults: resultSet.messageResults))

        resultSet = getResultSet(searchText: "Hel Ali")
        XCTAssertEqual(1, resultSet.messageResults.count)
        XCTAssertEqual(["Hello Alice"], bodies(forMessageResults: resultSet.messageResults))

        resultSet = getResultSet(searchText: "Hel Ali Alic")
        XCTAssertEqual(1, resultSet.messageResults.count)
        XCTAssertEqual(["Hello Alice"], bodies(forMessageResults: resultSet.messageResults))

        resultSet = getResultSet(searchText: "Ali Hel")
        XCTAssertEqual(1, resultSet.messageResults.count)
        XCTAssertEqual(["Hello Alice"], bodies(forMessageResults: resultSet.messageResults))

        resultSet = getResultSet(searchText: "CLU")
        XCTAssertEqual(2, resultSet.messageResults.count)
        XCTAssertEqual(["Goodbye Book Club", "Hello Book Club"], bodies(forMessageResults: resultSet.messageResults))

        resultSet = getResultSet(searchText: "hello !@##!@#!$^@!@#! alice")
        XCTAssertEqual(1, resultSet.messageResults.count)
        XCTAssertEqual(["Hello Alice"], bodies(forMessageResults: resultSet.messageResults))

        resultSet = getResultSet(searchText: "2345 phone")
        XCTAssertEqual(1, resultSet.messageResults.count)
        XCTAssertEqual(["My phone number is: 234-555-0100"], bodies(forMessageResults: resultSet.messageResults))

        resultSet = getResultSet(searchText: "PHO 2345")
        XCTAssertEqual(1, resultSet.messageResults.count)
        XCTAssertEqual(["My phone number is: 234-555-0100"], bodies(forMessageResults: resultSet.messageResults))

        resultSet = getResultSet(searchText: "fax")
        XCTAssertEqual(1, resultSet.messageResults.count)
        XCTAssertEqual(["My fax is: 234-555-0101"], bodies(forMessageResults: resultSet.messageResults))

        resultSet = getResultSet(searchText: "fax 2345")
        XCTAssertEqual(1, resultSet.messageResults.count)
        XCTAssertEqual(["My fax is: 234-555-0101"], bodies(forMessageResults: resultSet.messageResults))
    }

    // MARK: - More Tests

    func testModelLifecycle1() {

        var thread: TSGroupThread! = nil
        self.write { transaction in
            thread = try! GroupManager.createGroupForTests(
                members: [self.aliceRecipient.address, self.bobRecipient.address, DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction)!.aciAddress],
                shouldInsertInfoMessage: true,
                name: "Lifecycle",
                transaction: transaction,
            )
        }

        let message1 = TSOutgoingMessage(in: thread, messageBody: "This world contains glory and despair.")
        let message2 = TSOutgoingMessage(in: thread, messageBody: "This world contains hope and despair.")

        XCTAssertEqual(0, getResultSet(searchText: "GLORY").messageResults.count)
        XCTAssertEqual(0, getResultSet(searchText: "HOPE").messageResults.count)
        XCTAssertEqual(0, getResultSet(searchText: "DESPAIR").messageResults.count)
        XCTAssertEqual(0, getResultSet(searchText: "DEFEAT").messageResults.count)

        self.write { transaction in
            message1.anyInsert(transaction: transaction)
            message2.anyInsert(transaction: transaction)
        }

        XCTAssertEqual(1, getResultSet(searchText: "GLORY").messageResults.count)
        XCTAssertEqual(1, getResultSet(searchText: "HOPE").messageResults.count)
        XCTAssertEqual(2, getResultSet(searchText: "DESPAIR").messageResults.count)
        XCTAssertEqual(0, getResultSet(searchText: "DEFEAT").messageResults.count)

        self.write { transaction in
            message1.update(withMessageBody: "This world contains glory and defeat.", transaction: transaction)
        }

        XCTAssertEqual(1, getResultSet(searchText: "GLORY").messageResults.count)
        XCTAssertEqual(1, getResultSet(searchText: "HOPE").messageResults.count)
        XCTAssertEqual(1, getResultSet(searchText: "DESPAIR").messageResults.count)
        XCTAssertEqual(1, getResultSet(searchText: "DEFEAT").messageResults.count)

        self.write { transaction in
            DependenciesBridge.shared.interactionDeleteManager.delete(message1, sideEffects: .default(), tx: transaction)
        }

        XCTAssertEqual(0, getResultSet(searchText: "GLORY").messageResults.count)
        XCTAssertEqual(1, getResultSet(searchText: "HOPE").messageResults.count)
        XCTAssertEqual(1, getResultSet(searchText: "DESPAIR").messageResults.count)
        XCTAssertEqual(0, getResultSet(searchText: "DEFEAT").messageResults.count)

        self.write { transaction in
            DependenciesBridge.shared.interactionDeleteManager.delete(message2, sideEffects: .default(), tx: transaction)
        }

        XCTAssertEqual(0, getResultSet(searchText: "GLORY").messageResults.count)
        XCTAssertEqual(0, getResultSet(searchText: "HOPE").messageResults.count)
        XCTAssertEqual(0, getResultSet(searchText: "DESPAIR").messageResults.count)
        XCTAssertEqual(0, getResultSet(searchText: "DEFEAT").messageResults.count)
    }

    func testModelLifecycle2() {

        var message1: TSOutgoingMessage!
        var message2: TSOutgoingMessage!
        self.write { transaction in
            let thread = try! GroupManager.createGroupForTests(
                members: [self.aliceRecipient.address, self.bobRecipient.address, DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction)!.aciAddress],
                shouldInsertInfoMessage: true,
                name: "Lifecycle",
                transaction: transaction,
            )

            message1 = TSOutgoingMessage(in: thread, messageBody: "This world contains glory and despair.")
            message2 = TSOutgoingMessage(in: thread, messageBody: "This world contains hope and despair.")

            message1.anyInsert(transaction: transaction)
            message2.anyInsert(transaction: transaction)
        }

        XCTAssertEqual(1, getResultSet(searchText: "GLORY").messageResults.count)
        XCTAssertEqual(1, getResultSet(searchText: "HOPE").messageResults.count)
        XCTAssertEqual(2, getResultSet(searchText: "DESPAIR").messageResults.count)
        XCTAssertEqual(0, getResultSet(searchText: "DEFEAT").messageResults.count)

        self.write { transaction in
            DependenciesBridge.shared.interactionDeleteManager
                .delete(interactions: [message1, message2], sideEffects: .default(), tx: transaction)
        }

        XCTAssertEqual(0, getResultSet(searchText: "GLORY").messageResults.count)
        XCTAssertEqual(0, getResultSet(searchText: "HOPE").messageResults.count)
        XCTAssertEqual(0, getResultSet(searchText: "DESPAIR").messageResults.count)
        XCTAssertEqual(0, getResultSet(searchText: "DEFEAT").messageResults.count)
    }

    func testDiacritics() {

        self.write { transaction in
            let thread = try! GroupManager.createGroupForTests(
                members: [self.aliceRecipient.address, self.bobRecipient.address, DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction)!.aciAddress],
                shouldInsertInfoMessage: true,
                name: "Lifecycle",
                transaction: transaction,
            )

            TSOutgoingMessage(in: thread, messageBody: "NOËL and SØRINA and ADRIÁN and FRANÇOIS and NUÑEZ and Björk.").anyInsert(transaction: transaction)
        }

        XCTAssertEqual(1, getResultSet(searchText: "NOËL").messageResults.count)
        XCTAssertEqual(1, getResultSet(searchText: "noel").messageResults.count)
        XCTAssertEqual(1, getResultSet(searchText: "SØRINA").messageResults.count)
        // I guess Ø isn't a diacritical mark but a separate letter.
        XCTAssertEqual(0, getResultSet(searchText: "sorina").messageResults.count)
        XCTAssertEqual(1, getResultSet(searchText: "ADRIÁN").messageResults.count)
        XCTAssertEqual(1, getResultSet(searchText: "adrian").messageResults.count)
        XCTAssertEqual(1, getResultSet(searchText: "FRANÇOIS").messageResults.count)
        XCTAssertEqual(1, getResultSet(searchText: "francois").messageResults.count)
        XCTAssertEqual(1, getResultSet(searchText: "NUÑEZ").messageResults.count)
        XCTAssertEqual(1, getResultSet(searchText: "nunez").messageResults.count)
        XCTAssertEqual(1, getResultSet(searchText: "Björk").messageResults.count)
        XCTAssertEqual(1, getResultSet(searchText: "Bjork").messageResults.count)
    }

    private func AssertValidResultSet(query: String, expectedResultCount: Int, file: StaticString = #filePath, line: UInt = #line) {
        // For these simple test cases, the snippet should contain the entire query.
        let expectedSnippetContent: String = query

        let resultSet = getResultSet(searchText: query)
        XCTAssertEqual(expectedResultCount, resultSet.messageResults.count, file: file, line: line)
        for result in resultSet.messageResults {
            guard let snippet = result.snippet else {
                XCTFail("Missing snippet.", file: file, line: line)
                continue
            }
            let snippetString: String
            switch snippet {
            case .text(let string):
                snippetString = string
            case .attributedText(let nSAttributedString):
                snippetString = nSAttributedString.string
            case .messageBody(let hydratedMessageBody):
                snippetString = hydratedMessageBody.asPlaintext()
            }
            XCTAssertTrue(snippetString.lowercased().contains(expectedSnippetContent.lowercased()), file: file, line: line)
        }
    }

    func testSnippets() {

        var thread: TSGroupThread! = nil
        self.write { transaction in
            thread = try! GroupManager.createGroupForTests(
                members: [self.aliceRecipient.address, self.bobRecipient.address, DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction)!.aciAddress],
                shouldInsertInfoMessage: true,
                name: "Lifecycle",
                transaction: transaction,
            )
        }

        let message1 = TSOutgoingMessage(in: thread, messageBody: "This world contains glory and despair.")
        let message2 = TSOutgoingMessage(in: thread, messageBody: "This world contains hope and despair.")

        AssertValidResultSet(query: "GLORY", expectedResultCount: 0)
        AssertValidResultSet(query: "HOPE", expectedResultCount: 0)
        AssertValidResultSet(query: "DESPAIR", expectedResultCount: 0)
        AssertValidResultSet(query: "DEFEAT", expectedResultCount: 0)

        self.write { transaction in
            message1.anyInsert(transaction: transaction)
            message2.anyInsert(transaction: transaction)
        }

        AssertValidResultSet(query: "GLORY", expectedResultCount: 1)
        AssertValidResultSet(query: "HOPE", expectedResultCount: 1)
        AssertValidResultSet(query: "DESPAIR", expectedResultCount: 2)
        AssertValidResultSet(query: "DEFEAT", expectedResultCount: 0)
    }

    // MARK: - Perf

    func testPerf() {
        SSKEnvironment.shared.databaseStorageRef.write { tx in
            (DependenciesBridge.shared.registrationStateChangeManager as! RegistrationStateChangeManagerImpl).registerForTests(
                localIdentifiers: .forUnitTests,
                tx: tx,
            )
        }

        let string1 = "krazy"
        let string2 = "kat"
        let messageCount: UInt = 100

        BenchMemory(title: "Populate Index", memorySamplerRatio: 1) { _ in
            self.write { transaction in
                let thread = try! GroupManager.createGroupForTests(
                    members: [self.aliceRecipient.address, self.bobRecipient.address, DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction)!.aciAddress],
                    shouldInsertInfoMessage: true,
                    name: "Perf",
                    transaction: transaction,
                )

                TSOutgoingMessage(in: thread, messageBody: string1).anyInsert(transaction: transaction)

                for _ in 0...messageCount {
                    let message = TSOutgoingMessage(in: thread, messageBody: UUID().uuidString)
                    message.anyInsert(transaction: transaction)
                    message.update(withMessageBody: UUID().uuidString, transaction: transaction)
                }

                TSOutgoingMessage(in: thread, messageBody: string2).anyInsert(transaction: transaction)
            }
        }

        BenchMemory(title: "Search", memorySamplerRatio: 1) { _ in
            self.read { transaction in
                let getMatchCount = { (searchText: String) -> UInt in
                    var count: UInt = 0
                    FullTextSearchIndexer.search(
                        for: searchText,
                        maxResults: 500,
                        tx: transaction,
                    ) { match, snippet, _ in
                        count += 1
                    }
                    return count
                }
                XCTAssertEqual(1, getMatchCount(string1))
                XCTAssertEqual(1, getMatchCount(string2))
                XCTAssertEqual(0, getMatchCount(UUID().uuidString))
            }
        }
    }

    // MARK: - Helpers

    func bodies<T>(forMessageResults messageResults: [ConversationSearchResult<T>]) -> [String] {
        var result = [String]()

        self.read { transaction in
            for messageResult in messageResults {
                guard let messageId = messageResult.messageId else {
                    owsFailDebug("message result missing message id")
                    continue
                }
                guard let interaction = TSInteraction.fetchViaCache(uniqueId: messageId, transaction: transaction) else {
                    owsFailDebug("couldn't load interaction for message result")
                    continue
                }
                guard let message = interaction as? TSMessage else {
                    owsFailDebug("invalid message for message result")
                    continue
                }
                guard let messageBody = message.body else {
                    owsFailDebug("message result missing message body")
                    continue
                }
                result.append(messageBody)
            }
        }

        return result.sorted()
    }

    private func searchConversations(searchText: String) -> [ThreadViewModel] {
        let results = getResultSet(searchText: searchText)
        let contactThreadViewModels = results.contactThreadResults.map { $0.threadViewModel }
        let groupThreadViewModels = results.groupThreadResults.map { $0.threadViewModel }
        return contactThreadViewModels + groupThreadViewModels
    }

    private func getResultSet(searchText: String) -> HomeScreenSearchResultSet {
        self.read { transaction in
            try! self.searcher.searchForHomeScreen(searchText: searchText, tx: transaction)
        }
    }
}

// MARK: -

private extension TSOutgoingMessage {
    convenience init(in thread: TSThread, messageBody: String) {
        let builder: TSOutgoingMessageBuilder = .withDefaultValues(
            thread: thread,
            messageBody: AttachmentContentValidatorMock.mockValidatedBody(messageBody),
        )
        self.init(outgoingMessageWith: builder, recipientAddressStates: [:])
    }
}