|
| 1 | +/// The persisted log: what it keeps, what it drops, and what it must not do. |
| 2 | +/// |
| 3 | +/// Two properties matter more than the rest. It must **never throw** — a |
| 4 | +/// logger that can fail its caller turns a diagnostic into an outage, and it |
| 5 | +/// is called from error handlers where there is nowhere left to report to. And |
| 6 | +/// its retention must run on **write**, because retention that only runs when |
| 7 | +/// something reads the table is retention that never runs: nobody opens the |
| 8 | +/// log screen on the device that is filling up. |
| 9 | +library; |
| 10 | + |
| 11 | +import 'package:dpip/core/logging/log_store.dart'; |
| 12 | +import 'package:flutter_test/flutter_test.dart'; |
| 13 | +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; |
| 14 | + |
| 15 | +/// A fresh database per call — sqflite hands back the same handle for a |
| 16 | +/// repeated path, and `:memory:` is a path. |
| 17 | +Future<Database> _openMemory() => databaseFactoryFfi.openDatabase( |
| 18 | + inMemoryDatabasePath, |
| 19 | + options: OpenDatabaseOptions(singleInstance: false), |
| 20 | +); |
| 21 | + |
| 22 | +void main() { |
| 23 | + TestWidgetsFlutterBinding.ensureInitialized(); |
| 24 | + sqfliteFfiInit(); |
| 25 | + |
| 26 | + var clock = DateTime.utc(2026, 8, 15, 12); |
| 27 | + |
| 28 | + Future<(LogStore, Database)> makeStore({int flushAt = 64}) async { |
| 29 | + final db = await _openMemory(); |
| 30 | + await LogStore.createSchema(db); |
| 31 | + return (LogStore(db, now: () => clock, flushAt: flushAt), db); |
| 32 | + } |
| 33 | + |
| 34 | + StoredLog line(String message, {String level = 'info', DateTime? at}) => |
| 35 | + StoredLog(time: at ?? clock, level: level, message: message); |
| 36 | + |
| 37 | + setUp(() => clock = DateTime.utc(2026, 8, 15, 12)); |
| 38 | + |
| 39 | + test('a line survives a flush and reads back whole', () async { |
| 40 | + final (store, _) = await makeStore(); |
| 41 | + store.add( |
| 42 | + StoredLog( |
| 43 | + time: clock, |
| 44 | + level: 'error', |
| 45 | + message: 'boom', |
| 46 | + error: 'StateError: bad', |
| 47 | + stackTrace: '#0 somewhere', |
| 48 | + ), |
| 49 | + ); |
| 50 | + await store.flush(); |
| 51 | + |
| 52 | + final stored = await store.recent(); |
| 53 | + expect(stored, hasLength(1)); |
| 54 | + expect(stored.single.message, 'boom'); |
| 55 | + expect(stored.single.level, 'error'); |
| 56 | + expect(stored.single.error, 'StateError: bad'); |
| 57 | + expect(stored.single.stackTrace, '#0 somewhere'); |
| 58 | + }); |
| 59 | + |
| 60 | + test('adding does not touch the database until a flush', () async { |
| 61 | + final (store, _) = await makeStore(); |
| 62 | + store.add(line('buffered')); |
| 63 | + expect( |
| 64 | + await store.count(), |
| 65 | + 0, |
| 66 | + reason: 'a log call must not cost a database round-trip', |
| 67 | + ); |
| 68 | + await store.flush(); |
| 69 | + expect(await store.count(), 1); |
| 70 | + }); |
| 71 | + |
| 72 | + test('a burst flushes itself without waiting for the timer', () async { |
| 73 | + // A reconnect loop or a stack-trace storm should not sit in memory until |
| 74 | + // the timer fires — that is the run most likely to end in a kill. |
| 75 | + final (store, _) = await makeStore(flushAt: 4); |
| 76 | + for (var i = 0; i < 4; i++) { |
| 77 | + store.add(line('line $i')); |
| 78 | + } |
| 79 | + // The flush is scheduled synchronously by the fourth `add`. |
| 80 | + await Future<void>.delayed(Duration.zero); |
| 81 | + expect(await store.count(), 4); |
| 82 | + }); |
| 83 | + |
| 84 | + test('anything past 24 hours is dropped, on write', () async { |
| 85 | + final (store, _) = await makeStore(); |
| 86 | + store.add(line('old', at: clock.subtract(const Duration(hours: 25)))); |
| 87 | + store.add(line('edge', at: clock.subtract(const Duration(hours: 23)))); |
| 88 | + await store.flush(); |
| 89 | + // The prune runs inside the same transaction as the insert, so a line that |
| 90 | + // is already too old never even lands. |
| 91 | + expect((await store.recent()).map((e) => e.message), ['edge']); |
| 92 | + |
| 93 | + // Two hours on, `edge` has aged out too — and it is the *write* that |
| 94 | + // notices, not a reader. Nobody opens the log screen on the device that is |
| 95 | + // filling up. |
| 96 | + clock = clock.add(const Duration(hours: 2)); |
| 97 | + store.add(line('new')); |
| 98 | + await store.flush(); |
| 99 | + |
| 100 | + final messages = (await store.recent()).map((e) => e.message).toList(); |
| 101 | + expect(messages, ['new']); |
| 102 | + }); |
| 103 | + |
| 104 | + test('reads come back newest first', () async { |
| 105 | + final (store, _) = await makeStore(); |
| 106 | + for (var i = 0; i < 3; i++) { |
| 107 | + store.add(line('line $i', at: clock.add(Duration(minutes: i)))); |
| 108 | + } |
| 109 | + await store.flush(); |
| 110 | + expect((await store.recent()).map((e) => e.message).toList(), [ |
| 111 | + 'line 2', |
| 112 | + 'line 1', |
| 113 | + 'line 0', |
| 114 | + ]); |
| 115 | + }); |
| 116 | + |
| 117 | + test('a level filter narrows the read', () async { |
| 118 | + final (store, _) = await makeStore(); |
| 119 | + store |
| 120 | + ..add(line('fine')) |
| 121 | + ..add(line('bad', level: 'error')); |
| 122 | + await store.flush(); |
| 123 | + expect((await store.recent(level: 'error')).map((e) => e.message), ['bad']); |
| 124 | + }); |
| 125 | + |
| 126 | + test('clear empties it', () async { |
| 127 | + final (store, _) = await makeStore(); |
| 128 | + store.add(line('x')); |
| 129 | + await store.flush(); |
| 130 | + await store.clear(); |
| 131 | + expect(await store.count(), 0); |
| 132 | + }); |
| 133 | + |
| 134 | + test('a closed database is survived, not propagated', () async { |
| 135 | + // The property that matters most: this is called from error handlers, and |
| 136 | + // an exception here would replace a diagnostic with a crash. |
| 137 | + final (store, db) = await makeStore(); |
| 138 | + await db.close(); |
| 139 | + store.add(line('after close')); |
| 140 | + await expectLater(store.flush(), completes); |
| 141 | + expect(await store.count(), 0); |
| 142 | + expect(await store.recent(), isEmpty); |
| 143 | + await expectLater(store.clear(), completes); |
| 144 | + await expectLater(store.dispose(), completes); |
| 145 | + }); |
| 146 | + |
| 147 | + test('flushing an empty buffer is a no-op', () async { |
| 148 | + final (store, _) = await makeStore(); |
| 149 | + await expectLater(store.flush(), completes); |
| 150 | + expect(await store.count(), 0); |
| 151 | + }); |
| 152 | +} |
0 commit comments