Files
clide/test/builtin/claude/account_registry_test.dart
T
jpmschweitzerandClaude Opus 4.8 a8d321f344 feat(claude): per-repo account registry + storage foundation (T-483)
Foundation for the multi-account epic (T-476): a Flutter-free AccountRegistry
over the app-scope SettingsStore (per-user, never committed to a repo).

- `app.claude.accounts` — the {name, dir} account list (dir = CLAUDE_CONFIG_DIR).
- `app.claude.account.<hash>` — per-workspace binding to an account name; the
  hash is the SAME FNV-1a 64-bit hex D-70 uses for the socket path, trailing
  separators stripped so /repo and /repo/ map alike.
- accountForWorkspace(cwd) resolves an account or null (a binding to a removed
  account degrades to null = Claude's default, never errors).
- probeExistingAccountDirs(home): read-only bootstrap probe for adoptable
  ~/.claude-* config dirs (welcome-view UX is T-481).

Keys are app.-prefixed because SettingsStore requires a scope prefix; app scope
already provides the per-user persistence T-483 wants (T-356 will consolidate
more state later but isn't a hard blocker). No spawn/UI/CLI — downstream
tickets. Unit tests cover CRUD, the disk round-trip, hash/trailing-slash
mapping, independent bindings, and probe filtering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:42:39 +02:00

124 lines
5.6 KiB
Dart

/// T-483: AccountRegistry — user-scope storage + typed reader/writer for the
/// per-repo Claude account epic (T-476). Persistence flows through a real
/// SettingsStore over a temp appDir, so these cover the round-trip (incl. a
/// reload from disk) without mocking the store.
library;
import 'dart:io';
import 'package:clide/builtin/claude/src/account_registry.dart';
import 'package:clide/kernel/src/settings.dart';
import 'package:clide/src/ipc/paths.dart' show canonicalWorkspaceKey, fnv1a64Hex;
import 'package:flutter_test/flutter_test.dart';
void main() {
late Directory appDir;
late SettingsStore store;
late AccountRegistry reg;
setUp(() async {
appDir = await Directory.systemTemp.createTemp('clide-accounts-');
store = SettingsStore(appDir: appDir);
await store.load();
reg = AccountRegistry(store);
});
tearDown(() {
store.dispose();
if (appDir.existsSync()) appDir.deleteSync(recursive: true);
});
group('account registry CRUD', () {
test('register + read back; replace by name; remove', () async {
expect(reg.accounts, isEmpty);
await reg.registerAccount('personal', '/home/u/.claude-personal');
await reg.registerAccount('work', '/home/u/.claude-work');
expect(reg.accounts, [const Account(name: 'personal', dir: '/home/u/.claude-personal'), const Account(name: 'work', dir: '/home/u/.claude-work')]);
expect(reg.accountByName('work')?.dir, '/home/u/.claude-work');
expect(reg.accountByName('nope'), isNull);
// Re-register by the same name replaces (no duplicate).
await reg.registerAccount('personal', '/home/u/.claude-personal2');
expect(reg.accounts.where((a) => a.name == 'personal'), hasLength(1));
expect(reg.accountByName('personal')?.dir, '/home/u/.claude-personal2');
await reg.removeAccount('personal');
expect(reg.accountByName('personal'), isNull);
expect(reg.accounts, hasLength(1));
});
test('the accounts list survives a reload from disk', () async {
await reg.registerAccount('work', '/home/u/.claude-work');
// Fresh store over the same appDir, loaded from the YAML on disk.
final store2 = SettingsStore(appDir: appDir);
await store2.load();
addTearDown(store2.dispose);
final reg2 = AccountRegistry(store2);
expect(reg2.accounts, [const Account(name: 'work', dir: '/home/u/.claude-work')]);
});
});
group('workspace binding', () {
test('bind resolves an account; unbind clears it; unknown name → null', () async {
await reg.registerAccount('work', '/home/u/.claude-work');
expect(reg.accountForWorkspace('/repo/a'), isNull);
await reg.bindWorkspace('/repo/a', 'work');
expect(reg.accountForWorkspace('/repo/a')?.dir, '/home/u/.claude-work');
// A binding to a removed account degrades to null (Claude default).
await reg.removeAccount('work');
expect(reg.accountForWorkspace('/repo/a'), isNull);
await reg.registerAccount('work', '/home/u/.claude-work');
expect(reg.accountForWorkspace('/repo/a')?.dir, '/home/u/.claude-work');
await reg.unbindWorkspace('/repo/a');
expect(reg.accountForWorkspace('/repo/a'), isNull);
});
test('workspace hash is the D-70 socket hash and ignores trailing slashes', () {
expect(AccountRegistry.workspaceHash('/repo/a'), AccountRegistry.workspaceHash('/repo/a/'));
expect(AccountRegistry.workspaceHash('/repo/a'), AccountRegistry.workspaceHash('/repo/a///'));
expect(AccountRegistry.workspaceHash('/repo/a'), isNot(AccountRegistry.workspaceHash('/repo/b')));
// Same algorithm/input as the socket path (single source of truth, D-70).
expect(AccountRegistry.workspaceHash('/repo/a'), fnv1a64Hex(canonicalWorkspaceKey('/repo/a')));
// The binding key is namespaced under app.claude.account.<hash>.
expect(AccountRegistry.bindingKey('/repo/a'), 'app.claude.account.${AccountRegistry.workspaceHash('/repo/a')}');
});
test('two workspaces bind independently', () async {
await reg.registerAccount('personal', '/p');
await reg.registerAccount('work', '/w');
await reg.bindWorkspace('/repo/a', 'personal');
await reg.bindWorkspace('/repo/b', 'work');
expect(reg.accountForWorkspace('/repo/a')?.name, 'personal');
expect(reg.accountForWorkspace('/repo/b')?.name, 'work');
});
});
group('bootstrap probe', () {
test('returns ~/.claude-* dirs that look like config dirs, skipping the rest', () async {
final home = await Directory.systemTemp.createTemp('clide-home-');
addTearDown(() => home.deleteSync(recursive: true));
// A config dir marked by .claude.json.
Directory('${home.path}/.claude-personal').createSync();
File('${home.path}/.claude-personal/.claude.json').writeAsStringSync('{}');
// A config dir marked by sessions/.
Directory('${home.path}/.claude-work/sessions').createSync(recursive: true);
// A .claude-* dir that is NOT a config dir (no marker) — skipped.
Directory('${home.path}/.claude-empty').createSync();
// The real ~/.claude (no `-suffix`) — not a candidate.
Directory('${home.path}/.claude').createSync();
// An unrelated dir — skipped.
Directory('${home.path}/projects').createSync();
final found = probeExistingAccountDirs(home.path);
expect(found.map((d) => d.name), ['personal', 'work']);
expect(found.first.dir, '${home.path}/.claude-personal');
});
test('missing home returns empty, mutates nothing', () {
expect(probeExistingAccountDirs('/no/such/home/${DateTime.now().microsecondsSinceEpoch}'), isEmpty);
});
});
}