diff --git a/README.md b/README.md index 74a3cb9..e934224 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,25 @@ A few resources to get you started if this is your first Flutter project: For help getting started with Flutter development, view the [online documentation](https://docs.flutter.dev/), which offers tutorials, samples, guidance on mobile development, and a full API reference. + +## Headless note CLI + +`bin/todo_note.dart` adds, lists, reads and deletes notes from the PC without +the app, as one more sync node (own `node_id` and CRDT log under +`$XDG_DATA_HOME/todo-cli`, or `~/.local/share/todo-cli`). It needs the desktop +Firebase session at `~/.config/todo/firebase_auth.json` (seed it with +`python3 ~/src/utils/crdt-sync/tool/seed_session.py --app todo`). + +```sh +dart run bin/todo_note.dart --text "Title + +body" --priority high --status in_progress # prints the new note id +echo "Title" | dart run bin/todo_note.dart # text from stdin +dart run bin/todo_note.dart --list # id, priority, status, title +dart run bin/todo_note.dart --get # full note +dart run bin/todo_note.dart --delete +``` + +`--list` and `--get` never push; `--data-dir ` selects a throwaway node. +Exit codes: 0 ok, 1 sync/auth failure or note not found, 2 bad arguments. +All logic is in `lib/cli/` (tested in `test/cli/`); `--help` lists every flag. diff --git a/bin/todo_note.dart b/bin/todo_note.dart new file mode 100644 index 0000000..7c2de7d --- /dev/null +++ b/bin/todo_note.dart @@ -0,0 +1,21 @@ +// Headless note CLI: add, list, get or delete a todo note from the PC. +// +// dart run bin/todo_note.dart --text "Title\n\nbody" --priority high +// echo "Title" | dart run bin/todo_note.dart +// dart run bin/todo_note.dart --get +// +// See `kUsage` in lib/cli/cli_args.dart for every flag. All behaviour lives +// in lib/cli/cli_runner.dart, where the tests reach it. +import 'dart:io'; + +import 'package:todo/cli/cli_runner.dart'; + +Future main(List argv) async { + exitCode = await runCli( + argv, + env: Platform.environment, + out: stdout, + err: stderr, + readStdin: () => readInput(stdin, hasTerminal: stdin.hasTerminal), + ); +} diff --git a/lib/cli/cli_args.dart b/lib/cli/cli_args.dart new file mode 100644 index 0000000..7a3876a --- /dev/null +++ b/lib/cli/cli_args.dart @@ -0,0 +1,166 @@ +/// Hand-rolled argument parsing for `bin/todo_note.dart`. +/// +/// Deliberately not `package:args`: a handful of flags do not justify a new +/// exact-pinned dependency under the freshness gate. +library; + +import 'package:todo/data/note.dart'; + +/// What the CLI was asked to do. +enum CliMode { + /// Create a note and push it. + add, + + /// Print every live note, one per line. + list, + + /// Print one note in full. + get, + + /// Tombstone one note and push the deletion. + delete, + + /// Print usage. + help, +} + +/// Thrown for a malformed command line; maps to exit code 2. +class UsageError implements Exception { + /// Creates a [UsageError] carrying [message]. + UsageError(this.message); + + /// What was wrong with the arguments. + final String message; + + @override + String toString() => message; +} + +/// The parsed command line. +class CliArgs { + /// Creates parsed arguments; see [parse]. + const CliArgs({ + required this.mode, + this.text, + this.id, + this.priority = Priority.medium, + this.status = Status.todo, + this.dataDir, + }); + + /// Parses [argv]. Throws [UsageError] on anything it does not understand. + factory CliArgs.parse(List argv) { + String? text; + String? id; + String? dataDir; + var priority = Priority.medium; + var status = Status.todo; + final modes = {}; + for (var i = 0; i < argv.length; i++) { + final arg = argv[i]; + String value() { + if (i + 1 >= argv.length) throw UsageError('$arg needs a value'); + return argv[++i]; + } + + switch (arg) { + case '-h' || '--help': + modes.add(CliMode.help); + case '--text': + text = value(); + case '--priority': + priority = _byName(Priority.values, value(), arg); + case '--status': + status = _byName(Status.values, value(), arg, _statusName); + case '--data-dir': + dataDir = value(); + case '--list': + modes.add(CliMode.list); + case '--get': + modes.add(CliMode.get); + id = value(); + case '--delete': + modes.add(CliMode.delete); + id = value(); + default: + throw UsageError('unknown argument: $arg'); + } + } + if (modes.contains(CliMode.help)) return const CliArgs(mode: CliMode.help); + if (modes.length > 1) { + throw UsageError('--list, --get and --delete are mutually exclusive'); + } + final mode = modes.isEmpty ? CliMode.add : modes.single; + if (mode != CliMode.add && text != null) { + throw UsageError('--text only applies when adding a note'); + } + return CliArgs( + mode: mode, + text: text, + id: id, + priority: priority, + status: status, + dataDir: dataDir, + ); + } + + /// The selected mode. + final CliMode mode; + + /// Note text from `--text`; null means "read stdin" in [CliMode.add]. + final String? text; + + /// Note id for [CliMode.get] / [CliMode.delete]. + final String? id; + + /// Priority of a new note. + final Priority priority; + + /// Status of a new note. + final Status status; + + /// Overrides the default data dir (a second, independent node). + final String? dataDir; + + /// The command-line spelling of [status] (`in_progress`, not `inProgress`). + static String statusName(Status status) => _statusName(status); + + static String _statusName(Enum value) => + value == Status.inProgress ? 'in_progress' : value.name; + + static T _byName( + List values, + String raw, + String flag, [ + String Function(Enum) nameOf = _plainName, + ]) { + for (final v in values) { + if (nameOf(v) == raw) return v; + } + throw UsageError( + '$flag must be one of ${values.map(nameOf).join('|')}, got "$raw"', + ); + } + + static String _plainName(Enum value) => value.name; +} + +/// Usage text printed by `--help` and after a [UsageError]. +const kUsage = r''' +Usage: dart run bin/todo_note.dart [options] + +Adds a note to the todo app headlessly: pulls from Firebase RTDB, writes an +ordinary CRDT change as this install's own node, pushes, and prints the id. + + --text note text (first line = title); default: read stdin + --priority

low|medium|high (default medium) + --status todo|in_progress|done|abandoned (default todo) + --list pull, then print live notes: id, priority, status, title + --get pull, then print one note in full (exit 1 if absent) + --delete pull, tombstone the note, push + --data-dir

local log + node id (default + $XDG_DATA_HOME/todo-cli or ~/.local/share/todo-cli) + -h, --help this text + +--list and --get never push, so a throwaway --data-dir leaves nothing behind. +Exit codes: 0 ok, 1 sync/auth failure or note not found, 2 bad arguments.'''; diff --git a/lib/cli/cli_runner.dart b/lib/cli/cli_runner.dart new file mode 100644 index 0000000..9fe91b2 --- /dev/null +++ b/lib/cli/cli_runner.dart @@ -0,0 +1,186 @@ +/// The body of `bin/todo_note.dart`, with every side effect injected. +/// +/// Lives in `lib/` so the tests can drive it with a fake RTDB, a temp data +/// dir and in-memory output sinks; the `bin/` entrypoint only wires the real +/// process streams and environment into [runCli]. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:path/path.dart' as p; +import 'package:todo/cli/cli_args.dart'; +import 'package:todo/cli/cli_session.dart'; +import 'package:todo/data/note.dart'; +import 'package:todo/sync/sync_service.dart'; +import 'package:uuid/uuid.dart'; + +/// Opens a [CliSession]; swapped in tests to point it at a fake backend. +typedef SessionOpener = + Future Function( + String dataDir, { + required String seedCredentials, + }); + +/// Reads all of [input] as text, or '' when it is an interactive terminal. +Future readInput( + Stream> input, { + required bool hasTerminal, +}) async { + if (hasTerminal) return ''; + return (await input.transform(utf8.decoder).join()).trimRight(); +} + +/// Runs the CLI for [argv] and returns its exit code. +Future runCli( + List argv, { + required Map env, + required StringSink out, + required StringSink err, + required Future Function() readStdin, + SessionOpener open = CliSession.open, +}) async { + final CliArgs args; + try { + args = CliArgs.parse(argv); + } on UsageError catch (error) { + err + ..writeln('error: $error') + ..writeln(kUsage); + return 2; + } + if (args.mode == CliMode.help) { + out.writeln(kUsage); + return 0; + } + String? text; + if (args.mode == CliMode.add) { + text = args.text ?? await readStdin(); + if (text.trim().isEmpty) { + err.writeln('error: empty note (pass --text or pipe text on stdin)'); + return 2; + } + } + + CliSession? session; + try { + session = await open( + args.dataDir ?? defaultDataDir(env), + seedCredentials: p.join( + env['HOME'] ?? '', + '.config', + 'todo', + 'firebase_auth.json', + ), + ); + final cli = _Cli(session, out, err); + return switch (args.mode) { + CliMode.add => await cli.add(args, text!), + CliMode.list => await cli.list(), + CliMode.get => await cli.get(args.id!), + // Only delete is left: help returned above. + _ => await cli.delete(args.id!), + }; + } on RemoteSyncError catch (error) { + err.writeln('error: $error'); + return 1; + } on IOException catch (error) { + err.writeln('error: $error'); + return 1; + } finally { + await session?.close(); + } +} + +class _Cli { + _Cli(this.session, this.out, this.err); + + final CliSession session; + final StringSink out; + final StringSink err; + + Future add(CliArgs args, String text) async { + final now = DateTime.now(); + final note = Note( + id: const Uuid().v4(), + text: text, + priority: args.priority, + status: args.status, + createdAt: now, + updatedAt: now, + ); + await session.repository.upsert(note); + final result = _report(await session.sync(push: true)); + if (!result.pushed) { + err.writeln('error: note saved locally but nothing was pushed'); + return 1; + } + out.writeln(note.id); + return 0; + } + + Future list() async { + _report(await session.sync(push: false)); + for (final note in await session.repository.listNotes()) { + out.writeln( + [ + note.id, + note.priority.name, + CliArgs.statusName(note.status), + note.text.split('\n').first, + ].join('\t'), + ); + } + return 0; + } + + Future get(String id) async { + _report(await session.sync(push: false)); + final note = await _find(id); + if (note == null) { + err.writeln('error: no live note with id $id'); + return 1; + } + out + ..writeln('id: ${note.id}') + ..writeln('priority: ${note.priority.name}') + ..writeln('status: ${CliArgs.statusName(note.status)}') + ..writeln('created: ${note.createdAt.toIso8601String()}') + ..writeln('updated: ${note.updatedAt.toIso8601String()}') + ..writeln('---') + ..writeln(note.text); + return 0; + } + + Future delete(String id) async { + // Pull first: the note may have been created on another device, and a + // tombstone for an id this node has never seen would only be noise. + _report(await session.sync(push: true)); + if (await _find(id) == null) { + err.writeln('error: no live note with id $id'); + return 1; + } + await session.repository.delete(id); + if (!_report(await session.sync(push: true)).pushed) { + err.writeln('error: deletion saved locally but nothing was pushed'); + return 1; + } + out.writeln('deleted $id'); + return 0; + } + + Future _find(String id) async { + final notes = await session.repository.listNotes(); + return notes.where((n) => n.id == id).firstOrNull; + } + + /// Surfaces peers that could not be merged instead of dropping them + /// silently. + SyncResult _report(SyncResult result) { + for (final name in result.skippedFiles) { + err.writeln('warning: could not merge peer file $name'); + } + return result; + } +} diff --git a/lib/cli/cli_session.dart b/lib/cli/cli_session.dart new file mode 100644 index 0000000..e2f8ed7 --- /dev/null +++ b/lib/cli/cli_session.dart @@ -0,0 +1,133 @@ +/// One headless CLI run: a local note log, its node id, and a Firebase client. +/// +/// Built only from Flutter-free pieces of the app (the [NoteRepository], the +/// [SyncService] every device runs, and [kProject]) so `dart run` works with +/// no Flutter engine — the same constraint `tool/sync_smoke.dart` lives under. +/// The writes are therefore ordinary CRDT changes from one more device, never +/// records hand-written into RTDB. +library; + +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:crdt_sync/crdt_sync_io.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; +import 'package:todo/cli/file_credential_store.dart'; +import 'package:todo/cli/pull_only_store.dart'; +import 'package:todo/data/note_repository.dart'; +import 'package:todo/sync/firebase_project.dart'; +import 'package:todo/sync/sync_service.dart'; +import 'package:uuid/uuid.dart'; + +/// Default data dir: `$XDG_DATA_HOME/todo-cli`, else `~/.local/share/todo-cli`. +String defaultDataDir(Map env) { + final xdg = env['XDG_DATA_HOME'] ?? ''; + final base = xdg.isNotEmpty + ? xdg + : p.join(env['HOME'] ?? '', '.local', 'share'); + return p.join(base, 'todo-cli'); +} + +/// The open state of one CLI invocation. Call [close] when done. +class CliSession { + /// Wraps already-open parts; [open] is the normal way in. Public so a test + /// can subclass the session and script [sync]'s outcome. + CliSession(this.dataDir, this.repository, this._firebase); + + /// File holding this install's persisted CRDT node id. + static const nodeIdFileName = 'node_id'; + + /// File holding the per-peer revision cache (see [SyncService.stateStore]). + static const syncStateFileName = 'sync_state.json'; + + /// The CLI's private copy of the Firebase session. + static const credentialsFileName = 'firebase_auth.json'; + + /// Where the local log, node id and session copy live. + final String dataDir; + + /// The note store, exactly the class the app uses. + final NoteRepository repository; + + final FirebaseRestClient _firebase; + + /// Opens (creating on first use) the session rooted at [dataDir]. + /// + /// [seedCredentials] is todo's desktop session, read once and never written. + /// [httpClient] replaces the real network client (tests only). + static Future open( + String dataDir, { + required String seedCredentials, + http.Client? httpClient, + }) async { + final dir = Directory(dataDir); + await dir.create(recursive: true); + // Holds a database-write credential: owner-only, like ~/.config/todo. + await Process.run('chmod', ['700', dir.path]); + final repository = await NoteRepository.openWith( + persistence: FileLogPersistence( + File(p.join(dataDir, NoteRepository.logFileName)), + ), + nodeId: await _nodeId(dataDir), + ); + final auth = FirebaseTokenProvider( + apiKey: kProject.apiKey, + httpClient: httpClient, + store: FileCredentialStore( + file: File(p.join(dataDir, credentialsFileName)), + seedFile: File(seedCredentials), + ), + ); + if (!await auth.hasSession()) { + await repository.close(); + throw FirebaseAuthError( + 'no Firebase session: $seedCredentials is missing; re-seed it with ' + '`python3 ~/src/utils/crdt-sync/tool/seed_session.py --app todo`', + ); + } + final firebase = FirebaseRestClient( + databaseUrl: kProject.databaseUrl, + auth: auth, + httpClient: httpClient, + ); + return CliSession(dataDir, repository, firebase); + } + + /// Pull every peer's log and merge it; with [push], also publish this + /// node's merged log and its revision, exactly as a device's sync tick does. + /// + /// Without [push] nothing is written anywhere remote, and the revision cache + /// is bypassed so a read always sees every peer's current log. + Future sync({required bool push}) { + if (!push) { + return const SyncService().sync(repository, PullOnlyStore(_firebase)); + } + return SyncService( + stateStore: PersistedSyncStateStore( + FileLogPersistence(File(p.join(dataDir, syncStateFileName))), + ), + ).sync(repository, _firebase); + } + + /// Releases the log and the HTTP client. + Future close() async { + _firebase.close(); + await repository.close(); + } + + /// Reads this install's node id, minting and persisting a uuid on first run. + /// + /// Never a fixed name: two installs sharing an id would overwrite each + /// other's file under `todo-sync/notes/` and lose writes. + static Future _nodeId(String dataDir) async { + final file = File(p.join(dataDir, nodeIdFileName)); + if (file.existsSync()) { + final stored = (await file.readAsString()).trim(); + if (stored.isNotEmpty) return stored; + } + final minted = const Uuid().v4(); + await file.writeAsString('$minted\n'); + return minted; + } +} diff --git a/lib/cli/file_credential_store.dart b/lib/cli/file_credential_store.dart new file mode 100644 index 0000000..7deded7 --- /dev/null +++ b/lib/cli/file_credential_store.dart @@ -0,0 +1,52 @@ +/// The headless CLI's Firebase session, kept in a file of its own. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; + +/// A file-backed [FirebaseCredentialStore], seeded once from [seedFile]. +/// +/// [seedFile] is todo's desktop session (`~/.config/todo/firebase_auth.json`, +/// written by `crdt-sync/tool/seed_session.py --app todo`) and is only ever +/// read. Every write goes to [file], a private copy in the CLI's data dir. +/// That split matters: [FirebaseTokenProvider] clears its store when a refresh +/// token is revoked, and a shared file would let one bad CLI run delete the +/// seed the desktop wrapper provisions itself from. +class FileCredentialStore implements FirebaseCredentialStore { + /// Creates a store that persists to [file], falling back to [seedFile]. + FileCredentialStore({required this.file, required this.seedFile}); + + /// The CLI's own copy, written on every token refresh. + final File file; + + /// The read-only seed used until [file] exists. + final File seedFile; + + @override + Future load() async { + final source = file.existsSync() ? file : seedFile; + if (!source.existsSync()) return null; + final decoded = jsonDecode(await source.readAsString()); + if (decoded is! Map) return null; + return FirebaseCredentials.fromJson(decoded); + } + + @override + Future save(FirebaseCredentials credentials) async { + await file.parent.create(recursive: true); + final tmp = File('${file.path}.$pid.tmp'); + // Owner-only before the token is written, not after: dart:io has no + // chmod, and a umask-default file would briefly expose a DB credential. + await tmp.writeAsString(''); + await Process.run('chmod', ['600', tmp.path]); + await tmp.writeAsString(jsonEncode(credentials.toJson())); + await tmp.rename(file.path); + } + + @override + Future clear() async { + if (file.existsSync()) await file.delete(); + } +} diff --git a/lib/cli/pull_only_store.dart b/lib/cli/pull_only_store.dart new file mode 100644 index 0000000..ba1302e --- /dev/null +++ b/lib/cli/pull_only_store.dart @@ -0,0 +1,44 @@ +/// A read-only view of a [RemoteStore], for the CLI's verification modes. +library; + +import 'package:crdt_sync/crdt_sync.dart'; + +/// Delegates every read to [inner] and silently drops every write. +/// +/// Lets `--list` / `--get` run the app's own [SyncService] merge path without +/// publishing anything. Without it, each throwaway data dir (a fresh node id) +/// would leave a permanent full-log peer file in RTDB that every device then +/// downloads on every sync. +class PullOnlyStore implements RemoteStore, BulkMapReader { + /// Wraps [inner], which must also be able to read revision maps. + PullOnlyStore(this.inner); + + /// The real backend reads go to. + final FirebaseRestClient inner; + + @override + Future> listDirectory(String path) => inner.listDirectory(path); + + @override + Future getFileText(String path) => inner.getFileText(path); + + @override + Future> getStringMap(String path) => + inner.getStringMap(path); + + @override + Future putFileText( + String path, + String text, { + required String message, + }) async {} + + @override + Future deleteFile(String path, {String message = ''}) async {} + + @override + Future canAccessRemote() => inner.canAccessRemote(); + + @override + void close() => inner.close(); +} diff --git a/pubspec.yaml b/pubspec.yaml index aef2ebf..0ae0454 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.42+1 +version: 1.0.43+1 environment: sdk: ^3.12.2 diff --git a/test/cli/cli_args_test.dart b/test/cli/cli_args_test.dart new file mode 100644 index 0000000..f42b58c --- /dev/null +++ b/test/cli/cli_args_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/cli/cli_args.dart'; +import 'package:todo/data/note.dart'; + +void main() { + group('CliArgs.parse', () { + test('no arguments means add with defaults', () { + final args = CliArgs.parse([]); + expect(args.mode, CliMode.add); + expect(args.text, isNull); + expect(args.priority, Priority.medium); + expect(args.status, Status.todo); + expect(args.dataDir, isNull); + }); + + test('reads every add option', () { + final args = CliArgs.parse([ + '--text', + 'Title', + '--priority', + 'high', + '--status', + 'in_progress', + '--data-dir', + '/tmp/x', + ]); + expect(args.mode, CliMode.add); + expect(args.text, 'Title'); + expect(args.priority, Priority.high); + expect(args.status, Status.inProgress); + expect(args.dataDir, '/tmp/x'); + }); + + test('accepts plain status names', () { + expect(CliArgs.parse(['--status', 'done']).status, Status.done); + }); + + test('selects list, get and delete', () { + expect(CliArgs.parse(['--list']).mode, CliMode.list); + final get = CliArgs.parse(['--get', 'abc']); + expect(get.mode, CliMode.get); + expect(get.id, 'abc'); + final del = CliArgs.parse(['--delete', 'xyz']); + expect(del.mode, CliMode.delete); + expect(del.id, 'xyz'); + }); + + test('help wins over everything else', () { + expect(CliArgs.parse(['--list', '-h']).mode, CliMode.help); + expect(CliArgs.parse(['--help']).mode, CliMode.help); + }); + + test('rejects malformed command lines', () { + void expectUsage(List argv, String fragment) { + expect( + () => CliArgs.parse(argv), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains(fragment), + ), + ), + ); + } + + expectUsage(['--text'], '--text needs a value'); + expectUsage(['--bogus'], 'unknown argument: --bogus'); + expectUsage(['--priority', 'urgent'], 'low|medium|high'); + expectUsage(['--status', 'inProgress'], 'todo|in_progress|done'); + expectUsage(['--list', '--get', 'a'], 'mutually exclusive'); + expectUsage(['--list', '--text', 'x'], 'only applies when adding'); + }); + }); + + test('statusName spells inProgress with an underscore', () { + expect(CliArgs.statusName(Status.inProgress), 'in_progress'); + expect(CliArgs.statusName(Status.abandoned), 'abandoned'); + }); +} diff --git a/test/cli/cli_read_input_test.dart b/test/cli/cli_read_input_test.dart new file mode 100644 index 0000000..6dddb22 --- /dev/null +++ b/test/cli/cli_read_input_test.dart @@ -0,0 +1,20 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/cli/cli_runner.dart'; + +void main() { + group('readInput', () { + test('returns empty for an interactive terminal', () async { + expect(await readInput(const Stream.empty(), hasTerminal: true), ''); + }); + + test('decodes the whole stream and trims trailing whitespace', () async { + final input = Stream>.fromIterable([ + utf8.encode('ab'), + utf8.encode('c\n\n'), + ]); + expect(await readInput(input, hasTerminal: false), 'abc'); + }); + }); +} diff --git a/test/cli/cli_runner_test.dart b/test/cli/cli_runner_test.dart new file mode 100644 index 0000000..babaf99 --- /dev/null +++ b/test/cli/cli_runner_test.dart @@ -0,0 +1,242 @@ +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:todo/cli/cli_runner.dart'; +import 'package:todo/cli/cli_session.dart'; +import 'package:todo/data/note.dart'; +import 'package:todo/data/note_repository.dart'; +import 'package:todo/sync/sync_service.dart'; + +import 'fake_rtdb.dart'; + +/// A session over an in-memory log whose [sync] reports a scripted `pushed` +/// flag per call, optionally holding a note that already exists. +class _ScriptedSession extends CliSession { + _ScriptedSession._( + NoteRepository repo, + FirebaseRestClient firebase, + this._pushed, + ) : super('unused', repo, firebase); + + static Future<_ScriptedSession> create( + http.Client client, + List pushed, { + String? noteId, + }) async { + final repo = await NoteRepository.openInMemory(); + if (noteId != null) { + final now = DateTime.utc(2026); + await repo.upsert( + Note( + id: noteId, + text: 'Stuck', + priority: Priority.medium, + status: Status.todo, + createdAt: now, + updatedAt: now, + ), + ); + } + final firebase = FirebaseRestClient( + databaseUrl: 'https://example.test', + auth: FirebaseTokenProvider( + apiKey: 'k', + store: InMemoryCredentialStore( + FirebaseCredentials( + idToken: 'id', + refreshToken: 'rt', + expiresAt: DateTime.utc(2099), + ), + ), + ), + httpClient: client, + ); + return _ScriptedSession._(repo, firebase, pushed); + } + + final List _pushed; + + @override + Future sync({required bool push}) async => + SyncResult(mergedDevices: 0, pushed: _pushed.removeAt(0)); +} + +void main() { + late Directory tmp; + late FakeRtdb db; + late Map env; + late StringBuffer out; + late StringBuffer err; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('cli_runner_test'); + db = FakeRtdb(); + final home = Directory('${tmp.path}/home')..createSync(); + Directory('${home.path}/.config/todo').createSync(recursive: true); + writeSeedCredentials( + tmp, + ).copySync('${home.path}/.config/todo/firebase_auth.json'); + env = {'HOME': home.path, 'XDG_DATA_HOME': '${tmp.path}/xdg'}; + out = StringBuffer(); + err = StringBuffer(); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + Future open(String dir, {required String seedCredentials}) => + CliSession.open( + dir, + seedCredentials: seedCredentials, + httpClient: db.client, + ); + + Future run( + List argv, { + String stdin = '', + SessionOpener? opener, + }) async { + out.clear(); + err.clear(); + return await runCli( + argv, + env: env, + out: out, + err: err, + readStdin: () async => stdin, + open: opener ?? open, + ); + } + + String dir(String name) => '${tmp.path}/$name'; + + Future add( + String name, + String text, [ + List extra = const [], + ]) async { + expect(await run(['--data-dir', dir(name), '--text', text, ...extra]), 0); + return out.toString().trim(); + } + + group('argument handling', () { + test('--help prints usage and exits 0', () async { + expect(await run(['--help']), 0); + expect(out.toString(), contains('Usage: dart run bin/todo_note.dart')); + }); + + test('a bad flag prints the error and usage, exits 2', () async { + expect(await run(['--nope']), 2); + expect(err.toString(), contains('unknown argument: --nope')); + expect(err.toString(), contains('Usage:')); + }); + + test('an empty note is refused before touching the network', () async { + expect(await run(['--data-dir', dir('d')], stdin: ' \n'), 2); + expect(err.toString(), contains('empty note')); + expect(db.methods, isEmpty); + }); + }); + + group('add', () { + test('pushes an ordinary CRDT log and prints the new id', () async { + final id = await add('a', 'Title\n\nbody', ['--priority', 'high']); + expect(id, matches(RegExp(r'^[0-9a-f-]{36}$'))); + expect(db.files.keys.where((k) => k.contains('notes/')), hasLength(1)); + expect(err.toString(), isEmpty); + }); + + test('reads the text from stdin when --text is absent', () async { + expect(await run(['--data-dir', dir('a')], stdin: 'From stdin'), 0); + expect(await run(['--list', '--data-dir', dir('a')]), 0); + expect(out.toString(), contains('\tmedium\ttodo\tFrom stdin')); + }); + + test('uses the XDG data dir by default', () async { + expect(await run(['--text', 'Default dir']), 0); + expect(Directory('${tmp.path}/xdg/todo-cli').existsSync(), isTrue); + }); + + test('fails when the push reports nothing published', () async { + Future opener(String d, {required String seedCredentials}) => + _ScriptedSession.create(db.client, [false]); + expect(await run(['--text', 'x'], opener: opener), 1); + expect(err.toString(), contains('saved locally but nothing was pushed')); + }); + }); + + group('list and get', () { + test('a second node lists and gets what the first one added', () async { + final id = await add('a', 'Title\n\nbody', [ + '--priority', + 'high', + '--status', + 'in_progress', + ]); + + expect(await run(['--list', '--data-dir', dir('b')]), 0); + expect(out.toString().trim(), '$id\thigh\tin_progress\tTitle'); + + expect(await run(['--get', id, '--data-dir', dir('b')]), 0); + expect(out.toString(), contains('id: $id')); + expect(out.toString(), contains('priority: high')); + expect(out.toString(), contains('status: in_progress')); + expect(out.toString(), contains('---\nTitle\n\nbody')); + }); + + test('--get of an unknown id exits 1', () async { + expect(await run(['--get', 'nope', '--data-dir', dir('b')]), 1); + expect(err.toString(), contains('no live note with id nope')); + }); + + test('an unmergeable peer file is reported as a warning', () async { + db.files['todo-sync/notes/bad~2Ejson'] = 'not json'; + expect(await run(['--list', '--data-dir', dir('b')]), 0); + expect(err.toString(), contains('could not merge peer file bad.json')); + }); + }); + + group('delete', () { + test('tombstones a note created elsewhere and pushes', () async { + final id = await add('a', 'Gone soon'); + expect(await run(['--delete', id, '--data-dir', dir('b')]), 0); + expect(out.toString().trim(), 'deleted $id'); + + expect(await run(['--list', '--data-dir', dir('c')]), 0); + expect(out.toString().trim(), isEmpty); + }); + + test('an unknown id exits 1', () async { + expect(await run(['--delete', 'nope', '--data-dir', dir('b')]), 1); + expect(err.toString(), contains('no live note with id nope')); + }); + + test('fails when the deletion push reports nothing published', () async { + Future opener(String d, {required String seedCredentials}) => + _ScriptedSession.create(db.client, [true, false], noteId: 'stuck'); + expect(await run(['--delete', 'stuck'], opener: opener), 1); + expect(err.toString(), contains('deletion saved locally')); + }); + }); + + group('failures', () { + test('a missing Firebase session exits 1 with re-seed advice', () async { + File('${env['HOME']}/.config/todo/firebase_auth.json').deleteSync(); + expect(await run(['--list', '--data-dir', dir('a')]), 1); + expect(err.toString(), contains('seed_session.py')); + }); + + test('a rejected database exits 1', () async { + db.failWith = 403; + expect(await run(['--list', '--data-dir', dir('a')]), 1); + expect(err.toString(), contains('rejected: HTTP 403')); + }); + + test('a filesystem failure exits 1', () async { + Future boom(String d, {required String seedCredentials}) => + throw const FileSystemException('disk full'); + expect(await run(['--list'], opener: boom), 1); + expect(err.toString(), contains('disk full')); + }); + }); +} diff --git a/test/cli/cli_session_test.dart b/test/cli/cli_session_test.dart new file mode 100644 index 0000000..633b1f3 --- /dev/null +++ b/test/cli/cli_session_test.dart @@ -0,0 +1,145 @@ +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/cli/cli_session.dart'; +import 'package:todo/data/note.dart'; + +import 'fake_rtdb.dart'; + +void main() { + late Directory tmp; + late FakeRtdb db; + late String seed; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('cli_session_test'); + db = FakeRtdb(); + seed = writeSeedCredentials(tmp).path; + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + Future open(String name, {String? seedPath}) => CliSession.open( + '${tmp.path}/$name', + seedCredentials: seedPath ?? seed, + httpClient: db.client, + ); + + Note note(String id) { + final now = DateTime.utc(2026); + return Note( + id: id, + text: 'Title\nbody', + priority: Priority.medium, + status: Status.todo, + createdAt: now, + updatedAt: now, + ); + } + + group('defaultDataDir', () { + test('prefers XDG_DATA_HOME', () { + expect( + defaultDataDir({'XDG_DATA_HOME': '/x', 'HOME': '/h'}), + '/x/todo-cli', + ); + }); + + test('falls back to ~/.local/share', () { + expect(defaultDataDir({'HOME': '/h'}), '/h/.local/share/todo-cli'); + expect( + defaultDataDir({'XDG_DATA_HOME': '', 'HOME': '/h'}), + '/h/.local/share/todo-cli', + ); + }); + + test('survives a missing HOME', () { + expect(defaultDataDir({}), '.local/share/todo-cli'); + }); + }); + + group('open', () { + test('creates an owner-only data dir and persists one node id', () async { + final first = await open('data'); + final nodeId = first.repository.nodeId; + await first.close(); + final dir = Directory('${tmp.path}/data'); + expect(dir.statSync().mode & 0x1ff, 448); // 0700 + expect( + File('${dir.path}/${CliSession.nodeIdFileName}').readAsStringSync(), + '$nodeId\n', + ); + + final second = await open('data'); + expect(second.repository.nodeId, nodeId); + await second.close(); + }); + + test('mints a fresh id when the stored one is blank', () async { + Directory('${tmp.path}/blank').createSync(); + File( + '${tmp.path}/blank/${CliSession.nodeIdFileName}', + ).writeAsStringSync(' \n'); + final session = await open('blank'); + expect(session.repository.nodeId, isNotEmpty); + await session.close(); + }); + + test('fails without a Firebase session, naming the seed', () async { + final missing = '${tmp.path}/missing.json'; + await expectLater( + open('nosession', seedPath: missing), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains(missing), + ), + ), + ); + }); + }); + + group('sync', () { + test('a pull-only sync never writes to the database', () async { + final session = await open('pull'); + await session.repository.upsert(note('n1')); + final result = await session.sync(push: false); + expect(result.pushed, isTrue); // SyncService reports its own intent. + expect(db.methods, isNot(contains('PUT'))); + expect(db.files, isEmpty); + await session.close(); + }); + + test('a push publishes the log, then skips an unchanged one', () async { + final session = await open('push'); + await session.repository.upsert(note('n1')); + final first = await session.sync(push: true); + expect(first.pushed, isTrue); + final node = session.repository.nodeId; + expect(db.files.keys, contains('todo-sync/notes/$node~2Ejson')); + expect(db.files.keys, contains('todo-sync/revs/$node')); + expect( + File('${tmp.path}/push/${CliSession.syncStateFileName}').existsSync(), + isTrue, + ); + + final second = await session.sync(push: true); + expect(second.pushed, isFalse); + await session.close(); + }); + + test('a second node sees the first node note', () async { + final a = await open('a'); + await a.repository.upsert(note('shared')); + await a.sync(push: true); + await a.close(); + + final b = await open('b'); + final result = await b.sync(push: false); + expect(result.mergedDevices, 1); + expect((await b.repository.listNotes()).single.id, 'shared'); + await b.close(); + }); + }); +} diff --git a/test/cli/fake_rtdb.dart b/test/cli/fake_rtdb.dart new file mode 100644 index 0000000..9444b72 --- /dev/null +++ b/test/cli/fake_rtdb.dart @@ -0,0 +1,74 @@ +/// An in-memory Firebase Realtime Database speaking the REST dialect that +/// `FirebaseRestClient` uses, plus a seed-credentials helper. +/// +/// Not named `*_test.dart`: the runner would collect it and fail on the +/// missing `main()`. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; + +/// The fake database: text blobs keyed by their (still key-escaped) path. +class FakeRtdb { + /// Every stored leaf, e.g. `todo-sync/notes/~2Ejson` -> log JSON. + final Map files = {}; + + /// Methods of every request seen, in order (`GET`, `PUT`, ...). + final List methods = []; + + /// When set, every request answers with this status and an empty body. + int? failWith; + + /// The client to hand to the code under test. + late final http.Client client = http_testing.MockClient(_handle); + + Future _handle(http.Request request) async { + methods.add(request.method); + final status = failWith; + if (status != null) return http.Response('denied', status); + final path = request.url.path.replaceFirst(RegExp('^/'), ''); + final key = path.endsWith('.json') + ? path.substring(0, path.length - '.json'.length) + : path; + switch (request.method) { + case 'PUT': + files[key] = jsonDecode(request.body) as String; + return http.Response('null', 200); + case 'DELETE': + files.remove(key); + return http.Response('null', 200); + default: + return http.Response(_read(key, request.url.queryParameters), 200); + } + } + + String _read(String key, Map query) { + final children = { + for (final entry in files.entries) + if (key.isEmpty || entry.key.startsWith('$key/')) + entry.key.substring(key.isEmpty ? 0 : key.length + 1): entry.value, + }; + if (query['shallow'] == 'true') { + return children.isEmpty + ? 'null' + : jsonEncode({for (final k in children.keys) k: true}); + } + final leaf = files[key]; + if (leaf != null) return jsonEncode(leaf); + return children.isEmpty ? 'null' : jsonEncode(children); + } +} + +/// Writes a Firebase session that does not expire during a test run. +File writeSeedCredentials(Directory dir, {String name = 'seed.json'}) { + return File('${dir.path}/$name')..writeAsStringSync( + jsonEncode({ + 'id_token': 'seed-id-token', + 'refresh_token': 'seed-refresh-token', + 'expires_at': '2099-01-01T00:00:00Z', + }), + ); +} diff --git a/test/cli/file_credential_store_test.dart b/test/cli/file_credential_store_test.dart new file mode 100644 index 0000000..d9b1d7c --- /dev/null +++ b/test/cli/file_credential_store_test.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/cli/file_credential_store.dart'; + +import 'fake_rtdb.dart'; + +void main() { + late Directory tmp; + late File own; + late File seed; + late FileCredentialStore store; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('cred_store_test'); + own = File('${tmp.path}/nested/own.json'); + seed = File('${tmp.path}/seed.json'); + store = FileCredentialStore(file: own, seedFile: seed); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('load is null when neither file exists', () async { + expect(await store.load(), isNull); + }); + + test('load falls back to the seed, then prefers its own copy', () async { + writeSeedCredentials(tmp); + expect((await store.load())!.refreshToken, 'seed-refresh-token'); + + await store.save( + FirebaseCredentials( + idToken: 'new-id', + refreshToken: 'new-refresh', + expiresAt: DateTime.utc(2099), + ), + ); + expect((await store.load())!.refreshToken, 'new-refresh'); + // The seed is read-only: saving never touches it. + expect( + jsonDecode(seed.readAsStringSync()), + containsPair('refresh_token', 'seed-refresh-token'), + ); + }); + + test('save writes an owner-only file and leaves no temp file', () async { + await store.save( + FirebaseCredentials( + idToken: 'i', + refreshToken: 'r', + expiresAt: DateTime.utc(2099), + ), + ); + expect(own.statSync().mode & 0x1ff, 384); // 0600 + expect(own.parent.listSync().map((e) => e.path), [own.path]); + }); + + test('load is null for a non-object document', () async { + seed.writeAsStringSync('[1, 2]'); + expect(await store.load(), isNull); + }); + + test('clear deletes its own copy and tolerates a missing one', () async { + await store.clear(); + await store.save( + FirebaseCredentials( + idToken: 'i', + refreshToken: 'r', + expiresAt: DateTime.utc(2099), + ), + ); + await store.clear(); + expect(own.existsSync(), isFalse); + }); +} diff --git a/test/cli/pull_only_store_test.dart b/test/cli/pull_only_store_test.dart new file mode 100644 index 0000000..44b3814 --- /dev/null +++ b/test/cli/pull_only_store_test.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/cli/pull_only_store.dart'; + +import 'fake_rtdb.dart'; + +void main() { + test('reads pass through and writes are dropped', () async { + final tmp = Directory.systemTemp.createTempSync('pull_only_test'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final db = FakeRtdb() + ..files['todo-sync/notes/a~2Ejson'] = 'log-a' + ..files['todo-sync/revs/a'] = 'rev-a'; + final inner = FirebaseRestClient( + databaseUrl: 'https://example.test/', + auth: FirebaseTokenProvider( + apiKey: 'k', + store: InMemoryCredentialStore( + FirebaseCredentials( + idToken: 'id', + refreshToken: 'rt', + expiresAt: DateTime.utc(2099), + ), + ), + ), + httpClient: db.client, + ); + final store = PullOnlyStore(inner); + + expect(await store.listDirectory('todo-sync/notes'), ['a.json']); + expect(await store.getFileText('todo-sync/notes/a.json'), 'log-a'); + expect(await store.getStringMap('todo-sync/revs'), {'a': 'rev-a'}); + expect(await store.canAccessRemote(), isTrue); + + final before = db.methods.length; + await store.putFileText('todo-sync/notes/b.json', 'x', message: 'm'); + await store.deleteFile('todo-sync/notes/a.json'); + expect(db.methods.length, before); + expect(db.files.keys, hasLength(2)); + store.close(); + }); +}