Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## Unreleased

- Fix: the `QueueFake` check messages had bad grammar, like "a AllTypesJob" and "1 times". Now they say "an AllTypesJob" and "1 time".
- Fix: `Queue.restore()` did not undo the `Mail.useQueue()` call of `Queue.fake()`. With no `Queue.configure()`, `Mail.queue()` still found a queue after the test. Now `restore()` puts it back: no queue, or this queue when `Queue.configure()` was called.
- New: `Queue.fake()` for tests. It keeps every dispatched job in a `QueueFake` and does not run or save them. Check them with `assertPushed<T>([test])`, `assertPushedTimes<T>(n)`, `assertNotPushed<T>()` and `assertNothingPushed()`. Read them with `pushed` (each has the job, the queue name and the delay) or `jobsOf<T>()`. It also catches `Mail.queue()` and `Mail.later()`. `dispatchSync()` still runs the job. `Queue.restore()` stops the fake.
- Fix: with `Mail.queue(message, encrypt: true)`, a send error could name the recipient (eg. an SMTP reply `550 <ann@example.com>: mailbox unavailable`). The worker wrote that error to its log and to the failed jobs in plain text. Now the encrypted mail job throws a `RedactedMailError`. Its text has only the error type and the SMTP or HTTP code. The full error text, with the answer of the server, is encrypted with the app key (at most the first 64K characters, counted in UTF-16 code units). Read it with `RedactedMailError.decrypt(job.error)`. When the error can not be encrypted, only the type and the code are kept. Mail jobs without `encrypt` keep the full error, as before.
- New: `dox queue:table` writes a migration for the `jobs` and `failed_jobs` tables in `db/migration`. It calls `QueueTables.up` and `QueueTables.down`. It does not overwrite: when a `create_queue_tables` migration is already there, it prints so and exits with code 1. It is a package command (`dox: commands:` in pubspec.yaml), so it needs a `dox` CLI that finds package commands.
Expand Down
5 changes: 4 additions & 1 deletion lib/src/queue.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,12 @@ class Queue {
return fake;
}

/// Stop the fake: [dispatch] uses the config again.
/// Stop the fake: [dispatch] uses the config again. `Mail.queue()` uses
/// this queue again only when [configure] was called. Else it has no
/// queue, like before [fake].
static void restore() {
_fake = null;
Mail.useQueue(_config == null ? null : const QueueMailQueue());
}

/// Use [config] in this isolate. `QueueProvider` calls it. Call it in
Expand Down
29 changes: 23 additions & 6 deletions lib/src/queue_fake.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,15 @@ class QueueFake {
void assertPushed<T extends DoxJob>([bool Function(T job)? test]) {
List<T> jobs = jobsOf<T>();
if (jobs.isEmpty) {
throw AssertionError('Expected a $T job to be pushed. ${_summary()}');
throw AssertionError(
'Expected ${_a('$T')} job to be pushed. ${_summary()}',
);
}
if (test != null && !jobs.any(test)) {
throw AssertionError(
'Expected a $T job that passes the test to be pushed, but none of '
'the ${jobs.length} pushed $T jobs passed it.',
'Expected ${_a('$T')} job that passes the test to be pushed, but '
'none of the ${jobs.length} pushed $T ${_plural(jobs.length, 'job')} '
'passed it.',
);
}
}
Expand All @@ -51,8 +54,8 @@ class QueueFake {
int count = pushedOf<T>().length;
if (count != times) {
throw AssertionError(
'Expected $T to be pushed $times times, but it was pushed '
'$count times.',
'Expected $T to be pushed ${_times(times)}, but it was pushed '
'${_times(count)}.',
);
}
}
Expand All @@ -62,7 +65,8 @@ class QueueFake {
int count = pushedOf<T>().length;
if (count > 0) {
throw AssertionError(
'Expected no $T job to be pushed, but it was pushed $count times.',
'Expected no $T job to be pushed, but it was pushed '
'${_times(count)}.',
);
}
}
Expand All @@ -83,6 +87,19 @@ class QueueFake {
return 'Pushed: $names.';
}

/// `a SendWelcomeEmail`, `an AllTypesJob`
static String _a(String name) {
bool vowel = 'AEIOUaeiou'.contains(name.isEmpty ? 'x' : name[0]);
return vowel ? 'an $name' : 'a $name';
}

/// `1 time`, `2 times`
static String _times(int count) => '$count ${_plural(count, 'time')}';

static String _plural(int count, String word) {
return count == 1 ? word : '${word}s';
}

static String _name(PushedJob item) => '${item.job.runtimeType}';

static bool _isA<T>(PushedJob item) => item.job is T;
Expand Down
14 changes: 10 additions & 4 deletions test/queue_fake_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,11 @@ void main() {
expect(
_failure(() => queue.assertPushed<SendWelcomeEmail>(_isUser9)),
'Expected a SendWelcomeEmail job that passes the test to be pushed, '
'but none of the 1 pushed SendWelcomeEmail jobs passed it.',
'but none of the 1 pushed SendWelcomeEmail job passed it.',
);
expect(
_failure(queue.assertPushed<AllTypesJob>),
'Expected a AllTypesJob job to be pushed. Pushed: SendWelcomeEmail.',
'Expected an AllTypesJob job to be pushed. Pushed: SendWelcomeEmail.',
);
});

Expand All @@ -75,7 +75,7 @@ void main() {
queue.assertPushedTimes<AllTypesJob>(0);
expect(
_failure(() => queue.assertPushedTimes<SendWelcomeEmail>(1)),
'Expected SendWelcomeEmail to be pushed 1 times, but it was pushed '
'Expected SendWelcomeEmail to be pushed 1 time, but it was pushed '
'2 times.',
);
});
Expand All @@ -86,7 +86,7 @@ void main() {
expect(
_failure(queue.assertNotPushed<SendWelcomeEmail>),
'Expected no SendWelcomeEmail job to be pushed, but it was pushed '
'1 times.',
'1 time.',
);
});

Expand Down Expand Up @@ -124,6 +124,12 @@ void main() {
expect(queue.pushed.last.delay, Duration(minutes: 1));
});

test('restore takes back Mail.queue() when there is no config', () {
Queue.restore();
// like before Queue.fake(): Mail.queue() has no queue
expect(() => Mail.mailQueue, throwsStateError);
});

test('restore stops the fake', () async {
Queue.restore();
Queue.configure(QueueConfig());
Expand Down
7 changes: 7 additions & 0 deletions test/queue_mail_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,10 @@ void main() {
String log = await _logOf(_retryThenFail);
String error = (await Queue.driver.failed()).single.error;
for (String text in <String>[log, error]) {
// the encrypted details are random base64 text. About 1 in 300
// has "xxxx" in it by chance, and each run makes several. Check
// the text around them only.
text = text.replaceAll(_encrypted, 'Details are encrypted: ...');
expect(text, isNot(contains('918273')));
expect(text, isNot(contains('xxxx')));
expect(text, contains('MailException (code 552). Details are'));
Expand All @@ -235,6 +239,9 @@ void main() {
}
}

/// the encrypted details of a [RedactedMailError] text
final RegExp _encrypted = RegExp(r'Details are encrypted: [^\s"]+');

/// two tries: the first is released, the second fails
Future<void> _retryThenFail() async {
await _work('default', 2);
Expand Down
Loading