refactor!: Rename sync APIs and ownership spaces (#128)

This commit is contained in:
Marcelo Mendonça Soares 2026-09-14 13:39:07 -03:00 • committed by GitHub
commit b89725f6ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 43650 additions and 9417 deletions

View file

@ -1,358 +0,0 @@
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:test/test.dart';
import 'test_tools/serverpod_test_tools.dart';
void main() {
withServerpod('[CRDT scopes]', (sessionBuilder, _) {
late Session session;
setUp(() {
session = sessionBuilder.build();
session.serverpod.initializeCrdtSync(syncTables: []);
});
group('Given a database with no shared scopes,', () {
test(
'when createFor is called with only a user, '
'then the user is granted readWrite access to the new scope.',
() async {
final user = const Uuid().v7obj();
final scope = await session.crdt.scopes.createFor(user);
expect(
await session.crdt.scopes.roleOf(user: user, scope: scope),
CrdtScopeRole.readWrite,
);
expect(
await CrdtScopeMembership.memberScopes(session, user),
contains(scope),
);
},
);
test(
'when createFor is called with a user and a role, '
'then the user is granted that role.',
() async {
final user = const Uuid().v7obj();
final scope = await session.crdt.scopes.createFor(
user,
role: CrdtScopeRole.readOnly,
);
expect(
await session.crdt.scopes.members(scope),
{user: CrdtScopeRole.readOnly},
);
},
);
test(
'when create is called without grants, '
'then the new shared scope is dormant and has no explicit members.',
() async {
final user = const Uuid().v7obj();
final scope = await session.crdt.scopes.create();
expect(await session.crdt.scopes.members(scope), isEmpty);
expect(
await session.crdt.scopes.roleOf(user: user, scope: scope),
isNull,
);
expect(
await CrdtScopeMembership.memberScopes(session, user),
isNot(contains(scope)),
);
},
);
test(
'when create is called with several grants, '
'then every membership is stored with its role.',
() async {
final writer = const Uuid().v7obj();
final reader = const Uuid().v7obj();
final scope = await session.crdt.scopes.create(
grants: {
writer: CrdtScopeRole.readWrite,
reader: CrdtScopeRole.readOnly,
},
);
expect(await session.crdt.scopes.members(scope), {
writer: CrdtScopeRole.readWrite,
reader: CrdtScopeRole.readOnly,
});
},
);
test(
'when roleOf is called for a personal scope with no row, '
'then readWrite is returned.',
() async {
final user = const Uuid().v7obj();
expect(
await session.crdt.scopes.roleOf(user: user, scope: user),
CrdtScopeRole.readWrite,
);
expect(await session.crdt.scopes.members(user), isEmpty);
},
);
test(
'when createFor is called inside a transaction that rolls back, '
'then no scope or membership rows leak.',
() async {
final user = const Uuid().v7obj();
final countsBefore = await _membershipRowCounts(session);
await expectLater(
session.db.transaction((tx) async {
await session.crdt.scopes.createFor(user, transaction: tx);
throw StateError('rollback');
}),
throwsStateError,
);
expect(await _membershipRowCounts(session), countsBefore);
},
);
test(
'when grant is called for an unknown shared scope, '
'then CrdtScopeNotFoundException is thrown.',
() async {
final missingScope = const Uuid().v7obj();
await expectLater(
session.crdt.scopes.grant(
scope: missingScope,
user: const Uuid().v7obj(),
role: CrdtScopeRole.readWrite,
),
throwsA(
isA<CrdtScopeNotFoundException>().having(
(error) => error.scope,
'scope',
missingScope,
),
),
);
},
);
});
group('Given a database with a dormant shared scope,', () {
late UuidValue scope;
setUp(() async {
scope = await session.crdt.scopes.create();
});
test(
'when grant is called, '
'then the membership is inserted.',
() async {
final user = const Uuid().v7obj();
await session.crdt.scopes.grant(
scope: scope,
user: user,
role: CrdtScopeRole.readOnly,
);
expect(
await session.crdt.scopes.roleOf(user: user, scope: scope),
CrdtScopeRole.readOnly,
);
expect(
await session.crdt.scopes.members(scope),
{user: CrdtScopeRole.readOnly},
);
},
);
test(
'when grantAll is called with several users, '
'then all memberships are applied.',
() async {
final readOnlyUser = const Uuid().v7obj();
final readWriteUser = const Uuid().v7obj();
await session.crdt.scopes.grantAll(scope, {
readOnlyUser: CrdtScopeRole.readOnly,
readWriteUser: CrdtScopeRole.readWrite,
});
expect(await session.crdt.scopes.members(scope), {
readOnlyUser: CrdtScopeRole.readOnly,
readWriteUser: CrdtScopeRole.readWrite,
});
},
);
test(
'when grant is called inside a transaction that rolls back, '
'then no membership row leaks.',
() async {
final user = const Uuid().v7obj();
final countsBefore = await _membershipRowCounts(session);
await expectLater(
session.db.transaction((tx) async {
await session.crdt.scopes.grant(
scope: scope,
user: user,
role: CrdtScopeRole.readWrite,
transaction: tx,
);
throw StateError('rollback');
}),
throwsStateError,
);
expect(await _membershipRowCounts(session), countsBefore);
expect(
await session.crdt.scopes.roleOf(user: user, scope: scope),
isNull,
);
},
);
});
group('Given a database with a readOnly member on a shared scope,', () {
late UuidValue user;
late UuidValue scope;
setUp(() async {
user = const Uuid().v7obj();
scope = await session.crdt.scopes.create(
grants: {user: CrdtScopeRole.readOnly},
);
});
test(
'when grant is called again for the same member with readWrite, '
'then the existing membership is updated.',
() async {
await session.crdt.scopes.grant(
scope: scope,
user: user,
role: CrdtScopeRole.readWrite,
);
expect(
await session.crdt.scopes.roleOf(user: user, scope: scope),
CrdtScopeRole.readWrite,
);
expect(
await session.crdt.scopes.members(scope),
{user: CrdtScopeRole.readWrite},
);
},
);
});
group('Given a database with a shared scope that has one readWrite member,', () {
late UuidValue member;
late UuidValue nonMember;
late UuidValue scope;
setUp(() async {
member = const Uuid().v7obj();
nonMember = const Uuid().v7obj();
scope = await session.crdt.scopes.createFor(member);
});
test(
'when revoke is called for the member, '
'then the membership is removed.',
() async {
await session.crdt.scopes.revoke(scope: scope, user: member);
expect(
await session.crdt.scopes.roleOf(user: member, scope: scope),
isNull,
);
expect(await session.crdt.scopes.members(scope), isEmpty);
},
);
test(
'when revoke is called for a non-member, '
'then it completes without changing the scope members.',
() async {
await expectLater(
session.crdt.scopes.revoke(scope: scope, user: nonMember),
completes,
);
expect(
await session.crdt.scopes.members(scope),
{member: CrdtScopeRole.readWrite},
);
},
);
test(
'when roleOf is called for stored and missing memberships, '
'then it returns the stored role or null.',
() async {
expect(
await session.crdt.scopes.roleOf(user: member, scope: scope),
CrdtScopeRole.readWrite,
);
expect(
await session.crdt.scopes.roleOf(user: nonMember, scope: scope),
isNull,
);
},
);
});
group('Given a database with populated and dormant shared scopes,', () {
late UuidValue readOnlyUser;
late UuidValue readWriteUser;
late UuidValue populatedScope;
late UuidValue dormantScope;
setUp(() async {
readOnlyUser = const Uuid().v7obj();
readWriteUser = const Uuid().v7obj();
populatedScope = await session.crdt.scopes.create(
grants: {
readOnlyUser: CrdtScopeRole.readOnly,
readWriteUser: CrdtScopeRole.readWrite,
},
);
dormantScope = await session.crdt.scopes.create();
});
test(
'when members is called, '
'then explicit membership maps are returned.',
() async {
expect(await session.crdt.scopes.members(populatedScope), {
readOnlyUser: CrdtScopeRole.readOnly,
readWriteUser: CrdtScopeRole.readWrite,
});
expect(await session.crdt.scopes.members(dormantScope), isEmpty);
},
);
});
});
}
Future<Map<String, int>> _membershipRowCounts(Session session) async {
return {
CrdtScope.t.tableName: await CrdtScope.db.count(session),
CrdtScopeMember.t.tableName: await CrdtScopeMember.db.count(session),
};
}

View file

@ -0,0 +1,358 @@
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:test/test.dart';
import 'test_tools/serverpod_test_tools.dart';
void main() {
withServerpod('[CRDT spaces]', (sessionBuilder, _) {
late Session session;
setUp(() {
session = sessionBuilder.build();
session.serverpod.initializeOfflineSync(syncTables: []);
});
group('Given a database with no shared spaces,', () {
test(
'when createFor is called with only a user, '
'then the user is granted readWrite access to the new space.',
() async {
final user = const Uuid().v7obj();
final space = await session.offlineSync.spaces.createFor(user);
expect(
await session.offlineSync.spaces.roleOf(user: user, space: space),
OfflineSyncSpaceRole.readWrite,
);
expect(
await OfflineSyncSpaceMembership.memberSpaces(session, user),
contains(space),
);
},
);
test(
'when createFor is called with a user and a role, '
'then the user is granted that role.',
() async {
final user = const Uuid().v7obj();
final space = await session.offlineSync.spaces.createFor(
user,
role: OfflineSyncSpaceRole.readOnly,
);
expect(
await session.offlineSync.spaces.members(space),
{user: OfflineSyncSpaceRole.readOnly},
);
},
);
test(
'when create is called without grants, '
'then the new shared space is dormant and has no explicit members.',
() async {
final user = const Uuid().v7obj();
final space = await session.offlineSync.spaces.create();
expect(await session.offlineSync.spaces.members(space), isEmpty);
expect(
await session.offlineSync.spaces.roleOf(user: user, space: space),
isNull,
);
expect(
await OfflineSyncSpaceMembership.memberSpaces(session, user),
isNot(contains(space)),
);
},
);
test(
'when create is called with several grants, '
'then every membership is stored with its role.',
() async {
final writer = const Uuid().v7obj();
final reader = const Uuid().v7obj();
final space = await session.offlineSync.spaces.create(
grants: {
writer: OfflineSyncSpaceRole.readWrite,
reader: OfflineSyncSpaceRole.readOnly,
},
);
expect(await session.offlineSync.spaces.members(space), {
writer: OfflineSyncSpaceRole.readWrite,
reader: OfflineSyncSpaceRole.readOnly,
});
},
);
test(
'when roleOf is called for a personal space with no row, '
'then readWrite is returned.',
() async {
final user = const Uuid().v7obj();
expect(
await session.offlineSync.spaces.roleOf(user: user, space: user),
OfflineSyncSpaceRole.readWrite,
);
expect(await session.offlineSync.spaces.members(user), isEmpty);
},
);
test(
'when createFor is called inside a transaction that rolls back, '
'then no space or membership rows leak.',
() async {
final user = const Uuid().v7obj();
final countsBefore = await _membershipRowCounts(session);
await expectLater(
session.db.transaction((tx) async {
await session.offlineSync.spaces.createFor(user, transaction: tx);
throw StateError('rollback');
}),
throwsStateError,
);
expect(await _membershipRowCounts(session), countsBefore);
},
);
test(
'when grant is called for an unknown shared space, '
'then OfflineSyncSpaceNotFoundException is thrown.',
() async {
final missingSpace = const Uuid().v7obj();
await expectLater(
session.offlineSync.spaces.grant(
space: missingSpace,
user: const Uuid().v7obj(),
role: OfflineSyncSpaceRole.readWrite,
),
throwsA(
isA<OfflineSyncSpaceNotFoundException>().having(
(error) => error.space,
'space',
missingSpace,
),
),
);
},
);
});
group('Given a database with a dormant shared space,', () {
late UuidValue space;
setUp(() async {
space = await session.offlineSync.spaces.create();
});
test(
'when grant is called, '
'then the membership is inserted.',
() async {
final user = const Uuid().v7obj();
await session.offlineSync.spaces.grant(
space: space,
user: user,
role: OfflineSyncSpaceRole.readOnly,
);
expect(
await session.offlineSync.spaces.roleOf(user: user, space: space),
OfflineSyncSpaceRole.readOnly,
);
expect(
await session.offlineSync.spaces.members(space),
{user: OfflineSyncSpaceRole.readOnly},
);
},
);
test(
'when grantAll is called with several users, '
'then all memberships are applied.',
() async {
final readOnlyUser = const Uuid().v7obj();
final readWriteUser = const Uuid().v7obj();
await session.offlineSync.spaces.grantAll(space, {
readOnlyUser: OfflineSyncSpaceRole.readOnly,
readWriteUser: OfflineSyncSpaceRole.readWrite,
});
expect(await session.offlineSync.spaces.members(space), {
readOnlyUser: OfflineSyncSpaceRole.readOnly,
readWriteUser: OfflineSyncSpaceRole.readWrite,
});
},
);
test(
'when grant is called inside a transaction that rolls back, '
'then no membership row leaks.',
() async {
final user = const Uuid().v7obj();
final countsBefore = await _membershipRowCounts(session);
await expectLater(
session.db.transaction((tx) async {
await session.offlineSync.spaces.grant(
space: space,
user: user,
role: OfflineSyncSpaceRole.readWrite,
transaction: tx,
);
throw StateError('rollback');
}),
throwsStateError,
);
expect(await _membershipRowCounts(session), countsBefore);
expect(
await session.offlineSync.spaces.roleOf(user: user, space: space),
isNull,
);
},
);
});
group('Given a database with a readOnly member on a shared space,', () {
late UuidValue user;
late UuidValue space;
setUp(() async {
user = const Uuid().v7obj();
space = await session.offlineSync.spaces.create(
grants: {user: OfflineSyncSpaceRole.readOnly},
);
});
test(
'when grant is called again for the same member with readWrite, '
'then the existing membership is updated.',
() async {
await session.offlineSync.spaces.grant(
space: space,
user: user,
role: OfflineSyncSpaceRole.readWrite,
);
expect(
await session.offlineSync.spaces.roleOf(user: user, space: space),
OfflineSyncSpaceRole.readWrite,
);
expect(
await session.offlineSync.spaces.members(space),
{user: OfflineSyncSpaceRole.readWrite},
);
},
);
});
group('Given a database with a shared space that has one readWrite member,', () {
late UuidValue member;
late UuidValue nonMember;
late UuidValue space;
setUp(() async {
member = const Uuid().v7obj();
nonMember = const Uuid().v7obj();
space = await session.offlineSync.spaces.createFor(member);
});
test(
'when revoke is called for the member, '
'then the membership is removed.',
() async {
await session.offlineSync.spaces.revoke(space: space, user: member);
expect(
await session.offlineSync.spaces.roleOf(user: member, space: space),
isNull,
);
expect(await session.offlineSync.spaces.members(space), isEmpty);
},
);
test(
'when revoke is called for a non-member, '
'then it completes without changing the space members.',
() async {
await expectLater(
session.offlineSync.spaces.revoke(space: space, user: nonMember),
completes,
);
expect(
await session.offlineSync.spaces.members(space),
{member: OfflineSyncSpaceRole.readWrite},
);
},
);
test(
'when roleOf is called for stored and missing memberships, '
'then it returns the stored role or null.',
() async {
expect(
await session.offlineSync.spaces.roleOf(user: member, space: space),
OfflineSyncSpaceRole.readWrite,
);
expect(
await session.offlineSync.spaces.roleOf(user: nonMember, space: space),
isNull,
);
},
);
});
group('Given a database with populated and dormant shared spaces,', () {
late UuidValue readOnlyUser;
late UuidValue readWriteUser;
late UuidValue populatedSpace;
late UuidValue dormantSpace;
setUp(() async {
readOnlyUser = const Uuid().v7obj();
readWriteUser = const Uuid().v7obj();
populatedSpace = await session.offlineSync.spaces.create(
grants: {
readOnlyUser: OfflineSyncSpaceRole.readOnly,
readWriteUser: OfflineSyncSpaceRole.readWrite,
},
);
dormantSpace = await session.offlineSync.spaces.create();
});
test(
'when members is called, '
'then explicit membership maps are returned.',
() async {
expect(await session.offlineSync.spaces.members(populatedSpace), {
readOnlyUser: OfflineSyncSpaceRole.readOnly,
readWriteUser: OfflineSyncSpaceRole.readWrite,
});
expect(await session.offlineSync.spaces.members(dormantSpace), isEmpty);
},
);
});
});
}
Future<Map<String, int>> _membershipRowCounts(Session session) async {
return {
OfflineSyncSpace.t.tableName: await OfflineSyncSpace.db.count(session),
OfflineSyncSpaceMember.t.tableName: await OfflineSyncSpaceMember.db.count(session),
};
}

View file

@ -1,265 +0,0 @@
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:serverpod_offline_sync_server/src/generated/protocol.dart' as server;
import 'package:test/test.dart';
import 'test_tools/serverpod_test_tools.dart';
void main() {
withServerpod('[CRDT Scope Membership]', (sessionBuilder, _) {
late Session session;
setUp(() {
session = sessionBuilder.build();
});
group(
'Given a user with personal scope access and readWrite membership in one shared CRDT scope,',
() {
late UuidValue userUuid;
late UuidValue sharedScopeUuid;
setUp(() async {
userUuid = const Uuid().v7obj();
sharedScopeUuid = const Uuid().v7obj();
final sharedScope = await server.CrdtScope.db.insertRow(
session,
server.CrdtScope(uuidScopeId: sharedScopeUuid),
);
await server.CrdtScopeMember.db.insertRow(
session,
server.CrdtScopeMember(
scopeId: sharedScope.id!,
userUuid: userUuid,
role: server.CrdtScopeRole.readWrite,
),
);
});
test(
'when member scopes are resolved, '
'then only the personal scope and the granted shared scope are included.',
() async {
final scopes = await CrdtScopeMembership.memberScopes(session, userUuid);
final expectedScopes = [userUuid, sharedScopeUuid]
..sort((a, b) => a.uuid.compareTo(b.uuid));
expect(scopes, expectedScopes);
},
);
test(
'when member grants are resolved, '
'then the personal and granted shared scopes are returned with their roles.',
() async {
final grants = await CrdtScopeMembership.memberGrants(session, userUuid);
expect(
{for (final grant in grants) grant.uuidScopeId: grant.role},
{
userUuid: CrdtScopeRole.readWrite,
sharedScopeUuid: CrdtScopeRole.readWrite,
},
);
},
);
test(
'when membership is checked for the personal scope, '
'then the user is a member.',
() async {
expect(
await CrdtScopeMembership.isMember(
session,
userUuid: userUuid,
scopeUuid: userUuid,
),
isTrue,
);
},
);
test(
'when membership is checked for the granted shared scope, '
'then the user is a member.',
() async {
expect(
await CrdtScopeMembership.isMember(
session,
userUuid: userUuid,
scopeUuid: sharedScopeUuid,
),
isTrue,
);
},
);
test(
'when the role is resolved for the granted shared scope, '
'then the stored membership role is returned.',
() async {
expect(
await CrdtScopeMembership.roleOf(
session,
userUuid: userUuid,
scopeUuid: sharedScopeUuid,
),
CrdtScopeRole.readWrite,
);
},
);
test(
'when the role is resolved for the personal scope, '
'then the readWrite role is returned.',
() async {
expect(
await CrdtScopeMembership.roleOf(
session,
userUuid: userUuid,
scopeUuid: userUuid,
),
CrdtScopeRole.readWrite,
);
},
);
test(
'when the role is resolved for a scope without a CRDT scope row, '
'then no role is returned.',
() async {
final missingScopeUuid = const Uuid().v7obj();
expect(
await CrdtScopeMembership.roleOf(
session,
userUuid: userUuid,
scopeUuid: missingScopeUuid,
),
isNull,
);
},
);
test(
'when a stray membership row grants readOnly access to the personal scope, '
'then the personal grant remains readWrite.',
() async {
final personalScope = await server.CrdtScope.db.insertRow(
session,
server.CrdtScope(uuidScopeId: userUuid),
);
await server.CrdtScopeMember.db.insertRow(
session,
server.CrdtScopeMember(
scopeId: personalScope.id!,
userUuid: userUuid,
role: server.CrdtScopeRole.readOnly,
),
);
final grants = await CrdtScopeMembership.memberGrants(session, userUuid);
expect(
grants.singleWhere((grant) => grant.uuidScopeId == userUuid).role,
CrdtScopeRole.readWrite,
);
},
);
test(
'when a stored membership row has no role, '
'then resolving grants fails instead of treating it as a grant.',
() async {
final scope = await server.CrdtScope.db.insertRow(
session,
server.CrdtScope(uuidScopeId: const Uuid().v7obj()),
);
final encodedScopeId = ValueEncoder.instance.convert(scope.id);
final encodedUserUuid = ValueEncoder.instance.convert(userUuid);
await session.db.unsafeExecute(
'ALTER TABLE "crdt_scope_members" ALTER COLUMN "role" DROP NOT NULL',
);
await session.db.unsafeExecute(
'INSERT INTO "crdt_scope_members" ("scopeId", "userUuid", "role") '
'VALUES ($encodedScopeId, $encodedUserUuid, NULL)',
);
await expectLater(
CrdtScopeMembership.memberGrants(session, userUuid),
throwsA(isA<Error>()),
);
},
);
},
);
group('Given a user with readOnly membership in a shared CRDT scope,', () {
late UuidValue userUuid;
late UuidValue sharedScopeUuid;
setUp(() async {
userUuid = const Uuid().v7obj();
sharedScopeUuid = const Uuid().v7obj();
final sharedScope = await server.CrdtScope.db.insertRow(
session,
server.CrdtScope(uuidScopeId: sharedScopeUuid),
);
await server.CrdtScopeMember.db.insertRow(
session,
server.CrdtScopeMember(
scopeId: sharedScope.id!,
userUuid: userUuid,
role: server.CrdtScopeRole.readOnly,
),
);
});
test(
'when the role is resolved for that scope, '
'then the readOnly role is returned.',
() async {
expect(
await CrdtScopeMembership.roleOf(
session,
userUuid: userUuid,
scopeUuid: sharedScopeUuid,
),
CrdtScopeRole.readOnly,
);
},
);
});
group('Given a shared CRDT scope without membership for a user,', () {
late UuidValue userUuid;
late UuidValue sharedScopeUuid;
setUp(() async {
userUuid = const Uuid().v7obj();
sharedScopeUuid = const Uuid().v7obj();
await server.CrdtScope.db.insertRow(
session,
server.CrdtScope(uuidScopeId: sharedScopeUuid),
);
});
test(
'when membership is checked for that scope, '
'then the user is not a member.',
() async {
expect(
await CrdtScopeMembership.isMember(
session,
userUuid: userUuid,
scopeUuid: sharedScopeUuid,
),
isFalse,
);
},
);
});
});
}

View file

@ -0,0 +1,274 @@
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:serverpod_offline_sync_server/src/generated/protocol.dart' as server;
import 'package:test/test.dart';
import 'test_tools/serverpod_test_tools.dart';
void main() {
withServerpod('[CRDT Space Membership]', (sessionBuilder, _) {
late Session session;
setUp(() {
session = sessionBuilder.build();
});
group(
'Given a user with personal space access and readWrite membership in one shared CRDT space,',
() {
late UuidValue userUuid;
late UuidValue sharedSpaceUuid;
setUp(() async {
userUuid = const Uuid().v7obj();
sharedSpaceUuid = const Uuid().v7obj();
final sharedSpace = await server.OfflineSyncSpace.db.insertRow(
session,
server.OfflineSyncSpace(uuidSpaceId: sharedSpaceUuid),
);
await server.OfflineSyncSpaceMember.db.insertRow(
session,
server.OfflineSyncSpaceMember(
spaceId: sharedSpace.id!,
userUuid: userUuid,
role: server.OfflineSyncSpaceRole.readWrite,
),
);
});
test(
'when member spaces are resolved, '
'then only the personal space and the granted shared space are included.',
() async {
final spaces = await OfflineSyncSpaceMembership.memberSpaces(
session,
userUuid,
);
final expectedSpaces = [userUuid, sharedSpaceUuid]
..sort((a, b) => a.uuid.compareTo(b.uuid));
expect(spaces, expectedSpaces);
},
);
test(
'when member grants are resolved, '
'then the personal and granted shared spaces are returned with their roles.',
() async {
final grants = await OfflineSyncSpaceMembership.memberGrants(
session,
userUuid,
);
expect(
{for (final grant in grants) grant.uuidSpaceId: grant.role},
{
userUuid: OfflineSyncSpaceRole.readWrite,
sharedSpaceUuid: OfflineSyncSpaceRole.readWrite,
},
);
},
);
test(
'when membership is checked for the personal space, '
'then the user is a member.',
() async {
expect(
await OfflineSyncSpaceMembership.isMember(
session,
userUuid: userUuid,
spaceUuid: userUuid,
),
isTrue,
);
},
);
test(
'when membership is checked for the granted shared space, '
'then the user is a member.',
() async {
expect(
await OfflineSyncSpaceMembership.isMember(
session,
userUuid: userUuid,
spaceUuid: sharedSpaceUuid,
),
isTrue,
);
},
);
test(
'when the role is resolved for the granted shared space, '
'then the stored membership role is returned.',
() async {
expect(
await OfflineSyncSpaceMembership.roleOf(
session,
userUuid: userUuid,
spaceUuid: sharedSpaceUuid,
),
OfflineSyncSpaceRole.readWrite,
);
},
);
test(
'when the role is resolved for the personal space, '
'then the readWrite role is returned.',
() async {
expect(
await OfflineSyncSpaceMembership.roleOf(
session,
userUuid: userUuid,
spaceUuid: userUuid,
),
OfflineSyncSpaceRole.readWrite,
);
},
);
test(
'when the role is resolved for a space without a CRDT space row, '
'then no role is returned.',
() async {
final missingSpaceUuid = const Uuid().v7obj();
expect(
await OfflineSyncSpaceMembership.roleOf(
session,
userUuid: userUuid,
spaceUuid: missingSpaceUuid,
),
isNull,
);
},
);
test(
'when a stray membership row grants readOnly access to the personal space, '
'then the personal grant remains readWrite.',
() async {
final personalSpace = await server.OfflineSyncSpace.db.insertRow(
session,
server.OfflineSyncSpace(uuidSpaceId: userUuid),
);
await server.OfflineSyncSpaceMember.db.insertRow(
session,
server.OfflineSyncSpaceMember(
spaceId: personalSpace.id!,
userUuid: userUuid,
role: server.OfflineSyncSpaceRole.readOnly,
),
);
final grants = await OfflineSyncSpaceMembership.memberGrants(
session,
userUuid,
);
expect(
grants.singleWhere((grant) => grant.uuidSpaceId == userUuid).role,
OfflineSyncSpaceRole.readWrite,
);
},
);
test(
'when a stored membership row has no role, '
'then resolving grants fails instead of treating it as a grant.',
() async {
final space = await server.OfflineSyncSpace.db.insertRow(
session,
server.OfflineSyncSpace(uuidSpaceId: const Uuid().v7obj()),
);
final encodedSpaceId = ValueEncoder.instance.convert(space.id);
final encodedUserUuid = ValueEncoder.instance.convert(userUuid);
await session.db.unsafeExecute(
'ALTER TABLE "offline_sync_space_members" ALTER COLUMN "role" DROP NOT NULL',
);
await session.db.unsafeExecute(
'INSERT INTO "offline_sync_space_members" ("spaceId", "userUuid", "role") '
'VALUES ($encodedSpaceId, $encodedUserUuid, NULL)',
);
await expectLater(
OfflineSyncSpaceMembership.memberGrants(session, userUuid),
throwsA(isA<Error>()),
);
},
);
},
);
group('Given a user with readOnly membership in a shared CRDT space,', () {
late UuidValue userUuid;
late UuidValue sharedSpaceUuid;
setUp(() async {
userUuid = const Uuid().v7obj();
sharedSpaceUuid = const Uuid().v7obj();
final sharedSpace = await server.OfflineSyncSpace.db.insertRow(
session,
server.OfflineSyncSpace(uuidSpaceId: sharedSpaceUuid),
);
await server.OfflineSyncSpaceMember.db.insertRow(
session,
server.OfflineSyncSpaceMember(
spaceId: sharedSpace.id!,
userUuid: userUuid,
role: server.OfflineSyncSpaceRole.readOnly,
),
);
});
test(
'when the role is resolved for that space, '
'then the readOnly role is returned.',
() async {
expect(
await OfflineSyncSpaceMembership.roleOf(
session,
userUuid: userUuid,
spaceUuid: sharedSpaceUuid,
),
OfflineSyncSpaceRole.readOnly,
);
},
);
});
group('Given a shared CRDT space without membership for a user,', () {
late UuidValue userUuid;
late UuidValue sharedSpaceUuid;
setUp(() async {
userUuid = const Uuid().v7obj();
sharedSpaceUuid = const Uuid().v7obj();
await server.OfflineSyncSpace.db.insertRow(
session,
server.OfflineSyncSpace(uuidSpaceId: sharedSpaceUuid),
);
});
test(
'when membership is checked for that space, '
'then the user is not a member.',
() async {
expect(
await OfflineSyncSpaceMembership.isMember(
session,
userUuid: userUuid,
spaceUuid: sharedSpaceUuid,
),
isFalse,
);
},
);
});
});
}

View file

@ -144,7 +144,7 @@ void withServerpod(
}
class TestEndpoints {
late final _CrdtSyncEndpoint crdtSync;
late final _OfflineSyncEndpoint offlineSync;
}
class _InternalTestEndpoints extends TestEndpoints
@ -154,15 +154,15 @@ class _InternalTestEndpoints extends TestEndpoints
_is.SerializationManager serializationManager,
_is.EndpointDispatch endpoints,
) {
crdtSync = _CrdtSyncEndpoint(
offlineSync = _OfflineSyncEndpoint(
endpoints,
serializationManager,
);
}
}
class _CrdtSyncEndpoint {
_CrdtSyncEndpoint(
class _OfflineSyncEndpoint {
_OfflineSyncEndpoint(
this._endpointDispatch,
this._serializationManager,
);
@ -171,23 +171,23 @@ class _CrdtSyncEndpoint {
final _is.SerializationManager _serializationManager;
_ida.Stream<_icw2tu00.CrdtSyncStreamEvent> sync(
_ida.Stream<_icw2tu00.OfflineSyncStreamEvent> sync(
_ist.TestSessionBuilder sessionBuilder, {
required _ida.Stream<_icw2tu00.CrdtSyncStreamEvent> changes,
required _ida.Stream<_icw2tu00.OfflineSyncStreamEvent> changes,
required bool once,
}) {
var _localTestStreamManager =
_ist.TestStreamManager<_icw2tu00.CrdtSyncStreamEvent>();
_ist.TestStreamManager<_icw2tu00.OfflineSyncStreamEvent>();
_ist.callStreamFunctionAndHandleExceptions(
() async {
var _localUniqueSession = (sessionBuilder as _ist.InternalTestSessionBuilder)
.internalBuild(
endpoint: 'crdtSync',
endpoint: 'offlineSync',
method: 'sync',
);
var _localCallContext = await _endpointDispatch.getMethodStreamCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'crdtSync',
endpointPath: 'offlineSync',
methodName: 'sync',
arguments: {'once': once},
requestedInputStreams: ['changes'],