mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 16:38:36 +01:00
Add headless note CLI (bin/todo_note.dart) reusing app CRDT sync
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f7df3cbbd1
commit
56cb1b3594
22
README.md
22
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 <id> # full note
|
||||
dart run bin/todo_note.dart --delete <id>
|
||||
```
|
||||
|
||||
`--list` and `--get` never push; `--data-dir <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.
|
||||
|
||||
21
bin/todo_note.dart
Normal file
21
bin/todo_note.dart
Normal file
@ -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 <id>
|
||||
//
|
||||
// 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<void> main(List<String> argv) async {
|
||||
exitCode = await runCli(
|
||||
argv,
|
||||
env: Platform.environment,
|
||||
out: stdout,
|
||||
err: stderr,
|
||||
readStdin: () => readInput(stdin, hasTerminal: stdin.hasTerminal),
|
||||
);
|
||||
}
|
||||
166
lib/cli/cli_args.dart
Normal file
166
lib/cli/cli_args.dart
Normal file
@ -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<String> argv) {
|
||||
String? text;
|
||||
String? id;
|
||||
String? dataDir;
|
||||
var priority = Priority.medium;
|
||||
var status = Status.todo;
|
||||
final modes = <CliMode>{};
|
||||
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<T extends Enum>(
|
||||
List<T> 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 <markdown> note text (first line = title); default: read stdin
|
||||
--priority <p> low|medium|high (default medium)
|
||||
--status <s> todo|in_progress|done|abandoned (default todo)
|
||||
--list pull, then print live notes: id, priority, status, title
|
||||
--get <id> pull, then print one note in full (exit 1 if absent)
|
||||
--delete <id> pull, tombstone the note, push
|
||||
--data-dir <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.''';
|
||||
186
lib/cli/cli_runner.dart
Normal file
186
lib/cli/cli_runner.dart
Normal file
@ -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<CliSession> Function(
|
||||
String dataDir, {
|
||||
required String seedCredentials,
|
||||
});
|
||||
|
||||
/// Reads all of [input] as text, or '' when it is an interactive terminal.
|
||||
Future<String> readInput(
|
||||
Stream<List<int>> 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<int> runCli(
|
||||
List<String> argv, {
|
||||
required Map<String, String> env,
|
||||
required StringSink out,
|
||||
required StringSink err,
|
||||
required Future<String> 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<int> 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<int> 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<int> 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<int> 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<Note?> _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;
|
||||
}
|
||||
}
|
||||
133
lib/cli/cli_session.dart
Normal file
133
lib/cli/cli_session.dart
Normal file
@ -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<String, String> 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<CliSession> 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<SyncResult> 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<void> 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<String> _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;
|
||||
}
|
||||
}
|
||||
52
lib/cli/file_credential_store.dart
Normal file
52
lib/cli/file_credential_store.dart
Normal file
@ -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<FirebaseCredentials?> load() async {
|
||||
final source = file.existsSync() ? file : seedFile;
|
||||
if (!source.existsSync()) return null;
|
||||
final decoded = jsonDecode(await source.readAsString());
|
||||
if (decoded is! Map<String, dynamic>) return null;
|
||||
return FirebaseCredentials.fromJson(decoded);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<void> clear() async {
|
||||
if (file.existsSync()) await file.delete();
|
||||
}
|
||||
}
|
||||
44
lib/cli/pull_only_store.dart
Normal file
44
lib/cli/pull_only_store.dart
Normal file
@ -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<List<String>> listDirectory(String path) => inner.listDirectory(path);
|
||||
|
||||
@override
|
||||
Future<String?> getFileText(String path) => inner.getFileText(path);
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> getStringMap(String path) =>
|
||||
inner.getStringMap(path);
|
||||
|
||||
@override
|
||||
Future<void> putFileText(
|
||||
String path,
|
||||
String text, {
|
||||
required String message,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String path, {String message = ''}) async {}
|
||||
|
||||
@override
|
||||
Future<bool> canAccessRemote() => inner.canAccessRemote();
|
||||
|
||||
@override
|
||||
void close() => inner.close();
|
||||
}
|
||||
@ -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
|
||||
|
||||
80
test/cli/cli_args_test.dart
Normal file
80
test/cli/cli_args_test.dart
Normal file
@ -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<String> argv, String fragment) {
|
||||
expect(
|
||||
() => CliArgs.parse(argv),
|
||||
throwsA(
|
||||
isA<UsageError>().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');
|
||||
});
|
||||
}
|
||||
20
test/cli/cli_read_input_test.dart
Normal file
20
test/cli/cli_read_input_test.dart
Normal file
@ -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<List<int>>.fromIterable([
|
||||
utf8.encode('ab'),
|
||||
utf8.encode('c\n\n'),
|
||||
]);
|
||||
expect(await readInput(input, hasTerminal: false), 'abc');
|
||||
});
|
||||
});
|
||||
}
|
||||
242
test/cli/cli_runner_test.dart
Normal file
242
test/cli/cli_runner_test.dart
Normal file
@ -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<bool> 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<bool> _pushed;
|
||||
|
||||
@override
|
||||
Future<SyncResult> sync({required bool push}) async =>
|
||||
SyncResult(mergedDevices: 0, pushed: _pushed.removeAt(0));
|
||||
}
|
||||
|
||||
void main() {
|
||||
late Directory tmp;
|
||||
late FakeRtdb db;
|
||||
late Map<String, String> 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<CliSession> open(String dir, {required String seedCredentials}) =>
|
||||
CliSession.open(
|
||||
dir,
|
||||
seedCredentials: seedCredentials,
|
||||
httpClient: db.client,
|
||||
);
|
||||
|
||||
Future<int> run(
|
||||
List<String> 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<String> add(
|
||||
String name,
|
||||
String text, [
|
||||
List<String> 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<CliSession> 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<CliSession> 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<CliSession> boom(String d, {required String seedCredentials}) =>
|
||||
throw const FileSystemException('disk full');
|
||||
expect(await run(['--list'], opener: boom), 1);
|
||||
expect(err.toString(), contains('disk full'));
|
||||
});
|
||||
});
|
||||
}
|
||||
145
test/cli/cli_session_test.dart
Normal file
145
test/cli/cli_session_test.dart
Normal file
@ -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<CliSession> 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<FirebaseAuthError>().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();
|
||||
});
|
||||
});
|
||||
}
|
||||
74
test/cli/fake_rtdb.dart
Normal file
74
test/cli/fake_rtdb.dart
Normal file
@ -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/<node>~2Ejson` -> log JSON.
|
||||
final Map<String, String> files = {};
|
||||
|
||||
/// Methods of every request seen, in order (`GET`, `PUT`, ...).
|
||||
final List<String> 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<http.Response> _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<String, String> query) {
|
||||
final children = <String, String>{
|
||||
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',
|
||||
}),
|
||||
);
|
||||
}
|
||||
76
test/cli/file_credential_store_test.dart
Normal file
76
test/cli/file_credential_store_test.dart
Normal file
@ -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);
|
||||
});
|
||||
}
|
||||
44
test/cli/pull_only_store_test.dart
Normal file
44
test/cli/pull_only_store_test.dart
Normal file
@ -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();
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user