added support for multiple accounts with an account switcher in settings

This commit is contained in:
2026-09-27 14:54:10 +02:00
parent 5e65c4671a
commit 27563a6dc1
33 changed files with 1801 additions and 462 deletions
+16
View File
@@ -18,4 +18,20 @@ void main() {
expect(CacheStore.parse('abc\n{}'), isNull);
});
});
group('CacheStore.directoryName', () {
test('keeps the original directory for the pre-multi-account account', () {
expect(CacheStore.directoryName(''), 'request_cache');
});
test('separates accounts and the signed-out state', () {
final names = {
CacheStore.directoryName(''),
CacheStore.directoryName('a1b2'),
CacheStore.directoryName('c3d4'),
CacheStore.directoryName(null),
};
expect(names, hasLength(4));
});
});
}
+166
View File
@@ -0,0 +1,166 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/session/account_codec.dart';
import 'package:marianum_mobile/session/session.dart';
import 'package:marianum_mobile/session/session_codec.dart';
void main() {
final school = CredentialSession(username: 'max', password: 'pw');
final second = CredentialSession(username: 'erika', password: 'pw');
group('AccountIndex.activate', () {
test('adds an unknown account with its own namespace', () {
final index = AccountIndex.empty.activate(school, newId: 'a', now: 5);
expect(index.activeId, 'a');
final entry = index.active!;
expect(entry.kind, SessionKeys.kindCredential);
expect(entry.label, 'max');
expect(entry.namespace, 'a');
expect(entry.lastUsed, 5);
});
test('legacy account keeps the un-namespaced storage', () {
final index = AccountIndex.empty.activate(
school,
newId: 'a',
namespace: '',
);
expect(index.active!.namespace, '');
});
test('a known account is reused instead of duplicated', () {
final index = AccountIndex.empty
.activate(school, newId: 'a')
.activate(second, newId: 'b')
.activate(
CredentialSession(username: 'max', password: 'new'),
newId: 'c',
now: 9,
);
expect(index.accounts.map((e) => e.id), ['a', 'b']);
expect(index.activeId, 'a');
expect(index.active!.lastUsed, 9);
});
test('demo and real account with the same name stay apart', () {
final index = AccountIndex.empty
.activate(school, newId: 'a')
.activate(
CredentialSession(username: 'max', password: 'demo', isDemo: true),
newId: 'b',
);
expect(index.accounts, hasLength(2));
});
});
group('AccountIndex removal', () {
final index = AccountIndex.empty
.activate(school, newId: 'a', now: 1)
.activate(second, newId: 'b', now: 3)
.select('a', now: 2);
test('removing the active account leaves none active', () {
final removed = index.remove('a');
expect(removed.activeId, isNull);
expect(removed.accounts.map((e) => e.id), ['b']);
});
test('removing an inactive account keeps the active one', () {
expect(index.remove('b').activeId, 'a');
});
test('takeover picks the most recently used remaining account', () {
final third = index.activate(
CredentialSession(username: 'zoe', password: 'pw'),
newId: 'c',
now: 1,
);
expect(third.remove('a').mostRecentExcept(null)?.id, 'b');
expect(index.mostRecentExcept('b')?.id, 'a');
expect(AccountIndex.empty.mostRecentExcept(null), isNull);
});
});
group('AccountIndex codec', () {
test('round trip', () {
final index = AccountIndex.empty
.activate(school, newId: 'a', namespace: '', now: 1)
.activate(second, newId: 'b', now: 2);
final decoded = AccountIndex.decode(index.encode());
expect(decoded.activeId, 'b');
expect(decoded.accounts, hasLength(2));
expect(decoded.byId('a')!.namespace, '');
expect(decoded.byId('b')!.label, 'erika');
expect(decoded.byId('b')!.lastUsed, 2);
});
test('display name survives rename and round trip', () {
final index = AccountIndex.empty
.activate(school, newId: 'a')
.activate(second, newId: 'b')
.rename('b', 'Erika Muster');
final decoded = AccountIndex.decode(index.encode());
expect(decoded.byId('b')!.displayName, 'Erika Muster');
expect(decoded.byId('a')!.displayName, isNull);
expect(decoded.activeId, 'b');
});
test('garbage decodes to an empty index', () {
expect(AccountIndex.decode(null).accounts, isEmpty);
expect(AccountIndex.decode('{nope').accounts, isEmpty);
expect(AccountIndex.decode('[]').accounts, isEmpty);
});
test('malformed entries are skipped', () {
final decoded = AccountIndex.decode(
'{"activeId":"a","accounts":[{"id":"a"},'
'{"id":"b","kind":"credential","label":"x"}]}',
);
expect(decoded.accounts.map((e) => e.id), ['b']);
expect(decoded.active, isNull);
});
});
group('vault', () {
test('credential session survives the vault with app passwords', () {
final session = CredentialSession(
username: 'max',
password: 'pw',
appPassword: 'app',
appPasswordTalk: 'talk',
usesLoginFlow: true,
);
final fields = decodeVault(
encodeVault({...sessionVaultFields(session), 'mc_bearer_token': 't'}),
);
final restored = decodeSession(fields)! as CredentialSession;
expect(restored.username, 'max');
expect(restored.password, 'pw');
expect(restored.nextcloud.appPassword, 'app');
expect(restored.nextcloud.appPasswordTalk, 'talk');
expect(restored.nextcloud.usesLoginFlow, isTrue);
expect(fields['mc_bearer_token'], 't');
});
test('demo session survives the vault without app passwords', () {
final fields = decodeVault(
encodeVault(
sessionVaultFields(
CredentialSession(
username: 'demo@x',
password: 'demo',
isDemo: true,
),
),
),
);
final restored = decodeSession(fields)! as CredentialSession;
expect(restored.username, 'demo@x');
expect(restored.isDemo, isTrue);
expect(fields.containsKey(SessionKeys.appPassword), isFalse);
});
test('broken vault yields no session', () {
expect(decodeSession(decodeVault('garbage')), isNull);
});
});
}