Skip to content

Commit 57a4214

Browse files
committed
Unit tests for attachments
1 parent d7daefc commit 57a4214

File tree

14 files changed

+488
-34
lines changed

14 files changed

+488
-34
lines changed

demos/supabase-todolist/lib/attachments/photo_widget.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import 'dart:io';
33
import 'package:path_provider/path_provider.dart';
44
import 'package:path/path.dart' as p;
55
import 'package:flutter/material.dart';
6-
import 'package:powersync_core/attachments.dart';
6+
import 'package:powersync_core/attachments/attachments.dart';
77
import 'package:powersync_flutter_demo/attachments/camera_helpers.dart';
88
import 'package:powersync_flutter_demo/attachments/photo_capture_widget.dart';
99

packages/powersync_core/lib/attachments/attachments.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export '../src/attachments/attachment.dart';
99
export '../src/attachments/attachment_queue_service.dart';
1010
export '../src/attachments/local_storage.dart';
1111
export '../src/attachments/remote_storage.dart';
12+
export '../src/attachments/sync_error_handler.dart';

packages/powersync_core/lib/attachments/io.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/// A platform-specific import supporting attachments on native platforms.
22
///
33
/// This library exports the [IOLocalStorage] class, implementing the
4-
/// [LocalStorageAdapter] interface by storing files under a root directory.
4+
/// [LocalStorage] interface by storing files under a root directory.
55
///
66
/// {@category attachments}
77
library;

packages/powersync_core/lib/src/attachments/attachment.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,11 @@ final class Attachment {
142142
metaData: metaData ?? this.metaData,
143143
);
144144
}
145+
146+
@override
147+
String toString() {
148+
return 'Attachment(id: $id, state: $state, localUri: $localUri, metadata: $metaData)';
149+
}
145150
}
146151

147152
/// Table definition for the attachments queue.

