Signal-iOS/SignalServiceKit/tests/Polls/PollManagerTest.swift
2025-10-22 16:18:35 -04:00

1890 lines
68 KiB
Swift

//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Testing
@testable import LibSignalClient
@testable import SignalServiceKit
@MainActor
struct PollManagerTest {
private let db = InMemoryDB()
private let recipientDatabaseTable = RecipientDatabaseTable()
private let pollMessageManager: PollMessageManager
private let pollStore = PollStore()
private var groupThread: TSGroupThread!
private var recipient: SignalRecipient!
private let mockTSAccountManager = MockTSAccountManager()
var pollAuthorAci: Aci!
init() throws {
pollMessageManager = PollMessageManager(
pollStore: pollStore,
recipientDatabaseTable: RecipientDatabaseTable(),
interactionStore: InteractionStoreImpl(),
accountManager: mockTSAccountManager,
messageSenderJobQueue: MessageSenderJobQueue(appReadiness: AppReadinessMock()),
disappearingMessagesConfigurationStore: MockDisappearingMessagesConfigurationStore(),
attachmentContentValidator: AttachmentContentValidatorMock(),
db: db
)
let testPhone = E164("+16505550101")!
pollAuthorAci = Aci.constantForTesting("00000000-0000-4000-8000-000000000000")
let pni = Pni(fromUUID: UUID())
groupThread = TSGroupThread.randomForTesting()
recipient = SignalRecipient(aci: pollAuthorAci, pni: pni, phoneNumber: testPhone)
}
private func createIncomingMessage(
with thread: TSThread,
customizeBlock: ((TSIncomingMessageBuilder) -> Void)
) -> TSIncomingMessage {
let messageBuilder: TSIncomingMessageBuilder = .withDefaultValues(
thread: thread
)
customizeBlock(messageBuilder)
let targetMessage = messageBuilder.build()
return targetMessage
}
private func insertIncomingPollMessage(question: String, timestamp: UInt64? = nil) -> TSIncomingMessage {
db.write { tx in
let db = tx.database
if try! groupThread.asRecord().exists(db) == false {
try! groupThread!.asRecord().insert(db)
}
let incomingMessage = createIncomingMessage(with: groupThread) { builder in
builder.setMessageBody(AttachmentContentValidatorMock.mockValidatedBody(question))
builder.authorAci = pollAuthorAci
builder.isPoll = true
if let timestamp {
builder.timestamp = timestamp
}
}
try! incomingMessage.asRecord().insert(db)
return incomingMessage
}
}
private func insertOutgoingPollMessage(question: String) -> TSOutgoingMessage {
db.write { tx in
let db = tx.database
if try! groupThread.asRecord().exists(db) == false {
try! groupThread!.asRecord().insert(db)
}
let outgoingMessage = TSOutgoingMessage(in: groupThread, question: question)
try! outgoingMessage.asRecord().insert(db)
return outgoingMessage
}
}
private func insertSignalRecipient(aci: Aci, pni: Pni, phoneNumber: E164) {
db.write { tx in
recipientDatabaseTable.insertRecipient(
SignalRecipient(
aci: aci,
pni: pni,
phoneNumber: phoneNumber
),
transaction: tx
)
}
}
private func buildPollCreateProto(question: String, options: [String], allowMultiple: Bool) -> SSKProtoDataMessagePollCreate {
let pollCreateBuilder = SSKProtoDataMessagePollCreate.builder()
pollCreateBuilder.setQuestion(question)
pollCreateBuilder.setOptions(options)
pollCreateBuilder.setAllowMultiple(allowMultiple)
return pollCreateBuilder.buildInfallibly()
}
private func buildPollTerminateProto(targetSentTimestamp: UInt64) -> SSKProtoDataMessagePollTerminate {
let pollTerminateBuilder = SSKProtoDataMessagePollTerminate.builder()
pollTerminateBuilder.setTargetSentTimestamp(targetSentTimestamp)
return pollTerminateBuilder.buildInfallibly()
}
private func buildPollVoteProto(
pollAuthor: Aci,
targetSentTimestamp: UInt64,
optionIndexes: [OWSPoll.OptionIndex],
voteCount: UInt32
) -> SSKProtoDataMessagePollVote {
let pollVoteBuilder = SSKProtoDataMessagePollVote.builder()
pollVoteBuilder.setTargetAuthorAciBinary(pollAuthor.serviceIdBinary)
pollVoteBuilder.setTargetSentTimestamp(targetSentTimestamp)
pollVoteBuilder.setOptionIndexes(optionIndexes)
pollVoteBuilder.setVoteCount(voteCount)
return pollVoteBuilder.buildInfallibly()
}
@Test
func testPollCreate() throws {
let question = "What should we have for breakfast?"
_ = insertIncomingPollMessage(question: question)
let pollCreateProto = buildPollCreateProto(
question: question,
options: ["pancakes", "waffles"],
allowMultiple: false
)
try db.write { tx in
try pollMessageManager.processIncomingPollCreate(
interactionId: 1,
pollCreateProto: pollCreateProto,
transaction: tx
)
}
try db.read { tx in
let pollRecords = try PollRecord.fetchAll(tx.database)
#expect(pollRecords.count == 1)
#expect(pollRecords.first!.interactionId == 1)
#expect(pollRecords.first!.allowsMultiSelect == false)
}
try db.read { tx in
let pollOptions = try PollOptionRecord.fetchAll(tx.database)
#expect(pollOptions.count == 2)
#expect(pollOptions.first!.id == 1)
#expect(pollOptions.first!.pollId == 1)
#expect(pollOptions.first!.option == "pancakes")
#expect(pollOptions.first!.optionIndex == 0)
#expect(pollOptions.last!.id == 2)
#expect(pollOptions.last!.pollId == 1)
#expect(pollOptions.last!.option == "waffles")
#expect(pollOptions.last!.optionIndex == 1)
}
}
@Test
func testIncomingPollTerminate() throws {
let question = "What should we have for breakfast?"
let incomingMessage = insertIncomingPollMessage(question: question)
let pollCreateProto = buildPollCreateProto(
question: question,
options: ["pancakes", "waffles"],
allowMultiple: false
)
try db.write { tx in
try pollMessageManager.processIncomingPollCreate(
interactionId: 1,
pollCreateProto: pollCreateProto,
transaction: tx
)
}
// Before voting, insert voter into Signal Recipient Table
// which is referenced by id in the vote table.
let voterAci = Aci.constantForTesting("00000000-0000-4000-8000-000000000001")
insertSignalRecipient(
aci: voterAci,
pni: Pni.constantForTesting("PNI:00000000-0000-4000-8000-0000000000b1"),
phoneNumber: E164("+16505550101")!
)
try db.write { tx in
var vote1 = PollVoteRecord(optionId: 1, voteAuthorId: 1, voteCount: 1, voteState: .vote)
try vote1.insert(tx.database)
}
let terminateProto = buildPollTerminateProto(targetSentTimestamp: incomingMessage.timestamp)
try db.write { tx in
_ = try pollMessageManager.processIncomingPollTerminate(
pollTerminateProto: terminateProto,
terminateAuthor: pollAuthorAci,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: incomingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.sortedOptions()[0].text == "pancakes")
#expect(owsPoll!.sortedOptions()[1].text == "waffles")
#expect(owsPoll!.allowsMultiSelect == false)
#expect(owsPoll!.isEnded == true)
#expect(owsPoll!.totalVotes() == 1)
}
}
@Test
func testOutgoingPollTerminate() throws {
mockTSAccountManager.localIdentifiersMock = {
return LocalIdentifiers(
aci: pollAuthorAci,
pni: Pni(fromUUID: UUID()),
e164: E164("+16505550101")!
)
}
let question = "What should we have for breakfast?"
let outgoingMessage = insertOutgoingPollMessage(question: question)
try db.write { tx in
try pollMessageManager.processOutgoingPollCreate(
interactionId: outgoingMessage.grdbId as! Int64,
pollOptions: ["pancakes", "waffles"],
allowsMultiSelect: false,
transaction: tx
)
}
// Before voting, insert voter into Signal Recipient Table
// which is referenced by id in the vote table.
let voterAci = Aci.constantForTesting("00000000-0000-4000-8000-000000000001")
insertSignalRecipient(
aci: voterAci,
pni: Pni.constantForTesting("PNI:00000000-0000-4000-8000-0000000000b1"),
phoneNumber: E164("+16505550101")!
)
try db.write { tx in
var vote1 = PollVoteRecord(optionId: 1, voteAuthorId: 1, voteCount: 1, voteState: .vote)
try vote1.insert(tx.database)
}
let terminateProto = buildPollTerminateProto(targetSentTimestamp: outgoingMessage.timestamp)
try db.write { tx in
_ = try pollMessageManager.processIncomingPollTerminate(
pollTerminateProto: terminateProto,
terminateAuthor: pollAuthorAci,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: outgoingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.sortedOptions()[0].text == "pancakes")
#expect(owsPoll!.sortedOptions()[1].text == "waffles")
#expect(owsPoll!.allowsMultiSelect == false)
#expect(owsPoll!.isEnded == true)
#expect(owsPoll!.totalVotes() == 1)
}
}
@Test
func testIncomingPollVote() throws {
mockTSAccountManager.localIdentifiersMock = {
return LocalIdentifiers(
aci: pollAuthorAci,
pni: Pni(fromUUID: UUID()),
e164: E164("+16505550101")!
)
}
let question = "What should we have for breakfast?"
let outgoingMessage = insertOutgoingPollMessage(question: question)
try db.write { tx in
try pollMessageManager.processOutgoingPollCreate(
interactionId: outgoingMessage.grdbId as! Int64,
pollOptions: ["pancakes", "waffles"],
allowsMultiSelect: false,
transaction: tx
)
}
// Before voting, insert voter into Signal Recipient Table
// which is referenced by id in the vote table.
let voterAci = Aci.constantForTesting("00000000-0000-4000-8000-000000000002")
insertSignalRecipient(
aci: voterAci,
pni: Pni.constantForTesting("PNI:00000000-0000-4000-8000-0000000000b1"),
phoneNumber: E164("+16505550101")!
)
let pollWaffleVoteProto = buildPollVoteProto(
pollAuthor: pollAuthorAci,
targetSentTimestamp: outgoingMessage.timestamp,
optionIndexes: [1],
voteCount: 1
)
_ = try db.write { tx in
try pollMessageManager.processIncomingPollVote(
voteAuthor: voterAci,
pollVoteProto: pollWaffleVoteProto,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: outgoingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.sortedOptions()[0].text == "pancakes")
#expect(owsPoll!.sortedOptions()[1].text == "waffles")
#expect(owsPoll!.allowsMultiSelect == false)
#expect(owsPoll!.isEnded == false)
#expect(owsPoll!.totalVotes() == 1)
let wafflesOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(wafflesOption!.acis.contains(voterAci))
}
// Revoke vote for waffle and send it to pancake
let pollVoteProtoRevoke = buildPollVoteProto(
pollAuthor: pollAuthorAci,
targetSentTimestamp: outgoingMessage.timestamp,
optionIndexes: [0],
voteCount: 2
)
_ = try db.write { tx in
try pollMessageManager.processIncomingPollVote(
voteAuthor: voterAci,
pollVoteProto: pollVoteProtoRevoke,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: outgoingMessage, transaction: tx)
let wafflesOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(wafflesOption!.acis.isEmpty)
let pancakesOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakesOption!.acis.contains(voterAci))
}
// Voting with multiple options should fail to update votes
let pollVoteProtoMultiple = buildPollVoteProto(
pollAuthor: pollAuthorAci,
targetSentTimestamp: outgoingMessage.timestamp,
optionIndexes: [0, 1],
voteCount: 3
)
_ = try db.write { tx in
try pollMessageManager.processIncomingPollVote(
voteAuthor: voterAci,
pollVoteProto: pollVoteProtoMultiple,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: outgoingMessage, transaction: tx)
let wafflesOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(wafflesOption!.acis.isEmpty)
let pancakesOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakesOption!.acis.contains(voterAci))
}
}
@Test
func testOutgoingPollVote() throws {
let question = "What should we have for breakfast?"
let incomingMessage = insertIncomingPollMessage(question: question)
let pollCreateProto = buildPollCreateProto(
question: question,
options: ["pancakes", "waffles"],
allowMultiple: false
)
try db.write { tx in
try pollMessageManager.processIncomingPollCreate(
interactionId: 1,
pollCreateProto: pollCreateProto,
transaction: tx
)
}
// Before voting, insert voters into Signal Recipient Table
// which is referenced by id in the vote table.
let waffleVoterAci = Aci.constantForTesting("00000000-0000-4000-8000-000000000001")
let pancakeVoterAci = Aci.constantForTesting("00000000-0000-4000-8000-000000000002")
insertSignalRecipient(
aci: waffleVoterAci,
pni: Pni.constantForTesting("PNI:00000000-0000-4000-8000-0000000000b1"),
phoneNumber: E164("+16505550101")!
)
insertSignalRecipient(
aci: pancakeVoterAci,
pni: Pni.constantForTesting("PNI:00000000-0000-4000-8000-0000000000b2"),
phoneNumber: E164("+16505550102")!
)
let pollWaffleVoteProto = buildPollVoteProto(
pollAuthor: pollAuthorAci,
targetSentTimestamp: incomingMessage.timestamp,
optionIndexes: [1],
voteCount: 1
)
_ = try db.write { tx in
try pollMessageManager.processIncomingPollVote(
voteAuthor: waffleVoterAci,
pollVoteProto: pollWaffleVoteProto,
transaction: tx
)
}
let pollPancakesVoteProto = buildPollVoteProto(
pollAuthor: pollAuthorAci,
targetSentTimestamp: incomingMessage.timestamp,
optionIndexes: [0],
voteCount: 1
)
_ = try db.write { tx in
try pollMessageManager.processIncomingPollVote(
voteAuthor: pancakeVoterAci,
pollVoteProto: pollPancakesVoteProto,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: incomingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.sortedOptions()[0].text == "pancakes")
#expect(owsPoll!.sortedOptions()[1].text == "waffles")
#expect(owsPoll!.allowsMultiSelect == false)
#expect(owsPoll!.isEnded == false)
#expect(owsPoll!.totalVotes() == 2)
let pancakesOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakesOption!.acis.contains(pancakeVoterAci))
let wafflesOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(wafflesOption!.acis.contains(waffleVoterAci))
}
// Revoke vote for pancake and send it to waffle
let pollVoteProtoRevoke = buildPollVoteProto(
pollAuthor: pollAuthorAci,
targetSentTimestamp: incomingMessage.timestamp,
optionIndexes: [1],
voteCount: 2
)
_ = try db.write { tx in
try pollMessageManager.processIncomingPollVote(
voteAuthor: pancakeVoterAci,
pollVoteProto: pollVoteProtoRevoke,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: incomingMessage, transaction: tx)
let pancakesOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakesOption!.acis.isEmpty)
let wafflesOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(wafflesOption!.acis.contains(waffleVoterAci))
#expect(wafflesOption!.acis.contains(pancakeVoterAci))
}
// Voting with multiple options should fail to update votes
let pollVoteProtoMultiple = buildPollVoteProto(
pollAuthor: pollAuthorAci,
targetSentTimestamp: incomingMessage.timestamp,
optionIndexes: [0, 1],
voteCount: 3
)
_ = try db.write { tx in
try pollMessageManager.processIncomingPollVote(
voteAuthor: pancakeVoterAci,
pollVoteProto: pollVoteProtoMultiple,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: incomingMessage, transaction: tx)
let pancakesOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakesOption!.acis.isEmpty)
let wafflesOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(wafflesOption!.acis.contains(waffleVoterAci))
#expect(wafflesOption!.acis.contains(pancakeVoterAci))
}
}
@Test
func testPollVote_multiSelection() throws {
let question = "What should we have for breakfast?"
let incomingMessage = insertIncomingPollMessage(question: question)
let pollCreateProto = buildPollCreateProto(
question: question,
options: ["pancakes", "waffles"],
allowMultiple: true
)
try db.write { tx in
try pollMessageManager.processIncomingPollCreate(
interactionId: 1,
pollCreateProto: pollCreateProto,
transaction: tx
)
}
// Before voting, insert voter into Signal Recipient Table
// which is referenced by id in the vote table.
let voterAci = Aci.constantForTesting("00000000-0000-4000-8000-000000000001")
insertSignalRecipient(
aci: voterAci,
pni: Pni.constantForTesting("PNI:00000000-0000-4000-8000-0000000000b1"),
phoneNumber: E164("+16505550101")!
)
let pollVoteProto = buildPollVoteProto(
pollAuthor: pollAuthorAci,
targetSentTimestamp: incomingMessage.timestamp,
optionIndexes: [0, 1],
voteCount: 1
)
_ = try db.write { tx in
try pollMessageManager.processIncomingPollVote(
voteAuthor: voterAci,
pollVoteProto: pollVoteProto,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: incomingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.sortedOptions()[0].text == "pancakes")
#expect(owsPoll!.sortedOptions()[1].text == "waffles")
#expect(owsPoll!.allowsMultiSelect == true)
#expect(owsPoll!.isEnded == false)
#expect(owsPoll!.totalVotes() == 2)
let pancakesOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakesOption!.acis.contains(voterAci))
let wafflesOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(wafflesOption!.acis.contains(voterAci))
}
// Revoke vote for waffle
let pollVoteProtoRevoke = buildPollVoteProto(
pollAuthor: pollAuthorAci,
targetSentTimestamp: incomingMessage.timestamp,
optionIndexes: [0],
voteCount: 2
)
_ = try db.write { tx in
try pollMessageManager.processIncomingPollVote(
voteAuthor: voterAci,
pollVoteProto: pollVoteProtoRevoke,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: incomingMessage, transaction: tx)
#expect(owsPoll!.totalVotes() == 1)
let pancakesOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakesOption!.acis.contains(voterAci))
let wafflesOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(wafflesOption!.acis.isEmpty)
}
}
@Test
func testPollVote_dontOverwriteVoteWithOldVoteCount() throws {
let question = "What should we have for breakfast?"
let incomingMessage = insertIncomingPollMessage(question: question)
let pollCreateProto = buildPollCreateProto(
question: question,
options: ["pancakes", "waffles"],
allowMultiple: true
)
try db.write { tx in
try pollMessageManager.processIncomingPollCreate(
interactionId: 1,
pollCreateProto: pollCreateProto,
transaction: tx
)
}
// Before voting, insert voter into Signal Recipient Table
// which is referenced by id in the vote table.
let voterAci = Aci.constantForTesting("00000000-0000-4000-8000-000000000001")
insertSignalRecipient(
aci: voterAci,
pni: Pni.constantForTesting("PNI:00000000-0000-4000-8000-0000000000b1"),
phoneNumber: E164("+16505550101")!
)
let pollVoteProto = buildPollVoteProto(
pollAuthor: pollAuthorAci,
targetSentTimestamp: incomingMessage.timestamp,
optionIndexes: [0], // pancakes
voteCount: 2
)
_ = try db.write { tx in
try pollMessageManager.processIncomingPollVote(
voteAuthor: voterAci,
pollVoteProto: pollVoteProto,
transaction: tx
)
}
// Now send old voteCount with a different vote (waffles)
let oldPollVoteProto = buildPollVoteProto(
pollAuthor: pollAuthorAci,
targetSentTimestamp: incomingMessage.timestamp,
optionIndexes: [1],
voteCount: 1
)
_ = try db.write { tx in
try pollMessageManager.processIncomingPollVote(
voteAuthor: voterAci,
pollVoteProto: oldPollVoteProto,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: incomingMessage, transaction: tx)
#expect(owsPoll!.totalVotes() == 1)
let pancakesOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakesOption!.acis.contains(voterAci))
let wafflesOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(wafflesOption!.acis.isEmpty)
}
}
@Test
func testPollVote_dontOverwriteUnvoteWithOldVoteCount() throws {
let question = "What should we have for breakfast?"
let incomingMessage = insertIncomingPollMessage(question: question)
let pollCreateProto = buildPollCreateProto(
question: question,
options: ["pancakes", "waffles"],
allowMultiple: true
)
try db.write { tx in
try pollMessageManager.processIncomingPollCreate(
interactionId: 1,
pollCreateProto: pollCreateProto,
transaction: tx
)
}
// Before voting, insert voter into Signal Recipient Table
// which is referenced by id in the vote table.
let voterAci = Aci.constantForTesting("00000000-0000-4000-8000-000000000001")
insertSignalRecipient(
aci: voterAci,
pni: Pni.constantForTesting("PNI:00000000-0000-4000-8000-0000000000b1"),
phoneNumber: E164("+16505550101")!
)
let pollVoteProto = buildPollVoteProto(
pollAuthor: pollAuthorAci,
targetSentTimestamp: incomingMessage.timestamp,
optionIndexes: [0], // pancakes
voteCount: 2
)
_ = try db.write { tx in
try pollMessageManager.processIncomingPollVote(
voteAuthor: voterAci,
pollVoteProto: pollVoteProto,
transaction: tx
)
}
// Now send an unvote with a higher vote count
let pollUnVoteProto = buildPollVoteProto(
pollAuthor: pollAuthorAci,
targetSentTimestamp: incomingMessage.timestamp,
optionIndexes: [], // unvote for pancakes
voteCount: 4
)
_ = try db.write { tx in
try pollMessageManager.processIncomingPollVote(
voteAuthor: voterAci,
pollVoteProto: pollUnVoteProto,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: incomingMessage, transaction: tx)
#expect(owsPoll!.totalVotes() == 0)
let pancakesOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakesOption!.acis.isEmpty)
let wafflesOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(wafflesOption!.acis.isEmpty)
}
// Now send old voteCount with a different vote (waffles)
let oldPollVoteProto = buildPollVoteProto(
pollAuthor: pollAuthorAci,
targetSentTimestamp: incomingMessage.timestamp,
optionIndexes: [1],
voteCount: 3
)
_ = try db.write { tx in
try pollMessageManager.processIncomingPollVote(
voteAuthor: voterAci,
pollVoteProto: oldPollVoteProto,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: incomingMessage, transaction: tx)
#expect(owsPoll!.totalVotes() == 0)
let pancakesOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakesOption!.acis.isEmpty)
let wafflesOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(wafflesOption!.acis.isEmpty)
}
}
@Test
func testMultiplePollsSameAuthor() throws {
let question1 = "What should we have for breakfast?"
let incomingMessage1 = insertIncomingPollMessage(question: question1)
// Make sure these don't have the same timestamp because thats used to ID polls.
let question2 = "What is your favorite animal?"
let incomingMessage2 = insertIncomingPollMessage(question: question2, timestamp: incomingMessage1.timestamp + 1)
let poll1CreateProto = buildPollCreateProto(
question: question1,
options: ["pancakes", "waffles"],
allowMultiple: false
)
let poll2CreateProto = buildPollCreateProto(
question: question2,
options: ["dog", "cat"],
allowMultiple: false
)
try db.write { tx in
try pollMessageManager.processIncomingPollCreate(
interactionId: 1,
pollCreateProto: poll1CreateProto,
transaction: tx
)
try pollMessageManager.processIncomingPollCreate(
interactionId: 2,
pollCreateProto: poll2CreateProto,
transaction: tx
)
}
// Before voting, insert voters into Signal Recipient Table
// which is referenced by id in the vote table.
let user1Aci = Aci.constantForTesting("00000000-0000-4000-8000-000000000001")
let user2Aci = Aci.constantForTesting("00000000-0000-4000-8000-000000000002")
insertSignalRecipient(
aci: user1Aci,
pni: Pni.constantForTesting("PNI:00000000-0000-4000-8000-0000000000b1"),
phoneNumber: E164("+16505550101")!
)
insertSignalRecipient(
aci: user2Aci,
pni: Pni.constantForTesting("PNI:00000000-0000-4000-8000-0000000000b2"),
phoneNumber: E164("+16505550102")!
)
// user1 is going to vote for pancakes, and dogs
let user1VoteProto1 = buildPollVoteProto(
pollAuthor: pollAuthorAci,
targetSentTimestamp: incomingMessage1.timestamp,
optionIndexes: [0], // pancakes
voteCount: 1
)
let user1VoteProto2 = buildPollVoteProto(
pollAuthor: pollAuthorAci,
targetSentTimestamp: incomingMessage2.timestamp,
optionIndexes: [0], // dog
voteCount: 1
)
try db.write { tx in
_ = try pollMessageManager.processIncomingPollVote(
voteAuthor: user1Aci,
pollVoteProto: user1VoteProto1,
transaction: tx
)
_ = try pollMessageManager.processIncomingPollVote(
voteAuthor: user1Aci,
pollVoteProto: user1VoteProto2,
transaction: tx
)
}
// user2 is going to vote for waffles, and dogs
let user2VoteProto1 = buildPollVoteProto(
pollAuthor: pollAuthorAci,
targetSentTimestamp: incomingMessage1.timestamp,
optionIndexes: [1], // waffles
voteCount: 1
)
let user2VoteProto2 = buildPollVoteProto(
pollAuthor: pollAuthorAci,
targetSentTimestamp: incomingMessage2.timestamp,
optionIndexes: [0], // dog
voteCount: 1
)
try db.write { tx in
_ = try pollMessageManager.processIncomingPollVote(
voteAuthor: user2Aci,
pollVoteProto: user2VoteProto1,
transaction: tx
)
_ = try pollMessageManager.processIncomingPollVote(
voteAuthor: user2Aci,
pollVoteProto: user2VoteProto2,
transaction: tx
)
}
try db.read { tx in
// Poll 1 - pancakes and waffles
let owsPoll1 = try pollMessageManager.buildPoll(message: incomingMessage1, transaction: tx)
#expect(owsPoll1!.question == question1)
#expect(owsPoll1!.sortedOptions()[0].text == "pancakes")
#expect(owsPoll1!.sortedOptions()[1].text == "waffles")
#expect(owsPoll1!.allowsMultiSelect == false)
#expect(owsPoll1!.isEnded == false)
#expect(owsPoll1!.totalVotes() == 2)
let pancakesOption = owsPoll1!.optionForIndex(optionIndex: 0)
#expect(pancakesOption!.acis.contains(user1Aci))
let wafflesOption = owsPoll1!.optionForIndex(optionIndex: 1)
#expect(wafflesOption!.acis.contains(user2Aci))
// Poll 2 - dogs and cats
let owsPoll2 = try pollMessageManager.buildPoll(message: incomingMessage2, transaction: tx)
#expect(owsPoll2!.question == question2)
#expect(owsPoll2!.sortedOptions()[0].text == "dog")
#expect(owsPoll2!.sortedOptions()[1].text == "cat")
#expect(owsPoll2!.allowsMultiSelect == false)
#expect(owsPoll2!.isEnded == false)
#expect(owsPoll2!.totalVotes() == 2)
let dogOption = owsPoll2!.optionForIndex(optionIndex: 0)
#expect(dogOption!.acis == [user1Aci, user2Aci])
let catOption = owsPoll2!.optionForIndex(optionIndex: 1)
#expect(catOption!.acis.isEmpty)
}
}
@Test
func testPollEnded() throws {
let question = "What should we have for breakfast?"
let incomingMessage = insertIncomingPollMessage(question: question)
var poll = PollRecord(interactionId: 1, allowsMultiSelect: false)
poll.isEnded = true
try db.write { tx in
try poll.insert(tx.database)
}
var option = PollOptionRecord(pollId: poll.id!, option: "test", optionIndex: 0)
try db.write { tx in
try option.insert(tx.database)
}
// Before voting, insert voters into Signal Recipient Table
// which is referenced by id in the vote table.
let aci = Aci.constantForTesting("00000000-0000-4000-8000-000000000001")
insertSignalRecipient(
aci: aci,
pni: Pni.constantForTesting("PNI:00000000-0000-4000-8000-0000000000b1"),
phoneNumber: E164("+16505550101")!
)
let proto = buildPollVoteProto(
pollAuthor: pollAuthorAci,
targetSentTimestamp: incomingMessage.timestamp,
optionIndexes: [1],
voteCount: 1
)
_ = try db.write { tx in
try pollMessageManager.processIncomingPollVote(
voteAuthor: aci,
pollVoteProto: proto,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: incomingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.isEnded == true)
#expect(owsPoll!.totalVotes() == 0)
}
}
@Test
func testPendingThenSentVote_singleSelect() throws {
insertSignalRecipient(
aci: pollAuthorAci,
pni: Pni.constantForTesting("PNI:00000000-0000-4000-8000-0000000000b1"),
phoneNumber: E164("+16505550101")!
)
let question = "What should we have for breakfast?"
let outgoingMessage = insertOutgoingPollMessage(question: question)
let pollCreateProto = buildPollCreateProto(
question: question,
options: ["pancakes", "waffles"],
allowMultiple: false
)
try db.write { tx in
try pollMessageManager.processIncomingPollCreate(
interactionId: 1,
pollCreateProto: pollCreateProto,
transaction: tx
)
}
let signalRecipient = db.read { tx in
recipientDatabaseTable.fetchRecipient(serviceId: pollAuthorAci, transaction: tx)
}
var voteCount = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: outgoingMessage.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(0), // pancakes
isUnvote: false,
transaction: tx
)
}
// Pending vote should not count as a vote.
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: outgoingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.totalVotes() == 0)
}
_ = try db.write { tx in
try pollStore.updatePollWithVotes(
interactionId: outgoingMessage.grdbId!.int64Value,
optionsVoted: [OWSPoll.OptionIndex(0)], // pancakes,
voteAuthorId: signalRecipient!.id!,
voteCount: UInt32(voteCount!),
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: outgoingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.totalVotes() == 1)
let pancakeOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakeOption!.acis == [pollAuthorAci])
}
// Unvote
voteCount = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: outgoingMessage.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(0), // pancakes
isUnvote: true,
transaction: tx
)
}
// Since unvote is still pending, the vote is still valid.
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: outgoingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.totalVotes() == 1)
let pancakeOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakeOption!.acis == [pollAuthorAci])
}
_ = try db.write { tx in
try pollStore.updatePollWithVotes(
interactionId: outgoingMessage.grdbId!.int64Value,
optionsVoted: [], // unvote
voteAuthorId: signalRecipient!.id!,
voteCount: UInt32(voteCount!),
transaction: tx
)
}
// Sent unvote should now be finalized.
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: outgoingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.totalVotes() == 0)
}
}
@Test
func testPendingThenSentVote_multiSelect() throws {
insertSignalRecipient(
aci: pollAuthorAci,
pni: Pni.constantForTesting("PNI:00000000-0000-4000-8000-0000000000b1"),
phoneNumber: E164("+16505550101")!
)
let question = "What should we have for breakfast?"
let outgoingMessage = insertOutgoingPollMessage(question: question)
let pollCreateProto = buildPollCreateProto(
question: question,
options: ["pancakes", "waffles"],
allowMultiple: true
)
try db.write { tx in
try pollMessageManager.processIncomingPollCreate(
interactionId: 1,
pollCreateProto: pollCreateProto,
transaction: tx
)
}
let signalRecipient = db.read { tx in
recipientDatabaseTable.fetchRecipient(serviceId: pollAuthorAci, transaction: tx)
}
var voteCount = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: outgoingMessage.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(0), // pancakes
isUnvote: false,
transaction: tx
)
}
_ = try db.write { tx in
try pollStore.updatePollWithVotes(
interactionId: outgoingMessage.grdbId!.int64Value,
optionsVoted: [OWSPoll.OptionIndex(0)], // pancakes
voteAuthorId: signalRecipient!.id!,
voteCount: UInt32(voteCount!),
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: outgoingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.totalVotes() == 1)
let pancakeOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakeOption!.acis == [pollAuthorAci])
}
// Vote for another option.
voteCount = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: outgoingMessage.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(1), // waffles
isUnvote: false,
transaction: tx
)
}
// pending waffle vote should not affect pancakes vote.
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: outgoingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.totalVotes() == 1)
let pancakeOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakeOption!.acis == [pollAuthorAci])
let waffleOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(waffleOption!.acis.isEmpty)
}
_ = try db.write { tx in
try pollStore.updatePollWithVotes(
interactionId: outgoingMessage.grdbId!.int64Value,
optionsVoted: [OWSPoll.OptionIndex(1), OWSPoll.OptionIndex(0)],
voteAuthorId: signalRecipient!.id!,
voteCount: UInt32(voteCount!),
transaction: tx
)
}
// Sent second vote should now be finalized.
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: outgoingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.totalVotes() == 2)
let pancakeOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakeOption!.acis == [pollAuthorAci])
let waffleOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(waffleOption!.acis == [pollAuthorAci])
}
// Unvote for pancakes
voteCount = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: outgoingMessage.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(0), // pancakes
isUnvote: true,
transaction: tx
)
}
_ = try db.write { tx in
try pollStore.updatePollWithVotes(
interactionId: outgoingMessage.grdbId!.int64Value,
optionsVoted: [OWSPoll.OptionIndex(1)], // waffles only
voteAuthorId: signalRecipient!.id!,
voteCount: UInt32(voteCount!),
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: outgoingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.totalVotes() == 1)
let pancakeOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakeOption!.acis.isEmpty)
let waffleOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(waffleOption!.acis == [pollAuthorAci])
}
}
@Test
func testMultiplePendingBeforeSent_multi() throws {
insertSignalRecipient(
aci: pollAuthorAci,
pni: Pni.constantForTesting("PNI:00000000-0000-4000-8000-0000000000b1"),
phoneNumber: E164("+16505550101")!
)
let question = "What should we have for breakfast?"
let outgoingMessage = insertOutgoingPollMessage(question: question)
let pollCreateProto = buildPollCreateProto(
question: question,
options: ["pancakes", "waffles"],
allowMultiple: true
)
try db.write { tx in
try pollMessageManager.processIncomingPollCreate(
interactionId: 1,
pollCreateProto: pollCreateProto,
transaction: tx
)
}
let signalRecipient = db.read { tx in
recipientDatabaseTable.fetchRecipient(serviceId: pollAuthorAci, transaction: tx)
}
let voteCount1 = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: outgoingMessage.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(0), // pancakes
isUnvote: false,
transaction: tx
)
}
let voteCount2 = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: outgoingMessage.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(1), // waffles
isUnvote: false,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: outgoingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.totalVotes() == 0)
}
_ = try db.write { tx in
try pollStore.updatePollWithVotes(
interactionId: outgoingMessage.grdbId!.int64Value,
optionsVoted: [OWSPoll.OptionIndex(0)], // pancakes
voteAuthorId: signalRecipient!.id!,
voteCount: UInt32(voteCount1!),
transaction: tx
)
}
_ = try db.write { tx in
try pollStore.updatePollWithVotes(
interactionId: outgoingMessage.grdbId!.int64Value,
optionsVoted: [OWSPoll.OptionIndex(0), OWSPoll.OptionIndex(1)], // waffles + pancakes
voteAuthorId: signalRecipient!.id!,
voteCount: UInt32(voteCount2!),
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: outgoingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.totalVotes() == 2)
let pancakeOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakeOption!.acis == [pollAuthorAci])
let waffleOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(waffleOption!.acis == [pollAuthorAci])
}
}
@Test
func testOutOfOrderPendingAndSent() throws {
insertSignalRecipient(
aci: pollAuthorAci,
pni: Pni.constantForTesting("PNI:00000000-0000-4000-8000-0000000000b1"),
phoneNumber: E164("+16505550101")!
)
let question = "What should we have for breakfast?"
let outgoingMessage = insertOutgoingPollMessage(question: question)
let pollCreateProto = buildPollCreateProto(
question: question,
options: ["pancakes", "waffles"],
allowMultiple: true
)
try db.write { tx in
try pollMessageManager.processIncomingPollCreate(
interactionId: 1,
pollCreateProto: pollCreateProto,
transaction: tx
)
}
let signalRecipient = db.read { tx in
recipientDatabaseTable.fetchRecipient(serviceId: pollAuthorAci, transaction: tx)
}
let voteCount1 = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: outgoingMessage.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(0), // pancakes
isUnvote: false,
transaction: tx
)
}
let voteCount2 = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: outgoingMessage.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(1), // waffles
isUnvote: false,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: outgoingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.totalVotes() == 0)
}
// Send vote count 2 first, state should be updated
_ = try db.write { tx in
try pollStore.updatePollWithVotes(
interactionId: outgoingMessage.grdbId!.int64Value,
optionsVoted: [OWSPoll.OptionIndex(0), OWSPoll.OptionIndex(1)], // waffles + pancakes
voteAuthorId: signalRecipient!.id!,
voteCount: UInt32(voteCount2!),
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: outgoingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.totalVotes() == 2)
let pancakeOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakeOption!.acis == [pollAuthorAci])
let waffleOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(waffleOption!.acis == [pollAuthorAci])
}
// Now send vote count 1 -> should be ignored.
_ = try db.write { tx in
try pollStore.updatePollWithVotes(
interactionId: outgoingMessage.grdbId!.int64Value,
optionsVoted: [OWSPoll.OptionIndex(0)], // pancakes
voteAuthorId: signalRecipient!.id!,
voteCount: UInt32(voteCount1!),
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: outgoingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.totalVotes() == 2)
let pancakeOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakeOption!.acis == [pollAuthorAci])
let waffleOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(waffleOption!.acis == [pollAuthorAci])
}
}
@Test
func testSendFails_singleSelect() throws {
let question = "What should we have for breakfast?"
var voteAuthorAci: Aci
let message = insertOutgoingPollMessage(question: question)
voteAuthorAci = pollAuthorAci
insertSignalRecipient(
aci: pollAuthorAci,
pni: Pni.constantForTesting("PNI:00000000-0000-4000-8000-0000000000b1"),
phoneNumber: E164("+16505550101")!
)
let pollCreateProto = buildPollCreateProto(
question: question,
options: ["pancakes", "waffles"],
allowMultiple: false
)
try db.write { tx in
try pollMessageManager.processIncomingPollCreate(
interactionId: 1,
pollCreateProto: pollCreateProto,
transaction: tx
)
}
let signalRecipient = db.read { tx in
recipientDatabaseTable.fetchRecipient(serviceId: voteAuthorAci, transaction: tx)
}
let voteCount1 = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: message.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(0), // pancakes
isUnvote: false,
transaction: tx
)
}
_ = try db.write { tx in
try pollStore.updatePollWithVotes(
interactionId: message.grdbId!.int64Value,
optionsVoted: [OWSPoll.OptionIndex(0)], // pancakes
voteAuthorId: signalRecipient!.id!,
voteCount: UInt32(voteCount1!),
transaction: tx
)
}
// send pending message for another, different vote
let voteCount2 = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: message.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(1), // waffles
isUnvote: false,
transaction: tx
)
}
// Simulate vote fail, and rollback to old state
try db.write { tx in
try pollStore.revertVoteCount(
voteCount: voteCount2!,
interactionId: message.grdbId!.int64Value,
voteAuthorId: signalRecipient!.id!,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: message, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.totalVotes() == 1)
let pancakeOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakeOption!.acis == [voteAuthorAci])
let waffleOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(waffleOption!.acis.isEmpty)
}
// Now send successful vote
let voteCount3 = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: message.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(1), // waffles
isUnvote: false,
transaction: tx
)
}
_ = try db.write { tx in
try pollStore.updatePollWithVotes(
interactionId: message.grdbId!.int64Value,
optionsVoted: [OWSPoll.OptionIndex(1)], // waffles
voteAuthorId: signalRecipient!.id!,
voteCount: UInt32(voteCount3!),
transaction: tx
)
}
// send pending message for unvote
let voteCount4 = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: message.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(1), // waffles
isUnvote: true,
transaction: tx
)
}
// Simulate vote fail, and rollback to old state
try db.write { tx in
try pollStore.revertVoteCount(
voteCount: voteCount4!,
interactionId: message.grdbId!.int64Value,
voteAuthorId: signalRecipient!.id!,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: message, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.totalVotes() == 1)
let pancakeOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakeOption!.acis.isEmpty)
let waffleOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(waffleOption!.acis == [voteAuthorAci])
}
}
@Test
func testSendFails_multiSelect() throws {
let question = "What should we have for breakfast?"
var voteAuthorAci: Aci
let message = insertOutgoingPollMessage(question: question)
voteAuthorAci = pollAuthorAci
insertSignalRecipient(
aci: pollAuthorAci,
pni: Pni.constantForTesting("PNI:00000000-0000-4000-8000-0000000000b1"),
phoneNumber: E164("+16505550101")!
)
let pollCreateProto = buildPollCreateProto(
question: question,
options: ["pancakes", "waffles"],
allowMultiple: true
)
try db.write { tx in
try pollMessageManager.processIncomingPollCreate(
interactionId: 1,
pollCreateProto: pollCreateProto,
transaction: tx
)
}
let signalRecipient = db.read { tx in
recipientDatabaseTable.fetchRecipient(serviceId: voteAuthorAci, transaction: tx)
}
let voteCount1 = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: message.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(0), // pancakes
isUnvote: false,
transaction: tx
)
}
_ = try db.write { tx in
try pollStore.updatePollWithVotes(
interactionId: message.grdbId!.int64Value,
optionsVoted: [OWSPoll.OptionIndex(0)], // pancakes
voteAuthorId: signalRecipient!.id!,
voteCount: UInt32(voteCount1!),
transaction: tx
)
}
// send pending message for another, different vote
let voteCount2 = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: message.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(1), // waffles
isUnvote: false,
transaction: tx
)
}
// Simulate vote fail, and rollback to old state
try db.write { tx in
try pollStore.revertVoteCount(
voteCount: voteCount2!,
interactionId: message.grdbId!.int64Value,
voteAuthorId: signalRecipient!.id!,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: message, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.totalVotes() == 1)
let pancakeOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakeOption!.acis == [voteAuthorAci])
let waffleOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(waffleOption!.acis.isEmpty)
}
// Now send successful vote
let voteCount3 = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: message.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(1), // waffles
isUnvote: false,
transaction: tx
)
}
_ = try db.write { tx in
try pollStore.updatePollWithVotes(
interactionId: message.grdbId!.int64Value,
optionsVoted: [OWSPoll.OptionIndex(0), OWSPoll.OptionIndex(1)],
voteAuthorId: signalRecipient!.id!,
voteCount: UInt32(voteCount3!),
transaction: tx
)
}
// send pending message for unvote
let voteCount4 = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: message.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(0), // pancakes
isUnvote: true,
transaction: tx
)
}
// Simulate vote fail, and rollback to old state
try db.write { tx in
try pollStore.revertVoteCount(
voteCount: voteCount4!,
interactionId: message.grdbId!.int64Value,
voteAuthorId: signalRecipient!.id!,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: message, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.totalVotes() == 2)
let pancakeOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakeOption!.acis == [voteAuthorAci])
let waffleOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(waffleOption!.acis == [voteAuthorAci])
}
}
@Test
func testSendFailsButVoteCountHasMovedOn() throws {
insertSignalRecipient(
aci: pollAuthorAci,
pni: Pni.constantForTesting("PNI:00000000-0000-4000-8000-0000000000b1"),
phoneNumber: E164("+16505550101")!
)
let question = "What should we have for breakfast?"
let outgoingMessage = insertOutgoingPollMessage(question: question)
let pollCreateProto = buildPollCreateProto(
question: question,
options: ["pancakes", "waffles"],
allowMultiple: true
)
try db.write { tx in
try pollMessageManager.processIncomingPollCreate(
interactionId: 1,
pollCreateProto: pollCreateProto,
transaction: tx
)
}
let signalRecipient = db.read { tx in
recipientDatabaseTable.fetchRecipient(serviceId: pollAuthorAci, transaction: tx)
}
// Pending vote count 1
let voteCount1 = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: outgoingMessage.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(0), // pancakes
isUnvote: false,
transaction: tx
)
}
// Successful vote count 2
let voteCount2 = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: outgoingMessage.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(1), // waffles
isUnvote: false,
transaction: tx
)
}
_ = try db.write { tx in
try pollStore.updatePollWithVotes(
interactionId: outgoingMessage.grdbId!.int64Value,
optionsVoted: [OWSPoll.OptionIndex(1)], // pancakes
voteAuthorId: signalRecipient!.id!,
voteCount: UInt32(voteCount2!),
transaction: tx
)
}
// Simulate vote fail for voteCount 1 - should be ignored since
// vote count has moved on.
try db.write { tx in
try pollStore.revertVoteCount(
voteCount: voteCount1!,
interactionId: outgoingMessage.grdbId!.int64Value,
voteAuthorId: signalRecipient!.id!,
transaction: tx
)
}
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: outgoingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.totalVotes() == 1)
let pancakeOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakeOption!.acis.isEmpty)
let waffleOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(waffleOption!.acis == [pollAuthorAci])
}
}
@Test
func testMultipleConflictingPendingStatesFail_singleSelect() async throws {
let question = "What should we have for breakfast?"
var voteAuthorAci: Aci
let outgoingMessage = insertOutgoingPollMessage(question: question)
voteAuthorAci = pollAuthorAci
insertSignalRecipient(
aci: pollAuthorAci,
pni: Pni.constantForTesting("PNI:00000000-0000-4000-8000-0000000000b1"),
phoneNumber: E164("+16505550101")!
)
let pollCreateProto = buildPollCreateProto(
question: question,
options: ["pancakes", "waffles"],
allowMultiple: false
)
try db.write { tx in
try pollMessageManager.processIncomingPollCreate(
interactionId: 1,
pollCreateProto: pollCreateProto,
transaction: tx
)
}
let signalRecipient = db.read { tx in
recipientDatabaseTable.fetchRecipient(serviceId: voteAuthorAci, transaction: tx)
}
let voteCount1 = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: outgoingMessage.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(0), // pancakes
isUnvote: false,
transaction: tx
)
}
_ = try db.write { tx in
try pollStore.updatePollWithVotes(
interactionId: outgoingMessage.grdbId!.int64Value,
optionsVoted: [OWSPoll.OptionIndex(0)], // pancakes
voteAuthorId: signalRecipient!.id!,
voteCount: UInt32(voteCount1!),
transaction: tx
)
}
// send pending message for another, different vote
let voteCount2 = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: outgoingMessage.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(1), // waffles
isUnvote: false,
transaction: tx
)
}
// send a second pending message with a conflicting vote value (aka an unvote)
let voteCount3 = try db.write { tx in
try pollStore.applyPendingVote(
interactionId: outgoingMessage.grdbId!.int64Value,
localRecipientId: signalRecipient!.id!,
optionIndex: OWSPoll.OptionIndex(1), // waffles
isUnvote: true,
transaction: tx
)
}
// Now fail both.
try db.write { tx in
try pollStore.revertVoteCount(
voteCount: voteCount2!,
interactionId: outgoingMessage.grdbId!.int64Value,
voteAuthorId: signalRecipient!.id!,
transaction: tx
)
try pollStore.revertVoteCount(
voteCount: voteCount3!,
interactionId: outgoingMessage.grdbId!.int64Value,
voteAuthorId: signalRecipient!.id!,
transaction: tx
)
}
// Should go back to original state of single vote for pancake.
try db.read { tx in
let owsPoll = try pollMessageManager.buildPoll(message: outgoingMessage, transaction: tx)
#expect(owsPoll!.question == question)
#expect(owsPoll!.totalVotes() == 1)
let pancakeOption = owsPoll!.optionForIndex(optionIndex: 0)
#expect(pancakeOption!.acis == [voteAuthorAci])
let waffleOption = owsPoll!.optionForIndex(optionIndex: 1)
#expect(waffleOption!.acis.isEmpty)
}
}
}
private extension TSOutgoingMessage {
convenience init(in thread: TSThread, question: String) {
let builder: TSOutgoingMessageBuilder = .withDefaultValues(
thread: thread,
messageBody: AttachmentContentValidatorMock.mockValidatedBody(question),
isPoll: true
)
self.init(outgoingMessageWith: builder, recipientAddressStates: [:])
}
}
private extension TSGroupThread {
static func randomForTesting() -> TSGroupThread {
return .forUnitTest(groupId: 12)
}
}