mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Content scanner also checks for thumbnails (#5911)
* scan also thumbnails for medias * pr suggestion
This commit is contained in:
@@ -42,7 +42,7 @@ struct ContentScanningView<SafeContent: View, ScanningContent: View, UnsafeConte
|
|||||||
}
|
}
|
||||||
|
|
||||||
private let contentScannerService: ContentScannerServiceProtocol?
|
private let contentScannerService: ContentScannerServiceProtocol?
|
||||||
private let mediaSource: MediaSourceProxy?
|
private let mediaSources: [MediaSourceProxy]
|
||||||
private let containerShowsFailure: Bool
|
private let containerShowsFailure: Bool
|
||||||
private let safeContent: () -> SafeContent
|
private let safeContent: () -> SafeContent
|
||||||
private let scanningContent: () -> ScanningContent
|
private let scanningContent: () -> ScanningContent
|
||||||
@@ -55,6 +55,8 @@ struct ContentScanningView<SafeContent: View, ScanningContent: View, UnsafeConte
|
|||||||
/// scanning and the content is always rendered.
|
/// scanning and the content is always rendered.
|
||||||
/// - mediaSource: The media source to scan. Passing `nil` renders the content, this
|
/// - mediaSource: The media source to scan. Passing `nil` renders the content, this
|
||||||
/// makes it convenient to wrap items whose media source is optional.
|
/// makes it convenient to wrap items whose media source is optional.
|
||||||
|
/// - thumbnailSource: The thumbnail source to scan alongside the media source when the item
|
||||||
|
/// also downloads a thumbnail. The content is flagged if either source is unsafe or fails.
|
||||||
/// - containerShowsFailure: Whether a failure is reported up the view hierarchy through
|
/// - containerShowsFailure: Whether a failure is reported up the view hierarchy through
|
||||||
/// ``ContentScanningFailurePreferenceKey`` so that the surrounding container (such as the
|
/// ``ContentScanningFailurePreferenceKey`` so that the surrounding container (such as the
|
||||||
/// message bubble) adopts the critical styling. Pass `false` when the failure should only
|
/// message bubble) adopts the critical styling. Pass `false` when the failure should only
|
||||||
@@ -64,12 +66,13 @@ struct ContentScanningView<SafeContent: View, ScanningContent: View, UnsafeConte
|
|||||||
/// - unsafeContent: The presentation of the media when it can't be shown.
|
/// - unsafeContent: The presentation of the media when it can't be shown.
|
||||||
init(contentScannerService: ContentScannerServiceProtocol?,
|
init(contentScannerService: ContentScannerServiceProtocol?,
|
||||||
mediaSource: MediaSourceProxy?,
|
mediaSource: MediaSourceProxy?,
|
||||||
|
thumbnailSource: MediaSourceProxy? = nil,
|
||||||
containerShowsFailure: Bool = true,
|
containerShowsFailure: Bool = true,
|
||||||
@ViewBuilder safeContent: @escaping () -> SafeContent,
|
@ViewBuilder safeContent: @escaping () -> SafeContent,
|
||||||
@ViewBuilder scanningContent: @escaping () -> ScanningContent,
|
@ViewBuilder scanningContent: @escaping () -> ScanningContent,
|
||||||
@ViewBuilder unsafeContent: @escaping (ContentScanningFailure) -> UnsafeContent) {
|
@ViewBuilder unsafeContent: @escaping (ContentScanningFailure) -> UnsafeContent) {
|
||||||
self.contentScannerService = contentScannerService
|
self.contentScannerService = contentScannerService
|
||||||
self.mediaSource = mediaSource
|
mediaSources = [mediaSource, thumbnailSource].compactMap { $0 }
|
||||||
self.containerShowsFailure = containerShowsFailure
|
self.containerShowsFailure = containerShowsFailure
|
||||||
self.safeContent = safeContent
|
self.safeContent = safeContent
|
||||||
self.scanningContent = scanningContent
|
self.scanningContent = scanningContent
|
||||||
@@ -79,7 +82,7 @@ struct ContentScanningView<SafeContent: View, ScanningContent: View, UnsafeConte
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
content
|
content
|
||||||
.preference(key: ContentScanningFailurePreferenceKey.self, value: containerShowsFailure ? scanFailure : nil)
|
.preference(key: ContentScanningFailurePreferenceKey.self, value: containerShowsFailure ? scanFailure : nil)
|
||||||
.task(id: mediaSource?.url.absoluteString) {
|
.task(id: mediaSources.map(\.url.absoluteString).joined(separator: "|")) {
|
||||||
await scan()
|
await scan()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +107,7 @@ struct ContentScanningView<SafeContent: View, ScanningContent: View, UnsafeConte
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var resolvedScanState: ScanState {
|
private var resolvedScanState: ScanState {
|
||||||
guard let contentScannerService, let mediaSource else {
|
guard let contentScannerService, !mediaSources.isEmpty else {
|
||||||
return .safe
|
return .safe
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +116,7 @@ struct ContentScanningView<SafeContent: View, ScanningContent: View, UnsafeConte
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Until the scan triggered by this view resolves, reflect any verdict already in the cache.
|
// Until the scan triggered by this view resolves, reflect any verdict already in the cache.
|
||||||
switch contentScannerService.scanResultFromSource(mediaSource) {
|
switch contentScannerService.scanResultFromSources(mediaSources) {
|
||||||
case true: return .safe
|
case true: return .safe
|
||||||
case false: return .unsafe(.notSafe)
|
case false: return .unsafe(.notSafe)
|
||||||
default: return .scanning
|
default: return .scanning
|
||||||
@@ -121,11 +124,11 @@ struct ContentScanningView<SafeContent: View, ScanningContent: View, UnsafeConte
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func scan() async {
|
private func scan() async {
|
||||||
guard let contentScannerService, let mediaSource else {
|
guard let contentScannerService, !mediaSources.isEmpty else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch await contentScannerService.loadScanResultFromSource(mediaSource) {
|
switch await contentScannerService.loadScanResultFromSources(mediaSources) {
|
||||||
case .success(true):
|
case .success(true):
|
||||||
scanState = .safe
|
scanState = .safe
|
||||||
case .success(false):
|
case .success(false):
|
||||||
|
|||||||
@@ -137,17 +137,20 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Scans the media when a content scanner is configured, returning whether it's safe to be downloaded
|
/// Scans the media when a content scanner is configured, returning whether it's safe to be downloaded
|
||||||
/// and previewed, reflecting the scan's progress and outcome in the current item.
|
/// and previewed, reflecting the scan's progress and outcome in the current item. Both the media and
|
||||||
|
/// its thumbnail are scanned as either being downloaded through the scanner can flag the media.
|
||||||
private func checkSourceIsSafeIfNeeded(for mediaItem: TimelineMediaPreviewItem.Media, source: MediaSourceProxy) async -> Bool {
|
private func checkSourceIsSafeIfNeeded(for mediaItem: TimelineMediaPreviewItem.Media, source: MediaSourceProxy) async -> Bool {
|
||||||
guard let contentScannerService else { return true }
|
guard let contentScannerService else { return true }
|
||||||
|
|
||||||
|
let sources = [source, mediaItem.thumbnailMediaSource].compactMap { $0 }
|
||||||
|
|
||||||
// Only reflect the scanning state when there's no cached verdict, so that
|
// Only reflect the scanning state when there's no cached verdict, so that
|
||||||
// scanned items don't flash the scanning indicator when they're revisited.
|
// scanned items don't flash the scanning indicator when they're revisited.
|
||||||
if contentScannerService.scanResultFromSource(source) == nil {
|
if contentScannerService.scanResultFromSources(sources) == nil {
|
||||||
setCurrentItem(.contentScan(.init(media: mediaItem, state: .scanning)))
|
setCurrentItem(.contentScan(.init(media: mediaItem, state: .scanning)))
|
||||||
}
|
}
|
||||||
|
|
||||||
switch await contentScannerService.loadScanResultFromSource(source) {
|
switch await contentScannerService.loadScanResultFromSources(sources) {
|
||||||
case .success(true):
|
case .success(true):
|
||||||
finishScan(with: .media(mediaItem), for: mediaItem)
|
finishScan(with: .media(mediaItem), for: mediaItem)
|
||||||
return true
|
return true
|
||||||
|
|||||||
+2
-1
@@ -25,7 +25,8 @@ struct FileMediaEventsTimelineView: View {
|
|||||||
formattedCaption: timelineItem.content.formattedCaption,
|
formattedCaption: timelineItem.content.formattedCaption,
|
||||||
trailingReservedSize: timelineItem.trailingReservedSize,
|
trailingReservedSize: timelineItem.trailingReservedSize,
|
||||||
contentScannerService: context?.contentScannerService,
|
contentScannerService: context?.contentScannerService,
|
||||||
mediaSource: timelineItem.content.source)
|
mediaSource: timelineItem.content.source,
|
||||||
|
thumbnailSource: timelineItem.content.thumbnailSource)
|
||||||
.accessibilityLabel(L10n.commonFile)
|
.accessibilityLabel(L10n.commonFile)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.bubbleBackground(isOutgoing: timelineItem.isOutgoing,
|
.bubbleBackground(isOutgoing: timelineItem.isOutgoing,
|
||||||
|
|||||||
+2
-1
@@ -15,7 +15,8 @@ struct ImageMediaEventsTimelineView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ContentScanningView(contentScannerService: context?.contentScannerService,
|
ContentScanningView(contentScannerService: context?.contentScannerService,
|
||||||
mediaSource: timelineItem.content.imageInfo.source) {
|
mediaSource: timelineItem.content.imageInfo.source,
|
||||||
|
thumbnailSource: timelineItem.content.thumbnailInfo?.source) {
|
||||||
Color.clear // Let the image aspect fill in place
|
Color.clear // Let the image aspect fill in place
|
||||||
.aspectRatio(1, contentMode: .fill)
|
.aspectRatio(1, contentMode: .fill)
|
||||||
.overlay {
|
.overlay {
|
||||||
|
|||||||
+2
-1
@@ -15,7 +15,8 @@ struct VideoMediaEventsTimelineView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ContentScanningView(contentScannerService: context?.contentScannerService,
|
ContentScanningView(contentScannerService: context?.contentScannerService,
|
||||||
mediaSource: timelineItem.content.videoInfo.source) {
|
mediaSource: timelineItem.content.videoInfo.source,
|
||||||
|
thumbnailSource: timelineItem.content.thumbnailInfo?.source) {
|
||||||
Color.clear // Let the image aspect fill in place
|
Color.clear // Let the image aspect fill in place
|
||||||
.aspectRatio(1, contentMode: .fill)
|
.aspectRatio(1, contentMode: .fill)
|
||||||
.overlay {
|
.overlay {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ struct TimelineReplyView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ContentScanningView(contentScannerService: timelineContext?.contentScannerService,
|
ContentScanningView(contentScannerService: timelineContext?.contentScannerService,
|
||||||
mediaSource: scannedMediaSource,
|
mediaSource: scannedMediaSource,
|
||||||
|
thumbnailSource: scannedThumbnailSource,
|
||||||
containerShowsFailure: false) {
|
containerShowsFailure: false) {
|
||||||
content
|
content
|
||||||
.roundedContainer(padding: 4,
|
.roundedContainer(padding: 4,
|
||||||
@@ -63,6 +64,21 @@ struct TimelineReplyView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The thumbnail source validated alongside ``scannedMediaSource`` when the replied to message has one.
|
||||||
|
private var scannedThumbnailSource: MediaSourceProxy? {
|
||||||
|
guard case .loaded(_, _, let eventContent) = timelineItemReplyDetails,
|
||||||
|
case .message(let message) = eventContent else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch message {
|
||||||
|
case .file(let content): return content.thumbnailSource
|
||||||
|
case .image(let content): return content.thumbnailInfo?.source
|
||||||
|
case .video(let content): return content.thumbnailInfo?.source
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var content: some View {
|
private var content: some View {
|
||||||
if let timelineItemReplyDetails {
|
if let timelineItemReplyDetails {
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ struct FileRoomTimelineView: View {
|
|||||||
trailingReservedSize: timelineItem.trailingReservedSize,
|
trailingReservedSize: timelineItem.trailingReservedSize,
|
||||||
shouldBoost: timelineItem.shouldBoost,
|
shouldBoost: timelineItem.shouldBoost,
|
||||||
contentScannerService: context?.contentScannerService,
|
contentScannerService: context?.contentScannerService,
|
||||||
mediaSource: timelineItem.content.source) {
|
mediaSource: timelineItem.content.source,
|
||||||
|
thumbnailSource: timelineItem.content.thumbnailSource) {
|
||||||
context?.send(viewAction: .mediaTapped(itemID: timelineItem.id))
|
context?.send(viewAction: .mediaTapped(itemID: timelineItem.id))
|
||||||
}
|
}
|
||||||
.accessibilityLabel(L10n.commonFile)
|
.accessibilityLabel(L10n.commonFile)
|
||||||
@@ -42,6 +43,7 @@ struct MediaFileRoomTimelineContent: View {
|
|||||||
var isAudioFile = false
|
var isAudioFile = false
|
||||||
var contentScannerService: ContentScannerServiceProtocol?
|
var contentScannerService: ContentScannerServiceProtocol?
|
||||||
var mediaSource: MediaSourceProxy?
|
var mediaSource: MediaSourceProxy?
|
||||||
|
var thumbnailSource: MediaSourceProxy?
|
||||||
|
|
||||||
private var fileDescription: String {
|
private var fileDescription: String {
|
||||||
var fileDescription = "\(filename.validatedFileExtension.uppercased())"
|
var fileDescription = "\(filename.validatedFileExtension.uppercased())"
|
||||||
@@ -60,7 +62,8 @@ struct MediaFileRoomTimelineContent: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ContentScanningView(contentScannerService: contentScannerService,
|
ContentScanningView(contentScannerService: contentScannerService,
|
||||||
mediaSource: mediaSource) {
|
mediaSource: mediaSource,
|
||||||
|
thumbnailSource: thumbnailSource) {
|
||||||
if let onMediaTap {
|
if let onMediaTap {
|
||||||
filePreview(isScanning: false)
|
filePreview(isScanning: false)
|
||||||
.onTapGesture(perform: onMediaTap)
|
.onTapGesture(perform: onMediaTap)
|
||||||
|
|||||||
+2
-1
@@ -24,7 +24,8 @@ struct ImageRoomTimelineView: View {
|
|||||||
// The caption sits 8pts below the content scanner failure placeholder, 4pts below the media.
|
// The caption sits 8pts below the content scanner failure placeholder, 4pts below the media.
|
||||||
VStack(alignment: .leading, spacing: contentScanningFailure == nil ? 4 : 8) {
|
VStack(alignment: .leading, spacing: contentScanningFailure == nil ? 4 : 8) {
|
||||||
ContentScanningView(contentScannerService: context?.contentScannerService,
|
ContentScanningView(contentScannerService: context?.contentScannerService,
|
||||||
mediaSource: timelineItem.content.imageInfo.source) {
|
mediaSource: timelineItem.content.imageInfo.source,
|
||||||
|
thumbnailSource: timelineItem.content.thumbnailInfo?.source) {
|
||||||
loadableImage
|
loadableImage
|
||||||
.accessibilityElement(children: .ignore)
|
.accessibilityElement(children: .ignore)
|
||||||
.accessibilityLabel(L10n.commonImage)
|
.accessibilityLabel(L10n.commonImage)
|
||||||
|
|||||||
+2
-1
@@ -24,7 +24,8 @@ struct VideoRoomTimelineView: View {
|
|||||||
// The caption sits 8pts below the content scanner failure placeholder, 4pts below the media.
|
// The caption sits 8pts below the content scanner failure placeholder, 4pts below the media.
|
||||||
VStack(alignment: .leading, spacing: contentScanningFailure == nil ? 4 : 8) {
|
VStack(alignment: .leading, spacing: contentScanningFailure == nil ? 4 : 8) {
|
||||||
ContentScanningView(contentScannerService: context?.contentScannerService,
|
ContentScanningView(contentScannerService: context?.contentScannerService,
|
||||||
mediaSource: timelineItem.content.videoInfo.source) {
|
mediaSource: timelineItem.content.videoInfo.source,
|
||||||
|
thumbnailSource: timelineItem.content.thumbnailInfo?.source) {
|
||||||
thumbnail
|
thumbnail
|
||||||
.timelineMediaFrame(imageInfo: timelineItem.content.thumbnailInfo)
|
.timelineMediaFrame(imageInfo: timelineItem.content.thumbnailInfo)
|
||||||
.accessibilityElement(children: .ignore)
|
.accessibilityElement(children: .ignore)
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ nonisolated struct ContentScannerProxy: ContentScannerProxyProtocol {
|
|||||||
do {
|
do {
|
||||||
let response = try await contentScanner.scan(client: client, mediaSource: mediaSource.underlyingSource)
|
let response = try await contentScanner.scan(client: client, mediaSource: mediaSource.underlyingSource)
|
||||||
return .success(response.clean)
|
return .success(response.clean)
|
||||||
|
} catch ClientError.ContentScanner(.mcsMimeTypeForbidden, _), ClientError.ContentScanner(.mcsMediaNotClean, _) {
|
||||||
|
// The server flagged the media as unsafe (dangerous content or a forbidden mime type),
|
||||||
|
// which we treat as an unsafe verdict rather than a scan failure.
|
||||||
|
return .success(false)
|
||||||
} catch {
|
} catch {
|
||||||
MXLog.error("Failed scanning media content: \(error)")
|
MXLog.error("Failed scanning media content: \(error)")
|
||||||
return .failure(.sdkError(error))
|
return .failure(.sdkError(error))
|
||||||
|
|||||||
@@ -25,3 +25,47 @@ nonisolated protocol ContentScannerServiceProtocol: Sendable {
|
|||||||
@discardableResult
|
@discardableResult
|
||||||
func loadScanResultFromSource(_ source: MediaSourceProxy) async -> Result<Bool, ContentScannerServiceError>
|
func loadScanResultFromSource(_ source: MediaSourceProxy) async -> Result<Bool, ContentScannerServiceError>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated extension ContentScannerServiceProtocol {
|
||||||
|
/// The combined cached verdict for several sources (e.g. a media source and its thumbnail, both
|
||||||
|
/// downloaded through the scanner): `false` if any is unsafe, `true` if all are safe, or `nil`
|
||||||
|
/// when any of them hasn't been scanned yet.
|
||||||
|
func scanResultFromSources(_ sources: [MediaSourceProxy]) -> Bool? {
|
||||||
|
let results = sources.map { scanResultFromSource($0) }
|
||||||
|
if results.contains(false) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if results.contains(where: { $0 == nil }) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scans several sources concurrently, resolving as soon as one comes back non-safe (unsafe or
|
||||||
|
/// failed) since a single failure is enough to flag the media - the remaining scans are then
|
||||||
|
/// ignored. Resolves as safe only when every source is safe.
|
||||||
|
@discardableResult
|
||||||
|
func loadScanResultFromSources(_ sources: [MediaSourceProxy]) async -> Result<Bool, ContentScannerServiceError> {
|
||||||
|
await withTaskGroup(of: Result<Bool, ContentScannerServiceError>.self) { group in
|
||||||
|
for source in sources {
|
||||||
|
group.addTask { await loadScanResultFromSource(source) }
|
||||||
|
}
|
||||||
|
|
||||||
|
for await result in group where !result.isSafe {
|
||||||
|
group.cancelAll()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return .success(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated extension Result where Success == Bool {
|
||||||
|
var isSafe: Bool {
|
||||||
|
if case .success(true) = self {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,6 +101,66 @@ struct ContentScannerServiceTests {
|
|||||||
#expect(proxy.scanMediaSourceCallsCount == 1, "Concurrent loads for the same source should share a single scan.")
|
#expect(proxy.scanMediaSourceCallsCount == 1, "Concurrent loads for the same source should share a single scan.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Multiple sources
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func multipleSourcesAreSafeWhenAllSafe() async throws {
|
||||||
|
let proxy = ContentScannerProxyMock()
|
||||||
|
proxy.scanMediaSourceReturnValue = .success(true)
|
||||||
|
let service = ContentScannerService(contentScannerProxy: proxy)
|
||||||
|
|
||||||
|
let result = await service.loadScanResultFromSources([makeSource("media"), makeSource("thumbnail")])
|
||||||
|
|
||||||
|
#expect(try result.get() == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func multipleSourcesAreUnsafeWhenAnySourceIsUnsafe() async throws {
|
||||||
|
let proxy = ContentScannerProxyMock()
|
||||||
|
let unsafeSource = makeSource("thumbnail")
|
||||||
|
proxy.scanMediaSourceClosure = { source in
|
||||||
|
source == unsafeSource ? .success(false) : .success(true)
|
||||||
|
}
|
||||||
|
let service = ContentScannerService(contentScannerProxy: proxy)
|
||||||
|
|
||||||
|
// The media is safe but its thumbnail is unsafe, which is enough to flag the whole item.
|
||||||
|
let result = await service.loadScanResultFromSources([makeSource("media"), unsafeSource])
|
||||||
|
|
||||||
|
#expect(try result.get() == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func multipleSourcesFailWhenAnySourceFails() async {
|
||||||
|
let proxy = ContentScannerProxyMock()
|
||||||
|
let failingSource = makeSource("thumbnail")
|
||||||
|
proxy.scanMediaSourceClosure = { source in
|
||||||
|
source == failingSource ? .failure(.missingClient) : .success(true)
|
||||||
|
}
|
||||||
|
let service = ContentScannerService(contentScannerProxy: proxy)
|
||||||
|
|
||||||
|
let result = await service.loadScanResultFromSources([makeSource("media"), failingSource])
|
||||||
|
|
||||||
|
guard case .failure(.failedScanning) = result else {
|
||||||
|
Issue.record("A failure of any source should fail the combined scan.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func multipleSourcesSyncLookupCombinesVerdicts() async {
|
||||||
|
let proxy = ContentScannerProxyMock()
|
||||||
|
let service = ContentScannerService(contentScannerProxy: proxy)
|
||||||
|
|
||||||
|
proxy.scanMediaSourceReturnValue = .success(true)
|
||||||
|
await service.loadScanResultFromSource(makeSource("safe"))
|
||||||
|
proxy.scanMediaSourceReturnValue = .success(false)
|
||||||
|
await service.loadScanResultFromSource(makeSource("unsafe"))
|
||||||
|
|
||||||
|
#expect(service.scanResultFromSources([makeSource("safe")]) == true)
|
||||||
|
#expect(service.scanResultFromSources([makeSource("safe"), makeSource("unsafe")]) == false)
|
||||||
|
#expect(service.scanResultFromSources([makeSource("safe"), makeSource("unscanned")]) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func makeSource(_ id: String) -> MediaSourceProxy {
|
private func makeSource(_ id: String) -> MediaSourceProxy {
|
||||||
|
|||||||
Reference in New Issue
Block a user