packages/powersync_core/lib/src/attachments/attachment_queue_service.dart

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ final class WatchedAttachmentItem {
4646
/// Creates a [WatchedAttachmentItem].
4747
///
4848
/// Either [fileExtension] or [filename] must be provided.
49-
WatchedAttachmentItem({
49+
const WatchedAttachmentItem({
5050
required this.id,
5151
this.fileExtension,
5252
this.filename,
@@ -67,7 +67,7 @@ final class WatchedAttachmentItem {
6767
base class AttachmentQueue {
6868
final PowerSyncDatabase _db;
6969
final Stream<List<WatchedAttachmentItem>> Function() _watchAttachments;
70-
final LocalStorageAdapter _localStorage;
70+
final LocalStorage _localStorage;
7171
final bool _downloadAttachments;
7272
final Logger _logger;
7373

@@ -80,7 +80,7 @@ base class AttachmentQueue {
8080
AttachmentQueue._(
8181
{required PowerSyncDatabase db,
8282
required Stream<List<WatchedAttachmentItem>> Function() watchAttachments,
83-
required LocalStorageAdapter localStorage,
83+
required LocalStorage localStorage,
8484
required bool downloadAttachments,
8585
required Logger logger,
8686
required AttachmentService attachmentsService,
@@ -110,9 +110,9 @@ base class AttachmentQueue {
110110
/// - [logger]: Logging interface used for all log operations.
111111
factory AttachmentQueue({
112112
required PowerSyncDatabase db,
113-
required RemoteStorageAdapter remoteStorage,
113+
required RemoteStorage remoteStorage,
114114
required Stream<List<WatchedAttachmentItem>> Function() watchAttachments,
115-
required LocalStorageAdapter localStorage,
115+
required LocalStorage localStorage,
116116
String attachmentsQueueTableName = AttachmentsQueueTable.defaultTableName,
117117
AttachmentErrorHandler? errorHandler,
118118
Duration syncInterval = const Duration(seconds: 30),
@@ -136,6 +136,7 @@ base class AttachmentQueue {
136136
errorHandler: errorHandler,
137137
syncThrottle: syncThrottleDuration,
138138
period: syncInterval,
139+
logger: resolvedLogger,
139140
);
140141

141142
return AttachmentQueue._(
@@ -156,7 +157,7 @@ base class AttachmentQueue {
156157
Future<void> startSync() async {
157158
await _mutex.lock(() async {
158159
if (_closed) {
159-
throw Exception('Attachment queue has been closed');
160+
throw StateError('Attachment queue has been closed');
160161
}
161162

162163
await _stopSyncingInternal();
@@ -186,7 +187,7 @@ base class AttachmentQueue {
186187
}
187188

188189
Future<void> _stopSyncingInternal() async {
189-
if (_closed) return;
190+
if (_closed || _syncStatusSubscription == null) return;
190191

191192
await _syncStatusSubscription?.cancel();
192193
_syncStatusSubscription = null;
@@ -200,10 +201,8 @@ base class AttachmentQueue {
200201
await _mutex.lock(() async {
201202
if (_closed) return;
202203

203-
await _syncStatusSubscription?.cancel();
204-
await _syncingService.close();
204+
await _stopSyncingInternal();
205205
_closed = true;
206-
207206
_logger.info('AttachmentQueue closed.');
208207
});
209208
}
@@ -319,7 +318,7 @@ base class AttachmentQueue {
319318
}) async {
320319
final resolvedId = id ?? await generateAttachmentId();
321320

322-
final String filename = await resolveNewAttachmentFilename(
321+
final filename = await resolveNewAttachmentFilename(
323322
resolvedId,
324323
fileExtension,
325324
);

packages/powersync_core/lib/src/attachments/implementations/attachment_context.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ final class AttachmentContext {
5555

5656
Future<void> saveAttachments(List<Attachment> attachments) async {
5757
if (attachments.isEmpty) {
58-
log.info('No attachments to save.');
58+
log.finer('No attachments to save.');
5959
return;
6060
}
6161
await db.writeTransaction((tx) async {
@@ -98,6 +98,7 @@ final class AttachmentContext {
9898
) async {
9999
// Only delete archived attachments exceeding the maxArchivedCount, ordered by timestamp DESC
100100
const limit = 1000;
101+
101102
final results = await db.getAll(
102103
'SELECT * FROM $table WHERE state = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?',
103104
[
@@ -134,6 +135,8 @@ final class AttachmentContext {
134135
Attachment attachment,
135136
SqliteWriteContext context,
136137
) async {
138+
log.finest('Updating attachment ${attachment.id}: ${attachment.state}');
139+
137140
await context.execute(
138141
'''INSERT OR REPLACE INTO
139142
$table (id, timestamp, filename, local_uri, media_type, size, state, has_synced, meta_data)

packages/powersync_core/lib/src/attachments/io_local_storage.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@ import 'package:path/path.dart' as p;
77

88
import 'local_storage.dart';
99

10-
/// Implements [LocalStorageAdapter] for device filesystem using Dart IO.
10+
/// Implements [LocalStorage] for device filesystem using Dart IO.
1111
///
1212
/// Handles file and directory operations for attachments.
1313
///
1414
/// {@category attachments}
1515
@experimental
16-
final class IOLocalStorage implements LocalStorageAdapter {
16+
final class IOLocalStorage implements LocalStorage {
1717
final Directory _root;
1818

1919
const IOLocalStorage(this._root);

packages/powersync_core/lib/src/attachments/local_storage.dart

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ library;
44
import 'dart:typed_data';
55

66
import 'package:meta/meta.dart';
7+
import 'package:path/path.dart' as p;
78

89
/// An interface responsible for storing attachment data locally.
910
///
@@ -15,7 +16,10 @@ import 'package:meta/meta.dart';
1516
///
1617
/// {@category attachments}
1718
@experimental
18-
abstract interface class LocalStorageAdapter {
19+
abstract interface class LocalStorage {
20+
/// Returns an in-memory [LocalStorage] implementation, suitable for testing.
21+
factory LocalStorage.inMemory() = _InMemoryStorage;
22+
1923
/// Saves binary data stream to storage at the specified file path
2024
///
2125
/// [filePath] - Path where the file will be stored
@@ -48,3 +52,51 @@ abstract interface class LocalStorageAdapter {
4852
/// Clears all data from the storage.
4953
Future<void> clear();
5054
}
55+
56+
final class _InMemoryStorage implements LocalStorage {
57+
final Map<String, Uint8List> content = {};
58+
59+
String _keyForPath(String path) {
60+
return p.normalize(path);
61+
}
62+
63+
@override
64+
Future<void> clear() async {
65+
content.clear();
66+
}
67+
68+
@override
69+
Future<void> deleteFile(String filePath) async {
70+
content.remove(_keyForPath(filePath));
71+
}
72+
73+
@override
74+
Future<bool> fileExists(String filePath) async {
75+
return content.containsKey(_keyForPath(filePath));
76+
}
77+
78+
@override
79+
Future<void> initialize() async {}
80+
81+
@override
82+
Stream<Uint8List> readFile(String filePath, {String? mediaType}) {
83+
return switch (content[_keyForPath(filePath)]) {
84+
null =>
85+
Stream.error('file at $filePath does not exist in in-memory storage'),
86+
final contents => Stream.value(contents),
87+
};
88+
}
89+
90+
@override
91+
Future<int> saveFile(String filePath, Stream<List<int>> data) async {
92+
var length = 0;
93+
final builder = BytesBuilder(copy: false);
94+
await for (final chunk in data) {
95+
length += chunk.length;
96+
builder.add(chunk);
97+
}
98+
99+
content[_keyForPath(filePath)] = builder.takeBytes();
100+
return length;
101+
}
102+
}

packages/powersync_core/lib/src/attachments/remote_storage.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import 'attachment.dart';
1010
///
1111
/// {@category attachments}
1212
@experimental
13-
abstract interface class RemoteStorageAdapter {
13+
abstract interface class RemoteStorage {
1414
/// Uploads a file to remote storage.
1515
///
1616
/// [fileData] is a stream of byte arrays representing the file data.

packages/powersync_core/lib/src/attachments/sync/syncing_service.dart

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ import '../sync_error_handler.dart';
2424
/// - [errorHandler]: Optional error handler for managing sync-related errors.
2525
@internal
2626
final class SyncingService {
27-
final RemoteStorageAdapter remoteStorage;
28-
final LocalStorageAdapter localStorage;
27+
final RemoteStorage remoteStorage;
28+
final LocalStorage localStorage;
2929
final AttachmentService attachmentsService;
3030
final AttachmentErrorHandler? errorHandler;
3131
final Duration syncThrottle;
@@ -44,8 +44,8 @@ final class SyncingService {
4444
this.errorHandler,
4545
this.syncThrottle = const Duration(seconds: 5),
4646
this.period = const Duration(seconds: 30),
47-
Logger? logger,
48-
}) : logger = logger ?? Logger('SyncingService');
47+
required this.logger,
48+
});
4949

5050
/// Starts the syncing process, including periodic and event-driven sync operations.
5151
Future<void> startSync() async {
@@ -60,11 +60,11 @@ final class SyncingService {
6060
);
6161
final manualTriggers = _syncTriggerController.stream;
6262

63-
// Merge both streams and apply throttling
64-
_syncSubscription =
65-
StreamGroup.merge<void>([attachmentChanges, manualTriggers]).listen((
66-
_,
67-
) async {
63+
late StreamSubscription<void> sub;
64+
final syncStream =
65+
StreamGroup.merge<void>([attachmentChanges, manualTriggers])
66+
.takeWhile((_) => sub == _syncSubscription)
67+
.asyncMap((_) async {
6868
await attachmentsService.withContext((context) async {
6969
final attachments = await context.getActiveAttachments();
7070
logger.info('Found ${attachments.length} active attachments');
@@ -73,6 +73,8 @@ final class SyncingService {
7373
});
7474
});
7575

76+
_syncSubscription = sub = syncStream.listen(null);
77+
7678
// Start periodic sync using instance period
7779
_periodicSubscription = Stream<void>.periodic(period, (_) {}).listen((
7880
_,
@@ -90,8 +92,16 @@ final class SyncingService {
9092

9193
/// Stops all ongoing sync operations.
9294
Future<void> stopSync() async {
93-
await _syncSubscription?.cancel();
9495
await _periodicSubscription?.cancel();
96+
97+
final subscription = _syncSubscription;
98+
// Add a trigger event after clearing the subscription, which will make
99+
// the takeWhile() callback cancel. This allows us to use asFuture() here,
100+
// ensuring that we only complete this future when the stream is actually
101+
// done.
102+
_syncSubscription = null;
103+
_syncTriggerController.add(null);
104+
await subscription?.asFuture<void>();
95105
}
96106

97107
/// Closes the syncing service, stopping all operations and releasing resources.
@@ -177,7 +187,8 @@ final class SyncingService {
177187
st,
178188
);
179189
if (errorHandler != null) {
180-
final shouldRetry = await errorHandler!.onUploadError(attachment, e);
190+
final shouldRetry =
191+
await errorHandler!.onUploadError(attachment, e, st);
181192
if (!shouldRetry) {
182193
logger.info('Attachment with ID ${attachment.id} has been archived');
183194
return attachment.copyWith(state: AttachmentState.archived);
@@ -206,7 +217,8 @@ final class SyncingService {
206217
);
207218
} catch (e, st) {
208219
if (errorHandler != null) {
209-
final shouldRetry = await errorHandler!.onDownloadError(attachment, e);
220+
final shouldRetry =
221+
await errorHandler!.onDownloadError(attachment, e, st);
210222
if (!shouldRetry) {
211223
logger.info('Attachment with ID ${attachment.id} has been archived');
212224
return attachment.copyWith(state: AttachmentState.archived);
@@ -240,7 +252,8 @@ final class SyncingService {
240252
return attachment.copyWith(state: AttachmentState.archived);
241253
} catch (e, st) {
242254
if (errorHandler != null) {
243-
final shouldRetry = await errorHandler!.onDeleteError(attachment, e);
255+
final shouldRetry =
256+
await errorHandler!.onDeleteError(attachment, e, st);
244257
if (!shouldRetry) {
245258
logger.info('Attachment with ID ${attachment.id} has been archived');
246259
return attachment.copyWith(state: AttachmentState.archived);

0 commit comments

Comments
 (0)