Files
element-x-ios/UnitTests/Sources/MediaUploadPreviewScreenViewModelTests.swift
T
Mauro Romito 58fb7c4fcb Wait on durable state in three flaky test suites
These three suites assert against state that is still being loaded or has
already moved on, so they fail whenever a busy runner stretches the window.
All three passed on CI only because `-retry-tests-on-failure` re-ran them.

- MediaUploadPreviewScreenViewModelTests: the processing failure tests treated
  a one second `deferFailure` window as if it also meant the send had finished.
  Preprocessing the valid files outlasts it, so wait for the interaction to be
  re-enabled instead.
- SecurityAndPrivacyScreenViewModelTests: the screen loads its parent spaces and
  its room directory visibility in two independent tasks, and `isSaveDisabled`
  needs both. Every wait now gates on the visibility having landed too.
- RoomNotificationSettingsScreenViewModelTests: the delete tests waited on a
  transient `deletingCustomSetting` flag that the observation coalesces away
  under load. They now wait on the dismiss action and the alert.
2026-07-29 11:53:17 +02:00

350 lines
17 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
@testable import ElementX
import Foundation
import Testing
@MainActor
final class MediaUploadPreviewScreenViewModelTests {
var timelineProxy: TimelineProxyMock!
var clientProxy: ClientProxyMock!
var userIndicatorController: UserIndicatorControllerMock
private let appSettings: AppSettings
var viewModel: MediaUploadPreviewScreenViewModel!
var context: MediaUploadPreviewScreenViewModel.Context {
viewModel.context
}
enum TestError: Swift.Error {
case unexpectedParameter
case unknown
}
init() {
appSettings = AppSettings.volatile()
appSettings.optimizeMediaUploads = false
userIndicatorController = UserIndicatorControllerMock()
}
@Test
func imageUploadWithoutCaption() async throws {
setUpViewModel(urls: [imageURL], expectedCaption: nil)
context.caption = .init("")
try await send()
}
@Test
func imageUploadWithBlankCaption() async throws {
setUpViewModel(urls: [imageURL], expectedCaption: nil)
context.caption = .init(" ")
try await send()
}
@Test
func imageUploadWithCaption() async throws {
let caption = "This is a really great image!"
setUpViewModel(urls: [imageURL], expectedCaption: caption)
context.caption = .init(string: caption)
try await send()
}
@Test(.timeLimit(.minutes(2))) // Transcoding videos can be slow on a busy CI runner.
func videoUploadWithoutCaption() async throws {
setUpViewModel(urls: [videoURL], expectedCaption: nil)
context.caption = .init("")
// The preprocessor transcodes the video for real which can take a while on a busy CI runner.
try await send(timeout: .seconds(60))
}
@Test(.timeLimit(.minutes(2))) // Transcoding videos can be slow on a busy CI runner.
func videoUploadWithCaption() async throws {
let caption = "Check out this video!"
setUpViewModel(urls: [videoURL], expectedCaption: caption)
context.caption = .init(string: caption)
// The preprocessor transcodes the video for real which can take a while on a busy CI runner.
try await send(timeout: .seconds(60))
}
@Test
func audioUploadWithoutCaption() async throws {
setUpViewModel(urls: [audioURL], expectedCaption: nil)
context.caption = .init("")
try await send()
}
@Test
func audioUploadWithCaption() async throws {
let caption = "Listen to this!"
setUpViewModel(urls: [audioURL], expectedCaption: caption)
context.caption = .init(string: caption)
try await send()
}
@Test
func fileUploadWithoutCaption() async throws {
setUpViewModel(urls: [fileURL], expectedCaption: nil)
context.caption = .init("")
try await send()
}
@Test
func fileUploadWithCaption() async throws {
let caption = "Please will you check my article."
setUpViewModel(urls: [fileURL], expectedCaption: caption)
context.caption = .init(string: caption)
try await send()
}
@Test
func processingFailure() async throws {
// Given an upload screen for a non-existent file.
setUpViewModel(urls: [badImageURL], expectedCaption: nil)
#expect(!context.viewState.shouldDisableInteraction)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 0)
// When attempting to send the file
let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1), message: "The screen should remain visible.") { $0 == .dismiss }
context.send(viewAction: .send)
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator
// Then the failure should occur preventing the screen from being dismissed.
// The interaction being re-enabled is what marks the end of the send, the timeout above only proves
// that no dismissal happened within it — processing may still be in flight when it elapses.
let deferredInteraction = deferFulfillment(context.observe(\.viewState.shouldDisableInteraction)) { !$0 }
try await deferredFailure.fulfill()
try await deferredInteraction.fulfill()
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 2, "An error indicator should be shown.")
}
@Test
func uploadWithUnknownMaxUploadSize() async throws {
// Given an upload screen that is unable to fetch the max upload size.
setUpViewModel(urls: [imageURL], expectedCaption: nil, maxUploadSizeResult: .failure(.sdkError(ClientProxyMockError.generic)))
#expect(!context.viewState.shouldDisableInteraction)
#expect(context.alertInfo == nil)
// When attempting to send the media.
let deferredAlert = deferFulfillment(context.observe(\.viewState.bindings.alertInfo)) { $0 != nil }
let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1), message: "The screen should remain visible.") { $0 == .dismiss }
context.send(viewAction: .send)
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
// Then alert should be shown to tell the user it failed.
try await deferredAlert.fulfill()
try await deferredFailure.fulfill()
#expect(!context.viewState.shouldDisableInteraction)
#expect(context.alertInfo?.id == .maxUploadSizeUnknown)
// When trying with the max upload size now available.
let deferredDismiss = deferFulfillment(viewModel.actions) { $0 == .dismiss }
clientProxy.underlyingMaxMediaUploadSize = .success(100 * 1024 * 1024)
context.alertInfo?.primaryButton.action?()
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while retrying.")
// Then the file should upload successfully.
try await deferredDismiss.fulfill()
}
@Test
func uploadExceedingMaxUploadSize() async throws {
// Given an upload screen with a really small max upload size.
setUpViewModel(urls: [imageURL], expectedCaption: nil, maxUploadSizeResult: .success(100))
#expect(!context.viewState.shouldDisableInteraction)
#expect(context.alertInfo == nil)
// When attempting to send an image that is larger the limit.
let deferredAlert = deferFulfillment(context.observe(\.viewState.bindings.alertInfo)) { $0 != nil }
let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1), message: "The screen should remain visible.") { $0 == .dismiss }
context.send(viewAction: .send)
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
// Then an alert should be shown to inform the user of the max upload size.
try await deferredAlert.fulfill()
try await deferredFailure.fulfill()
#expect(!context.viewState.shouldDisableInteraction)
#expect(context.alertInfo?.id == .maxUploadSizeExceeded(limit: 100))
}
@Test
func gallery() async throws {
// Given an upload screen with multiple media files.
setUpViewModel(urls: [fileURL, imageURL, fileURL], expectedCaption: nil)
#expect(!context.viewState.shouldDisableInteraction)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 0)
// When attempting to send the files.
let deferredDismiss = deferFulfillment(viewModel.actions) { $0 == .dismiss }
context.send(viewAction: .send)
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator
// Then the screen should be dismissed once the gallery has been sent in a single request.
try await deferredDismiss.fulfill()
#expect(timelineProxy.sendGalleryItemInfosCaptionInReplyToEventIDCallsCount == 1)
#expect(timelineProxy.sendGalleryItemInfosCaptionInReplyToEventIDReceivedArguments?.itemInfos.count == 3)
#expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 0)
#expect(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount == 0)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1, "Only a loading indicator should be shown.")
}
@Test
func galleryWithProcessingFailure() async throws {
// Given an upload screen for a non-existent file.
setUpViewModel(urls: [imageURL, fileURL, badImageURL], expectedCaption: nil)
#expect(!context.viewState.shouldDisableInteraction)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 0)
// When attempting to send the file
let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1), message: "The screen should remain visible.") { $0 == .dismiss }
context.send(viewAction: .send)
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator
// Then the failure should occur preventing the screen from being dismissed.
// Preprocessing the valid files can outlast the timeout above, so wait for the interaction to be
// re-enabled rather than assuming the send has finished by the time it elapses.
let deferredInteraction = deferFulfillment(context.observe(\.viewState.shouldDisableInteraction)) { !$0 }
try await deferredFailure.fulfill()
try await deferredInteraction.fulfill()
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 2, "An error indicator should be shown.")
}
@Test
func galleryWithSendFailure() async throws {
// Given an upload screen with multiple media files where the gallery send fails.
setUpViewModel(urls: [fileURL, imageURL, imageURL, fileURL], expectedCaption: nil, simulateGallerySendFailure: true)
#expect(!context.viewState.shouldDisableInteraction)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 0)
// When attempting to send the files.
let deferredDismiss = deferFulfillment(viewModel.actions) { $0 == .dismiss }
context.send(viewAction: .send)
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator
// Then the screen should still be dismissed and an error indicator surfaced.
try await deferredDismiss.fulfill()
#expect(timelineProxy.sendGalleryItemInfosCaptionInReplyToEventIDCallsCount == 1)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 2, "An error indicator should be shown.")
}
@Test
func galleryDisabledSendsIndividualMessages() async throws {
// Given an upload screen with multiple media files and gallery sending disabled.
setUpViewModel(urls: [fileURL, imageURL, fileURL], expectedCaption: nil, galleryEnabled: false)
#expect(!context.viewState.shouldDisableInteraction)
// When attempting to send the files.
let deferredDismiss = deferFulfillment(viewModel.actions) { $0 == .dismiss }
context.send(viewAction: .send)
// Then each file should be sent as an individual message rather than a single gallery.
try await deferredDismiss.fulfill()
#expect(timelineProxy.sendGalleryItemInfosCaptionInReplyToEventIDCallsCount == 0)
#expect(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount == 2)
#expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 1)
}
// MARK: - Helpers
private var audioURL: URL {
assertResourceURL(filename: "test_audio.mp3")
}
private var fileURL: URL {
assertResourceURL(filename: "test_pdf.pdf")
}
private var imageURL: URL {
assertResourceURL(filename: "test_animated_image.gif")
}
private var videoURL: URL {
assertResourceURL(filename: "landscape_test_video.mov")
}
private var badImageURL = URL(filePath: "/home/user/this_file_doesn't_exist.jpg")
private func assertResourceURL(filename: String) -> URL {
guard let url = Bundle(for: Self.self).url(forResource: filename, withExtension: nil) else {
Issue.record("Failed retrieving test asset")
return .picturesDirectory
}
return url
}
private func setUpViewModel(urls: [URL],
expectedCaption: String?,
maxUploadSizeResult: Result<UInt, ClientProxyError>? = nil,
simulateGallerySendFailure: Bool = false,
galleryEnabled: Bool = true) {
timelineProxy = TimelineProxyMock(.init())
timelineProxy.sendAudioUrlAudioInfoCaptionRequestHandleClosure = { [weak self] _, _, caption, _ in
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
}
timelineProxy.sendFileUrlFileInfoCaptionRequestHandleClosure = { [weak self] _, _, caption, _ in
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
}
timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleClosure = { [weak self] _, _, _, caption, _ in
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
}
timelineProxy.sendVideoUrlThumbnailURLVideoInfoCaptionRequestHandleClosure = { [weak self] _, _, _, caption, _ in
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
}
timelineProxy.sendGalleryItemInfosCaptionInReplyToEventIDClosure = { [weak self] _, caption, _ in
guard !simulateGallerySendFailure else { return .failure(.sdkError(TestError.unknown)) }
return self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
}
clientProxy = ClientProxyMock(.init())
if let maxUploadSizeResult {
clientProxy.underlyingMaxMediaUploadSize = maxUploadSizeResult
}
viewModel = MediaUploadPreviewScreenViewModel(mediaURLs: urls,
caption: nil,
title: "Some File",
shouldShowCaptionWarning: true,
galleryEnabled: galleryEnabled,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: appSettings),
timelineController: TimelineControllerMock(.init(timelineProxy: timelineProxy)),
clientProxy: clientProxy,
userIndicatorController: userIndicatorController)
}
private func verifyCaption(_ caption: String?, expectedCaption: String?) -> Result<Void, TimelineProxyError> {
guard caption == expectedCaption else {
Issue.record("The sent caption '\(caption ?? "nil")' does not match the expected value '\(expectedCaption ?? "nil")'")
return .failure(.sdkError(TestError.unexpectedParameter))
}
return .success(())
}
private func send(timeout: Duration = .seconds(10)) async throws {
#expect(!context.viewState.shouldDisableInteraction, "Attempting to send when interaction is disabled.")
let deferred = deferFulfillment(viewModel.actions, timeout: timeout) { $0 == .dismiss }
context.send(viewAction: .send)
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
try await deferred.fulfill()
}
}