From 15e7b30fa144b9fb60f75ce5bcc1ce880fb4cdc5 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 20 May 2026 11:43:56 -0400 Subject: [PATCH] Add tests for IndividualSendJobV2. --- .../securesms/jobs/IndividualSendJobV2Test.kt | 684 ++++++++++++++++++ 1 file changed, 684 insertions(+) create mode 100644 app/src/test/java/org/thoughtcrime/securesms/jobs/IndividualSendJobV2Test.kt diff --git a/app/src/test/java/org/thoughtcrime/securesms/jobs/IndividualSendJobV2Test.kt b/app/src/test/java/org/thoughtcrime/securesms/jobs/IndividualSendJobV2Test.kt new file mode 100644 index 0000000000..0beb023942 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/jobs/IndividualSendJobV2Test.kt @@ -0,0 +1,684 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import arrow.core.left +import arrow.core.right +import assertk.assertThat +import assertk.assertions.isTrue +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.core.models.ServiceId.ACI +import org.signal.libsignal.net.ChallengeOption +import org.signal.libsignal.protocol.UntrustedIdentityException +import org.signal.network.service.MessageService +import org.thoughtcrime.securesms.attachments.Attachment +import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil +import org.thoughtcrime.securesms.database.AttachmentTable +import org.thoughtcrime.securesms.database.MessageSendLogTables +import org.thoughtcrime.securesms.database.MessageTable +import org.thoughtcrime.securesms.database.MessageTypes +import org.thoughtcrime.securesms.database.PendingPniSignatureMessageTable +import org.thoughtcrime.securesms.database.RecipientTable +import org.thoughtcrime.securesms.database.RecipientTable.SealedSenderAccessMode +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.ThreadTable +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.JobTracker +import org.thoughtcrime.securesms.keyvalue.MiscellaneousValues +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.mms.OutgoingMessage +import org.thoughtcrime.securesms.ratelimit.ProofRequiredExceptionHandler +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.RecipientUtil +import org.thoughtcrime.securesms.service.ExpiringMessageManager +import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule +import org.thoughtcrime.securesms.testutil.MockSignalStoreRule +import org.thoughtcrime.securesms.util.DataMessageError +import org.thoughtcrime.securesms.util.MessageUtil +import org.thoughtcrime.securesms.util.RemoteConfig +import org.thoughtcrime.securesms.util.toDataMessage +import org.whispersystems.signalservice.api.crypto.ContentHint +import org.whispersystems.signalservice.api.crypto.EnvelopeContent +import org.whispersystems.signalservice.internal.push.Content +import org.whispersystems.signalservice.internal.push.DataMessage +import org.whispersystems.signalservice.internal.push.PniSignatureMessage +import java.io.IOException +import java.util.Optional +import java.util.UUID +import kotlin.time.Duration.Companion.seconds + +/** + * Unit tests for [IndividualSendJobV2]. Drives the job's [doRun] via [Job.run] (which delegates to the + * coroutine entry point) with all of its collaborators mocked: [SignalStore], [SignalDatabase] tables, + * [AppDependencies] (including [MessageService]), [Recipient] statics, and the various static helpers + * the job depends on ([SealedSenderAccessUtil], [RecipientUtil], [PushSendJob], etc.). + * + * Tests verify both the [Job.Result] returned and the side effects on the database / job manager, + * including for every [MessageService.SendError] variant. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class IndividualSendJobV2Test { + + @get:Rule + val signalStore = MockSignalStoreRule() + + @get:Rule + val appDependencies = MockAppDependenciesRule() + + private val messageId: Long = 42L + private val threadId: Long = 17L + private val sentTime: Long = 1_700_000_000_000L + private val selfAci: ACI = ACI.from(UUID.fromString("00000000-0000-4000-8000-000000000001")) + private val recipientAci: ACI = ACI.from(UUID.fromString("00000000-0000-4000-8000-000000000002")) + private val selfRecipientId: RecipientId = RecipientId.from(1L) + private val recipientId: RecipientId = RecipientId.from(2L) + + private lateinit var misc: MiscellaneousValues + + private lateinit var recipient: Recipient + private lateinit var self: Recipient + private lateinit var outgoingMessage: OutgoingMessage + private lateinit var messageRecord: MessageRecord + private lateinit var dataMessage: DataMessage + + private lateinit var messages: MessageTable + private lateinit var messageLog: MessageSendLogTables + private lateinit var threads: ThreadTable + private lateinit var recipientsTable: RecipientTable + private lateinit var attachments: AttachmentTable + private lateinit var pendingPniSignatureMessages: PendingPniSignatureMessageTable + + private lateinit var expiringMessageManager: ExpiringMessageManager + private lateinit var messageService: MessageService + + @Before + fun setUp() { + misc = mockk(relaxUnitFun = true) + every { misc.isClientDeprecated } returns false + every { SignalStore.misc } returns misc + + every { signalStore.account.aci } returns selfAci + every { signalStore.account.requireAci() } returns selfAci + every { signalStore.account.isMultiDevice } returns false + every { signalStore.account.aciPreKeys.lastSignedPreKeyRotationTime } returns System.currentTimeMillis() + every { signalStore.account.pniPreKeys.lastSignedPreKeyRotationTime } returns System.currentTimeMillis() + + messages = mockk(relaxed = true) + messageLog = mockk(relaxed = true) + threads = mockk(relaxed = true) + recipientsTable = mockk(relaxed = true) + attachments = mockk(relaxed = true) + pendingPniSignatureMessages = mockk(relaxed = true) + + mockkObject(SignalDatabase.Companion) + every { SignalDatabase.messages } returns messages + every { SignalDatabase.messageLog } returns messageLog + every { SignalDatabase.threads } returns threads + every { SignalDatabase.recipients } returns recipientsTable + every { SignalDatabase.attachments } returns attachments + every { SignalDatabase.pendingPniSignatureMessages } returns pendingPniSignatureMessages + + every { messageLog.insertIfPossible(any(), any(), any(), any(), any(), any()) } returns 1L + + recipient = mockRecipient(recipientAci, isSelf = false) + self = mockRecipient(selfAci, isSelf = true) + every { self.isRegistered } returns true + + outgoingMessage = mockk { + every { threadRecipient } returns recipient + every { sentTimeMillis } returns sentTime + every { body } returns "hello" + every { messageToEdit } returns 0L + every { expiresIn } returns 0L + every { isExpirationUpdate } returns false + every { isViewOnce } returns false + every { attachments } returns emptyList() + } + + messageRecord = mockk(relaxed = true) + every { messageRecord.threadId } returns threadId + every { messageRecord.dateSent } returns sentTime + every { messageRecord.type } returns 0L + + every { messages.getOutgoingMessageOrNull(messageId) } returns outgoingMessage + every { messages.getMessageRecordOrNull(messageId) } returns messageRecord + + every { threads.getRecipientForThreadId(threadId) } returns recipient + + dataMessage = DataMessage(timestamp = sentTime) + mockkStatic("org.thoughtcrime.securesms.util.SignalServiceTransformExtensionsKt") + every { outgoingMessage.toDataMessage() } returns dataMessage.right() + + mockkObject(Recipient.Companion) + every { Recipient.self() } returns self + + mockkStatic(RecipientUtil::class) + every { RecipientUtil.shareProfileIfFirstSecureMessage(any()) } just runs + + mockkStatic(SealedSenderAccessUtil::class) + every { SealedSenderAccessUtil.getSealedSenderAccessFor(any()) } returns mockk(relaxed = true) + every { SealedSenderAccessUtil.getSealedSenderCertificate() } returns mockk(relaxed = true) + + mockkObject(RemoteConfig) + every { RemoteConfig.defaultMaxBackoff } returns 60_000L + + mockkObject(PushSendJob.Companion) + every { PushSendJob.markAttachmentsUploaded(any(), any()) } just runs + every { PushSendJob.notifyMediaMessageDeliveryFailed(any(), any()) } just runs + + mockkObject(ConversationShortcutRankingUpdateJob.Companion) + every { ConversationShortcutRankingUpdateJob.enqueueForOutgoingIfNecessary(any()) } just runs + + mockkStatic(ProofRequiredExceptionHandler::class) + + messageService = AppDependencies.messageService + expiringMessageManager = AppDependencies.expiringMessageManager + } + + @After + fun tearDown() { + unmockkAll() + } + + // region — Pre-send guards + + @Test + fun `Given client is deprecated, when run, then return failure and skip send`() { + every { SignalStore.misc.isClientDeprecated } returns true + + val result = createAndRunJob() + + assertThat(result.isFailure).isTrue() + coVerify(exactly = 0) { messageService.sendMessage(serviceId = any(), envelopeContent = any(), timestamp = any(), sealedSenderAccess = any(), story = any(), isOnline = any(), urgent = any(), onEncrypted = any()) } + } + + @Test + fun `Given signed prekey age is too old, when run, then trigger PreKeysSyncJob and continue on success`() { + val staleTime = System.currentTimeMillis() - 30L * 24L * 60L * 60L * 1000L + every { signalStore.account.aciPreKeys.lastSignedPreKeyRotationTime } returns staleTime + stubSuccessfulSend() + + every { + AppDependencies.jobManager.runSynchronously(any(), any()) + } returns Optional.of(JobTracker.JobState.SUCCESS) + + val result = createAndRunJob() + + assertThat(result.isSuccess).isTrue() + verify { AppDependencies.jobManager.runSynchronously(match { it is PreKeysSyncJob }, any()) } + } + + @Test + fun `Given signed prekey age is too old and refresh fails, when run, then retry`() { + val staleTime = System.currentTimeMillis() - 30L * 24L * 60L * 60L * 1000L + every { signalStore.account.pniPreKeys.lastSignedPreKeyRotationTime } returns staleTime + + every { + AppDependencies.jobManager.runSynchronously(any(), any()) + } returns Optional.empty() + + val result = createAndRunJob() + + assertThat(result.isRetry).isTrue() + coVerify(exactly = 0) { messageService.sendMessage(serviceId = any(), envelopeContent = any(), timestamp = any(), sealedSenderAccess = any(), story = any(), isOnline = any(), urgent = any(), onEncrypted = any()) } + } + + @Test + fun `Given self is not registered, when run, then return failure`() { + every { self.isRegistered } returns false + + val result = createAndRunJob() + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `Given outgoing message is missing, when run, then return failure`() { + every { messages.getOutgoingMessageOrNull(messageId) } returns null + + val result = createAndRunJob() + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `Given message record is missing, when run, then return failure`() { + every { messages.getMessageRecordOrNull(messageId) } returns null + + val result = createAndRunJob() + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `Given message is already sent, when run, then return success without sending`() { + every { messageRecord.type } returns MessageTypes.BASE_SENT_TYPE + + val result = createAndRunJob() + + assertThat(result.isSuccess).isTrue() + coVerify(exactly = 0) { messageService.sendMessage(serviceId = any(), envelopeContent = any(), timestamp = any(), sealedSenderAccess = any(), story = any(), isOnline = any(), urgent = any(), onEncrypted = any()) } + } + + @Test + fun `Given body exceeds inline size limit, when run, then return failure`() { + val tooLargeBody = "x".repeat(MessageUtil.MAX_INLINE_BODY_SIZE_BYTES + 1) + every { outgoingMessage.body } returns tooLargeBody + + val result = createAndRunJob() + + assertThat(result.isFailure).isTrue() + coVerify(exactly = 0) { messageService.sendMessage(serviceId = any(), envelopeContent = any(), timestamp = any(), sealedSenderAccess = any(), story = any(), isOnline = any(), urgent = any(), onEncrypted = any()) } + } + + @Test + fun `Given recipient is unregistered, when run, then return failure`() { + every { recipient.isUnregistered } returns true + + val result = createAndRunJob() + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `Given recipient has no service id, when run, then return failure`() { + every { recipient.hasServiceId } returns false + + val result = createAndRunJob() + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `Given toDataMessage fails, when run, then return failure`() { + every { outgoingMessage.toDataMessage() } returns DataMessageError.MissingAttachmentRemoteFields.left() + + val result = createAndRunJob() + + assertThat(result.isFailure).isTrue() + coVerify(exactly = 0) { messageService.sendMessage(serviceId = any(), envelopeContent = any(), timestamp = any(), sealedSenderAccess = any(), story = any(), isOnline = any(), urgent = any(), onEncrypted = any()) } + } + + // endregion + + // region — Successful send + + @Test + fun `Given a successful send, when run, then mark sent, write to log, update thread, return success`() { + stubSuccessfulSend(sentSealedSender = true) + + val result = createAndRunJob() + + assertThat(result.isSuccess).isTrue() + verify { messages.markAsSent(messageId, true) } + verify { threads.updateSilently(threadId, false) } + verify { messageLog.insertIfPossible(recipientId, sentTime, any(), ContentHint.RESENDABLE, MessageId(messageId), any()) } + verify { ConversationShortcutRankingUpdateJob.enqueueForOutgoingIfNecessary(recipient) } + } + + @Test + fun `Given a successful send and an expiring message, when run, then start expiration timer`() { + every { outgoingMessage.expiresIn } returns 60_000L + every { outgoingMessage.isExpirationUpdate } returns false + stubSuccessfulSend() + + createAndRunJob() + + verify { messages.markExpireStarted(messageId, any()) } + verify { expiringMessageManager.scheduleDeletion(messageId, true, 60_000L) } + } + + @Test + fun `Given a successful send and a view-once message, when run, then delete attachment files`() { + every { outgoingMessage.isViewOnce } returns true + stubSuccessfulSend() + + createAndRunJob() + + verify { attachments.deleteAttachmentFilesForViewOnceMessage(messageId) } + } + + @Test + fun `Given a UD send to unknown sealed-sender-mode recipient with no profile key, when run, then mark UNRESTRICTED`() { + every { recipient.sealedSenderAccessMode } returns SealedSenderAccessMode.UNKNOWN + every { recipient.profileKey } returns null + stubSuccessfulSend(sentSealedSender = true) + + createAndRunJob() + + verify { recipientsTable.setSealedSenderAccessMode(recipientId, SealedSenderAccessMode.UNRESTRICTED) } + } + + @Test + fun `Given a UD send to unknown sealed-sender-mode recipient with profile key, when run, then mark ENABLED`() { + every { recipient.sealedSenderAccessMode } returns SealedSenderAccessMode.UNKNOWN + every { recipient.profileKey } returns ByteArray(32) + stubSuccessfulSend(sentSealedSender = true) + + createAndRunJob() + + verify { recipientsTable.setSealedSenderAccessMode(recipientId, SealedSenderAccessMode.ENABLED) } + } + + @Test + fun `Given a non-UD send to an enabled recipient, when run, then mark DISABLED`() { + every { recipient.sealedSenderAccessMode } returns SealedSenderAccessMode.ENABLED + stubSuccessfulSend(sentSealedSender = false) + + createAndRunJob() + + verify { recipientsTable.setSealedSenderAccessMode(recipientId, SealedSenderAccessMode.DISABLED) } + } + + @Test + fun `Given multi-device, when send succeeds, then also send a sync transcript`() { + every { signalStore.account.isMultiDevice } returns true + stubSuccessfulSend() + + createAndRunJob() + + coVerify(exactly = 1) { + messageService.sendMessage( + serviceId = any(), + envelopeContent = any(), + timestamp = any(), + sealedSenderAccess = any(), + story = any(), + isOnline = any(), + urgent = any(), + onEncrypted = any() + ) + } + coVerify(exactly = 1) { + messageService.sendSyncMessage( + timestamp = any(), + envelopeContent = any(), + urgent = any(), + onEncrypted = any() + ) + } + } + + @Test + fun `Given a send needs PNI signature, when send succeeds, then write a pending PNI signature record`() { + every { recipient.needsPniSignature } returns true + every { AppDependencies.signalServiceMessageSender.createPniSignatureMessage() } returns PniSignatureMessage() + stubSuccessfulSend() + + createAndRunJob() + + verify { pendingPniSignatureMessages.insertIfNecessary(recipientId, sentTime, any()) } + } + + @Test + fun `Given a self-send, when run, then wrap content in a SyncMessage and use ContentHint IMPLICIT`() { + every { outgoingMessage.threadRecipient } returns self + every { messages.getMessageRecordOrNull(messageId) } returns messageRecord + stubSuccessfulSend(sentSealedSender = false) + + createAndRunJob() + + // Self-send signals success via the certificate fallback, not the network-reported flag. + verify { messages.markAsSent(messageId, true) } + } + + // endregion + + // region — SendError variants + + @Test + fun `Given IdentityMismatch, when run, then record mismatched identity and mark failed, return success`() { + val untrusted = mockk(relaxed = true) + val external = mockRecipient(recipientAci, isSelf = false) + every { Recipient.external(recipientAci.toString()) } returns external + + coEvery { + messageService.sendMessage(any(), any(), any(), any(), any(), any(), any(), any()) + } returns MessageService.SendError.IdentityMismatch( + serviceId = recipientAci, + exception = untrusted + ).left() + + val result = createAndRunJob() + + assertThat(result.isSuccess).isTrue() + verify { messages.addMismatchedIdentity(messageId, recipientId, any()) } + verify { messages.markAsSentFailed(messageId) } + } + + @Test + fun `Given NotRegistered, when run, then mark failed and enqueue DirectoryRefreshJob, return success`() { + coEvery { + messageService.sendMessage(any(), any(), any(), any(), any(), any(), any(), any()) + } returns MessageService.SendError.NotRegistered().left() + + val result = createAndRunJob() + + assertThat(result.isSuccess).isTrue() + verify { messages.markAsSentFailed(messageId) } + verify { AppDependencies.jobManager.add(match { it is DirectoryRefreshJob }) } + } + + @Test + fun `Given Unauthorized, when run, then return failure`() { + coEvery { + messageService.sendMessage(any(), any(), any(), any(), any(), any(), any(), any()) + } returns MessageService.SendError.Unauthorized().left() + + val result = createAndRunJob() + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `Given ChallengeRequired and handler says retry now, when run, then retry with zero backoff`() { + coEvery { + messageService.sendMessage(any(), any(), any(), any(), any(), any(), any(), any()) + } returns MessageService.SendError.ChallengeRequired( + token = "tok", + options = setOf(ChallengeOption.CAPTCHA), + retryAfter = 1.seconds + ).left() + + every { + ProofRequiredExceptionHandler.handle(any(), any(), any(), any(), any()) + } returns ProofRequiredExceptionHandler.Result.RETRY_NOW + + val result = createAndRunJob() + + assertThat(result.isRetry).isTrue() + } + + @Test + fun `Given ChallengeRequired and handler says retry later, when run, then retry with backoff`() { + coEvery { + messageService.sendMessage(any(), any(), any(), any(), any(), any(), any(), any()) + } returns MessageService.SendError.ChallengeRequired( + token = "tok", + options = emptySet(), + retryAfter = null + ).left() + + every { + ProofRequiredExceptionHandler.handle(any(), any(), any(), any(), any()) + } returns ProofRequiredExceptionHandler.Result.RETRY_LATER + + val result = createAndRunJob() + + assertThat(result.isRetry).isTrue() + } + + @Test + fun `Given ServerRejected, when run, then return failure`() { + coEvery { + messageService.sendMessage(any(), any(), any(), any(), any(), any(), any(), any()) + } returns MessageService.SendError.ServerRejected().left() + + val result = createAndRunJob() + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `Given ContentTooLarge, when run, then return failure`() { + coEvery { + messageService.sendMessage(any(), any(), any(), any(), any(), any(), any(), any()) + } returns MessageService.SendError.ContentTooLarge(size = 9_999L, maxAllowed = 256L).left() + + val result = createAndRunJob() + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `Given SessionAttemptsExhausted, when run, then return retry`() { + coEvery { + messageService.sendMessage(any(), any(), any(), any(), any(), any(), any(), any()) + } returns MessageService.SendError.SessionAttemptsExhausted().left() + + val result = createAndRunJob() + + assertThat(result.isRetry).isTrue() + } + + @Test + fun `Given PreKeyUnavailable, when run, then return retry`() { + coEvery { + messageService.sendMessage(any(), any(), any(), any(), any(), any(), any(), any()) + } returns MessageService.SendError.PreKeyUnavailable("no signed prekey").left() + + val result = createAndRunJob() + + assertThat(result.isRetry).isTrue() + } + + @Test + fun `Given RateLimited, when run, then return retry`() { + coEvery { + messageService.sendMessage(any(), any(), any(), any(), any(), any(), any(), any()) + } returns MessageService.SendError.RateLimited(retryAfter = 600.seconds).left() + + val result = createAndRunJob() + + assertThat(result.isRetry).isTrue() + } + + @Test + fun `Given NetworkError, when run, then return retry`() { + coEvery { + messageService.sendMessage(any(), any(), any(), any(), any(), any(), any(), any()) + } returns MessageService.SendError.NetworkError(IOException("boom")).left() + + val result = createAndRunJob() + + assertThat(result.isRetry).isTrue() + } + + @Test + fun `Given ApplicationError with a RuntimeException, when run, then return fatal failure`() { + coEvery { + messageService.sendMessage(any(), any(), any(), any(), any(), any(), any(), any()) + } returns MessageService.SendError.ApplicationError(IllegalStateException("nope")).left() + + val result = createAndRunJob() + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `Given ApplicationError with a non-RuntimeException, when run, then return retry`() { + coEvery { + messageService.sendMessage(any(), any(), any(), any(), any(), any(), any(), any()) + } returns MessageService.SendError.ApplicationError(Exception("eventually retryable")).left() + + val result = createAndRunJob() + + assertThat(result.isRetry).isTrue() + } + + // endregion + + // region — Helpers + + private fun createAndRunJob(): Job.Result { + val job = IndividualSendJobV2(messageId, recipient, hasMedia = false, isScheduledSend = false) + job.setContext(ApplicationProvider.getApplicationContext()) + return job.run() + } + + private fun stubSuccessfulSend(sentSealedSender: Boolean = false) { + val envelopeContent = EnvelopeContent.encrypted(Content(dataMessage = dataMessage), ContentHint.RESENDABLE, Optional.empty()) + coEvery { + messageService.sendMessage( + serviceId = any(), + envelopeContent = any(), + timestamp = any(), + sealedSenderAccess = any(), + story = any(), + isOnline = any(), + urgent = any(), + onEncrypted = any() + ) + } returns MessageService.SendSuccess(envelopeContent = envelopeContent, sentSealedSender = sentSealedSender, devices = listOf(1)).right() + + coEvery { + messageService.sendSyncMessage( + timestamp = any(), + envelopeContent = any(), + urgent = any(), + onEncrypted = any() + ) + } returns MessageService.SendSuccess(envelopeContent = envelopeContent, sentSealedSender = sentSealedSender, devices = listOf(1)).right() + } + + /** + * Builds a [Recipient] mock with the minimum stubs the job touches. [fresh] returns the mock itself + * so the job's `recipient.fresh()` calls don't hit the live cache. + */ + private fun mockRecipient(aci: ACI, isSelf: Boolean): Recipient { + val rid = if (isSelf) selfRecipientId else recipientId + val mock = mockk(relaxed = true) + every { mock.id } returns rid + every { mock.hasServiceId } returns true + every { mock.isUnregistered } returns false + every { mock.isRegistered } returns true + every { mock.isGroup } returns false + every { mock.isSelf } returns isSelf + every { mock.serviceId } returns Optional.of(aci) + every { mock.requireServiceId() } returns aci + every { mock.e164 } returns Optional.empty() + every { mock.needsPniSignature } returns false + every { mock.profileKey } returns null + every { mock.sealedSenderAccessMode } returns SealedSenderAccessMode.DISABLED + every { mock.fresh() } returns mock + return mock + } + + // endregion +}