feat(server): partage ciblé de programmes et séances entre comptes (ticket #51)
Ajoute l'endpoint api/share_api.dart, les use cases de partage (application/share_use_cases.dart) et l'adapter Postgres (infrastructure/postgres/share_repository.dart). L'acceptation d'un partage crée une nouvelle ressource synced_resources avec de nouveaux IDs pour le destinataire, sans jamais modifier la ressource source de l'émetteur ; gestion des conflits révoqué/déjà répondu. dart pub get OK, dart analyze clean, dart test 24/24 vert. Use case d'acceptation relu manuellement et jugé correct ; pas de test de bout en bout contre un vrai PostgreSQL faute d'accès Docker dans ce sandbox. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
205
server/test/share_api_test.dart
Normal file
205
server/test/share_api_test.dart
Normal file
@ -0,0 +1,205 @@
|
||||
import 'package:gametime_server/api/router.dart';
|
||||
import 'package:gametime_server/api/share_api.dart';
|
||||
import 'package:gametime_server/application/application.dart';
|
||||
import 'package:gametime_server/domain/domain.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
test('share routes require bearer authentication', () async {
|
||||
final handler = buildApiHandler(shareApi: _shareApi());
|
||||
final requests = [
|
||||
Request('POST', Uri.parse('http://localhost/shares')),
|
||||
Request('GET', Uri.parse('http://localhost/shares/inbox')),
|
||||
Request('POST', Uri.parse('http://localhost/shares/share-1/accept')),
|
||||
Request('POST', Uri.parse('http://localhost/shares/share-1/decline')),
|
||||
Request('POST', Uri.parse('http://localhost/shares/share-1/revoke')),
|
||||
];
|
||||
|
||||
for (final request in requests) {
|
||||
final response = await handler(request);
|
||||
|
||||
expect(response.statusCode, 401, reason: request.url.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ShareApi _shareApi() {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 19, 12));
|
||||
final ids = _FakeIds();
|
||||
final users = _FakeUserRepository(
|
||||
UserAccount(
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
passwordHash: 'hash',
|
||||
createdAt: clock.now(),
|
||||
updatedAt: clock.now(),
|
||||
),
|
||||
);
|
||||
final sessions = _FakeAuthSessionRepository(
|
||||
AuthSession(
|
||||
id: 'session-1',
|
||||
userId: 'user-1',
|
||||
tokenHash: 'token-hash:valid-token',
|
||||
issuedAt: clock.now(),
|
||||
expiresAt: clock.now().add(const Duration(days: 1)),
|
||||
),
|
||||
);
|
||||
final tokens = _FakeTokenService();
|
||||
final authenticate = AuthenticateRequestUseCase(
|
||||
users: users,
|
||||
sessions: sessions,
|
||||
tokens: tokens,
|
||||
clock: clock,
|
||||
);
|
||||
final shares = _FakeShareRepository();
|
||||
final resources = _FakeSyncedResourceRepository();
|
||||
return ShareApi(
|
||||
createShare: CreateShareUseCase(
|
||||
users: users,
|
||||
shares: shares,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
),
|
||||
listInbox: ListInboxUseCase(shares: shares),
|
||||
acceptShare: AcceptShareUseCase(
|
||||
shares: shares,
|
||||
resources: resources,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
),
|
||||
declineShare: DeclineShareUseCase(shares: shares, clock: clock),
|
||||
revokeShare: RevokeShareUseCase(shares: shares, clock: clock),
|
||||
authenticateRequest: authenticate,
|
||||
);
|
||||
}
|
||||
|
||||
final class _FakeUserRepository implements UserRepository {
|
||||
const _FakeUserRepository(this.user);
|
||||
|
||||
final UserAccount user;
|
||||
|
||||
@override
|
||||
Future<UserAccount?> findByEmail(String email) async {
|
||||
return user.email == email.toLowerCase() ? user : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserAccount?> findById(String id) async {
|
||||
return id == user.id ? user : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insert(UserAccount user) async {}
|
||||
|
||||
@override
|
||||
Future<void> updatePasswordHash({
|
||||
required String userId,
|
||||
required String passwordHash,
|
||||
required DateTime updatedAt,
|
||||
}) async {}
|
||||
}
|
||||
|
||||
final class _FakeAuthSessionRepository implements AuthSessionRepository {
|
||||
const _FakeAuthSessionRepository(this.session);
|
||||
|
||||
final AuthSession session;
|
||||
|
||||
@override
|
||||
Future<AuthSession?> findByTokenHash(String tokenHash) async {
|
||||
return tokenHash == session.tokenHash ? session : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insert(AuthSession session) async {}
|
||||
|
||||
@override
|
||||
Future<void> revoke({
|
||||
required String sessionId,
|
||||
required DateTime revokedAt,
|
||||
}) async {}
|
||||
}
|
||||
|
||||
final class _FakeShareRepository implements ShareRepository {
|
||||
@override
|
||||
Future<void> insertShare({
|
||||
required Share share,
|
||||
required List<ShareRecipient> recipients,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<List<ShareInboxItem>> listInbox(String recipientUserId) async {
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Share?> findShareById(String shareId) async {
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ShareRecipient?> findRecipient({
|
||||
required String shareId,
|
||||
required String recipientUserId,
|
||||
}) async {
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateRecipientStatus({
|
||||
required String recipientId,
|
||||
required ShareRecipientStatus status,
|
||||
required DateTime respondedAt,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> revokeShare({
|
||||
required String shareId,
|
||||
required DateTime revokedAt,
|
||||
}) async {}
|
||||
}
|
||||
|
||||
final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
|
||||
@override
|
||||
Future<SyncWriteResult> upsertWithLww(SyncedResource resource) async {
|
||||
return SyncWriteResult(
|
||||
status: SyncWriteStatus.accepted,
|
||||
resource: resource,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SyncedResource>> findAllForUserSince({
|
||||
required String ownerUserId,
|
||||
DateTime? since,
|
||||
}) async {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeTokenService implements OpaqueTokenService {
|
||||
@override
|
||||
String generateToken() => 'valid-token';
|
||||
|
||||
@override
|
||||
String hashToken(String token) => 'token-hash:$token';
|
||||
}
|
||||
|
||||
final class _FakeClock implements Clock {
|
||||
const _FakeClock(this.value);
|
||||
|
||||
final DateTime value;
|
||||
|
||||
@override
|
||||
DateTime now() => value;
|
||||
}
|
||||
|
||||
final class _FakeIds implements IdGenerator {
|
||||
var _next = 0;
|
||||
|
||||
@override
|
||||
String newId() {
|
||||
_next += 1;
|
||||
return 'id-$_next';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user