Files
buzz/mobile/test/features/channels/message_attachment_rendering_test.dart
klopez4212 1ac0a1271b fix(mobile): preserve Markdown attachment destinations (#8168)
## Summary
Fix mobile “Image unavailable” errors caused by angle-bracketed Markdown
URLs. Preserve attachment destinations across images, galleries, videos,
and file links, including spaces, parentheses, and optional titles.

### Related issue
Related: #8086 (attachment filenames).

### Testing
- `just mobile-install mobile-check mobile-test`
- Regression coverage decodes PNG/JPEG/GIF/WebP/BMP and verifies gallery
viewers, video routing, and authenticated file links.
- Native device playback not tested.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-08 14:05:46 -04:00

274 lines
8.9 KiB
Dart

import 'dart:convert';
import 'package:buzz/features/channels/message_content.dart';
import 'package:buzz/features/channels/media_viewer_page.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/media_loading_placeholder.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/misc.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../helpers/media_codec_fixtures.dart';
const _base = 'https://relay.example';
Widget _app(
String content, {
List<List<String>> tags = const [],
List<Override> overrides = const [],
}) => ProviderScope(
overrides: overrides,
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: MessageContent(
content: content,
tags: tags,
channelNames: const {'general': 'channel-id'},
),
),
),
);
// Image codecs complete outside the fake test clock. Wait for actual pixels,
// rather than waiting for an indefinitely animated loading shimmer to settle.
Future<void> _waitForImages(WidgetTester tester) async {
for (var attempt = 0; attempt < 200; attempt++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 10)),
);
await tester.pump(const Duration(milliseconds: 16));
final images = tester.widgetList<RawImage>(find.byType(RawImage));
if (images.any((image) => image.image != null) &&
find.byType(MediaLoadingPlaceholder).evaluate().isEmpty) {
return;
}
}
fail('Media did not produce a decoded image frame');
}
void main() {
setUp(() {
MediaImageProvider.debugResetCooldowns();
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
});
for (final fixture in mediaCodecFixtures.entries) {
for (final angled in [false, true]) {
testWidgets(
'${fixture.key} ${angled ? 'angle' : 'plain'} image downloads and opens',
(tester) async {
final url = '$_base/media/photo.${fixture.key}?download=1&v=2';
final requests = <http.Request>[];
final client = MockClient((request) async {
requests.add(request);
return http.Response.bytes(base64Decode(fixture.value), 200);
});
final auth = MediaGetAuthService(
baseUrl: _base,
nsec: nostr.Keys.generate().nsec,
);
await tester.pumpWidget(
_app(
'${angled ? 'oops [ text ' : 'Look\n'}'
'![photo \\[Q4\\]](${angled ? '<$url>' : url})',
tags: [
[
'imeta',
'url $url',
'm image/${fixture.key}',
'dim 2x2',
'alt Photo',
],
],
overrides: [
mediaHttpClientProvider.overrideWithValue(client),
mediaGetAuthServiceProvider.overrideWithValue(auth),
],
),
);
await _waitForImages(tester);
await tester.pumpAndSettle();
expect(requests, hasLength(1));
expect(requests.single.url.toString(), url);
expect(
requests.single.headers['Authorization'],
startsWith('Nostr '),
);
expect(find.text('Image unavailable'), findsNothing);
expect(tester.takeException(), isNull);
await tester.tap(
find.byKey(ValueKey('message-media-image-preview:$url')),
);
await _waitForImages(tester);
await tester.pumpAndSettle();
expect(
tester
.widget<MediaImageViewerPage>(find.byType(MediaImageViewerPage))
.imageUrl,
url,
);
expect(find.text('Failed to load image'), findsNothing);
},
);
}
}
for (final title in ['(Video title)', r'"A \"preview\""']) {
for (final item in [
('mp4', 'video/mp4'),
('mov', 'video/quicktime'),
('webm', 'video/webm'),
('bin', 'video/mp4'),
]) {
testWidgets('${item.$1} uses video metadata with title $title', (
tester,
) async {
final url = '$_base/media/clip (1).${item.$1}';
final expected = '$_base/media/clip%20%281%29.${item.$1}';
String? requested;
await tester.pumpWidget(
_app(
'oops [ text ![clip \\[Q4\\]](<$url> $title)',
tags: [
['imeta', 'url $url', 'm ${item.$2}'],
],
overrides: [
videoPreviewFrameLoaderProvider.overrideWithValue((url) async {
requested = url;
return null;
}),
],
),
);
await tester.pumpAndSettle();
expect(requested, expected);
expect(
find.byKey(ValueKey('message-media-video-preview:$expected')),
findsOneWidget,
);
expect(find.text('Image unavailable'), findsNothing);
});
}
for (final (stem, angled) in [
('report [Q4]', false),
('report [Q4]', true),
('report [Q4', true),
('report Q4]', true),
(r'report \ [Q4]', true),
]) {
for (final extension in ['pdf', 'txt', 'zip', 'mp3', 'svg']) {
testWidgets(
'$stem.$extension angled=$angled title $title opens with auth and filename',
(tester) async {
final url = '$_base/media/report.$extension';
final blob = BlobDescriptor(
url: url,
sha256: 'a' * 64,
size: 1,
type: 'application/octet-stream',
uploaded: 0,
filename: '$stem.$extension',
);
final markdown = blob.toMarkdownImage().replaceFirst(
'($url)',
angled ? '(<$url> $title)' : '($url)',
);
String? opened;
String? filename;
Map<String, String>? headers;
await tester.pumpWidget(
_app(
'${angled ? 'oops [ then ' : ''}$markdown',
overrides: [
mediaGetAuthServiceProvider.overrideWithValue(
MediaGetAuthService(
baseUrl: _base,
nsec: nostr.Keys.generate().nsec,
),
),
openDownloadedFileProvider.overrideWithValue((
url,
auth,
name,
) async {
opened = url;
headers = auth;
filename = name;
}),
],
),
);
await tester.tap(find.text('$stem.$extension'));
await tester.pump();
expect(opened, url);
expect(filename, '$stem.$extension');
expect(headers?['Authorization'], startsWith('Nostr '));
expect(find.byType(MediaImage), findsNothing);
},
);
}
}
}
testWidgets(
'mixed angle/plain gallery retains URLs, metadata and viewer items',
(tester) async {
const first = '$_base/media/photo(1).png';
const second = '$_base/media/photo2.png';
const normalizedFirst = '$_base/media/photo%281%29.png';
final client = MockClient(
(_) async =>
http.Response.bytes(base64Decode(mediaCodecFixtures['png']!), 200),
);
await tester.pumpWidget(
_app(
'oops [ text ![inline](<$second>)\n![first](<$first>)\n![second \\[Q4\\]]($second)',
tags: [
[
'imeta',
'url $first',
'm image/png',
'dim 2x2',
'alt First photo',
],
['imeta', 'url $second', 'm image/png', 'dim 2x2'],
],
overrides: [mediaHttpClientProvider.overrideWithValue(client)],
),
);
await _waitForImages(tester);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('message-media-image-preview:$second')),
findsOneWidget,
);
expect(find.text('2 images'), findsOneWidget);
expect(find.bySemanticsLabel('Open second [Q4]'), findsOneWidget);
expect(find.bySemanticsLabel('Open First photo'), findsOneWidget);
expect(find.text('Image unavailable'), findsNothing);
await tester.tap(
find.byKey(
const ValueKey('message-media-carousel-item:$normalizedFirst'),
),
);
await _waitForImages(tester);
await tester.pumpAndSettle();
final viewer = tester.widget<MediaImageViewerPage>(
find.byType(MediaImageViewerPage),
);
expect(viewer.galleryItems!.map((item) => item.url), [
normalizedFirst,
second,
]);
},
);
}