feat: add secure clubs foundation and membership architecture

This commit is contained in:
Developer 2026-09-06 17:44:56 -04:00
parent 3ca69f9be4
commit 0d8e26750f
10 changed files with 3710 additions and 1 deletions

File diff suppressed because it is too large Load Diff

View File

@ -27,6 +27,88 @@
"order": "ASCENDING" "order": "ASCENDING"
} }
] ]
},
{
"collectionGroup": "clubs",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "visibility",
"order": "ASCENDING"
},
{
"fieldPath": "archivedAt",
"order": "ASCENDING"
},
{
"fieldPath": "memberCount",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "members",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "status",
"order": "ASCENDING"
},
{
"fieldPath": "joinedAt",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "members",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "status",
"order": "ASCENDING"
},
{
"fieldPath": "requestedAt",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "members",
"queryScope": "COLLECTION_GROUP",
"fields": [
{
"fieldPath": "uid",
"order": "ASCENDING"
},
{
"fieldPath": "status",
"order": "ASCENDING"
},
{
"fieldPath": "joinedAt",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "members",
"queryScope": "COLLECTION_GROUP",
"fields": [
{
"fieldPath": "uid",
"order": "ASCENDING"
},
{
"fieldPath": "status",
"order": "ASCENDING"
},
{
"fieldPath": "updatedAt",
"order": "DESCENDING"
}
]
} }
], ],
"fieldOverrides": [] "fieldOverrides": []

View File

@ -5,6 +5,13 @@ service cloud.firestore {
return request.auth != null; return request.auth != null;
} }
function isRegisteredUser() {
return signedIn()
&& (!('firebase' in request.auth.token)
|| !('sign_in_provider' in request.auth.token.firebase)
|| request.auth.token.firebase.sign_in_provider != 'anonymous');
}
function ownsUser(userId) { function ownsUser(userId) {
return signedIn() && request.auth.uid == userId; return signedIn() && request.auth.uid == userId;
} }
@ -637,6 +644,285 @@ service cloud.firestore {
allow delete: if isRouteOwner(); allow delete: if isRouteOwner();
} }
// --- CLUB HANDLE RESERVATIONS ---
match /club_handles/{slug} {
allow list: if false;
allow get: if isRegisteredUser();
allow create: if isRegisteredUser()
&& request.resource.data.slug == slug
&& request.resource.data.ownerUid == request.auth.uid
&& request.resource.data.clubId is string
&& request.resource.data.clubId.size() > 0
// Post-write club document invariant
&& getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)).data.slug == slug
&& getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)).data.ownerUid == request.auth.uid
&& getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)).data.memberCount == 1
&& getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)).data.archivedAt == null
// Post-write owner membership invariant
&& getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)/members/$(request.auth.uid)).data.uid == request.auth.uid
&& getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)/members/$(request.auth.uid)).data.clubId == request.resource.data.clubId
&& getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)/members/$(request.auth.uid)).data.status == 'active'
&& getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)/members/$(request.auth.uid)).data.role == 'owner';
allow delete: if false;
allow update: if false;
}
// --- CLUBS & MEMBERSHIP ---
match /clubs/{clubId} {
function getClubDoc() {
return get(/databases/$(database)/documents/clubs/$(clubId));
}
function isCallerClubOwner() {
return isRegisteredUser() && getClubDoc().data.ownerUid == request.auth.uid;
}
function hasMemberDoc(uid) {
return exists(/databases/$(database)/documents/clubs/$(clubId)/members/$(uid));
}
function memberData(uid) {
return get(/databases/$(database)/documents/clubs/$(clubId)/members/$(uid)).data;
}
function isActiveMember(uid) {
return isRegisteredUser() && hasMemberDoc(uid) && memberData(uid).status == 'active';
}
function isPendingInvite(uid) {
return isRegisteredUser() && hasMemberDoc(uid) && memberData(uid).status == 'invited';
}
function isClubAdmin(uid) {
return isActiveMember(uid) && (memberData(uid).role == 'admin' || memberData(uid).role == 'owner');
}
function isBanned(uid) {
return exists(/databases/$(database)/documents/clubs/$(clubId)/bans/$(uid));
}
function isValidMemberCountIncrement() {
let targetUid = ('lastMembershipChangeUid' in request.resource.data)
? request.resource.data.lastMembershipChangeUid
: request.auth.uid;
let memberDocPath = /databases/$(database)/documents/clubs/$(clubId)/members/$(targetUid);
return (targetUid == request.auth.uid || isClubAdmin(request.auth.uid))
&& request.resource.data.memberCount == resource.data.memberCount + 1
&& getAfter(memberDocPath).data.status == 'active'
&& (!exists(memberDocPath) || get(memberDocPath).data.status != 'active');
}
function isValidMemberCountDecrement() {
let targetUid = ('lastMembershipChangeUid' in request.resource.data)
? request.resource.data.lastMembershipChangeUid
: request.auth.uid;
let memberDocPath = /databases/$(database)/documents/clubs/$(clubId)/members/$(targetUid);
return (targetUid == request.auth.uid || isClubAdmin(request.auth.uid))
&& request.resource.data.memberCount == resource.data.memberCount - 1
&& request.resource.data.memberCount >= 1
&& getAfter(memberDocPath).data.status in ['left', 'removed']
&& exists(memberDocPath)
&& get(memberDocPath).data.status == 'active';
}
// Point-read access to club root:
// Active members, owner, or invited riders evaluating an invite may read private clubs.
// Unlisted and public clubs can be point-read by any registered rider.
allow get: if isRegisteredUser() && (
resource.data.visibility in ['public', 'unlisted'] ||
resource.data.ownerUid == request.auth.uid ||
isActiveMember(request.auth.uid) ||
isPendingInvite(request.auth.uid)
);
// Discovery list query: Safe public discovery or owner's owned clubs query only
allow list: if isRegisteredUser() && (
(resource.data.visibility == 'public' && resource.data.archivedAt == null) ||
(resource.data.ownerUid == request.auth.uid && resource.data.archivedAt == null)
);
// Club creation: Atomic batch/transaction creating club + owner membership + slug reservation
allow create: if isRegisteredUser()
&& request.resource.data.id == clubId
&& request.resource.data.ownerUid == request.auth.uid
&& request.resource.data.memberCount == 1
&& request.resource.data.archivedAt == null
&& request.resource.data.slug is string
&& request.resource.data.slug.size() >= 3
&& request.resource.data.slug.size() <= 30
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.auth.uid)).data.role == 'owner'
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.auth.uid)).data.status == 'active'
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.auth.uid)).data.clubId == clubId
&& getAfter(/databases/$(database)/documents/club_handles/$(request.resource.data.slug)).data.clubId == clubId
&& getAfter(/databases/$(database)/documents/club_handles/$(request.resource.data.slug)).data.ownerUid == request.auth.uid
&& getAfter(/databases/$(database)/documents/club_handles/$(request.resource.data.slug)).data.slug == request.resource.data.slug;
// Club update: Ownership transfer, metadata edit, or member count change
allow update: if isRegisteredUser() && (
// Case 1: Owner transferring ownership atomically (cannot mutate memberCount, slug, createdAt)
(
request.auth.uid == resource.data.ownerUid
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['ownerUid', 'updatedAt'])
&& request.resource.data.ownerUid != resource.data.ownerUid
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.resource.data.ownerUid)).data.role == 'owner'
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.resource.data.ownerUid)).data.status == 'active'
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(resource.data.ownerUid)).data.role != 'owner'
) ||
// Case 2: Owner general metadata update or archiving (slug, ownerUid, memberCount, createdAt immutable)
(
request.auth.uid == resource.data.ownerUid
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'name', 'description', 'visibility', 'admissionPolicy',
'logoUrl', 'bannerUrl', 'homeRegion', 'motorcycleFocus',
'archivedAt', 'updatedAt'
])
&& (!('name' in request.resource.data) || (request.resource.data.name is string && request.resource.data.name.size() >= 3 && request.resource.data.name.size() <= 50))
) ||
// Case 3: Admin editing metadata (cannot change ownerUid, slug, createdAt, archivedAt, visibility, admissionPolicy, memberCount)
(
isClubAdmin(request.auth.uid)
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
'name', 'description', 'logoUrl', 'bannerUrl',
'homeRegion', 'motorcycleFocus', 'updatedAt'
])
&& (!('name' in request.resource.data) || (request.resource.data.name is string && request.resource.data.name.size() >= 3 && request.resource.data.name.size() <= 50))
) ||
// Case 4: Member count increment/decrement by membership transitions ONLY
(
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['memberCount', 'updatedAt', 'lastMembershipChangeUid'])
&& (isValidMemberCountIncrement() || isValidMemberCountDecrement())
)
);
allow delete: if false;
// --- MEMBERS SUBCOLLECTION ---
match /members/{memberUid} {
// Point read: Caller themselves, club management, or peer active member (if not mutually blocked)
allow get: if isRegisteredUser() && (
request.auth.uid == memberUid ||
isCallerClubOwner() ||
isClubAdmin(request.auth.uid) ||
(isActiveMember(request.auth.uid) && resource.data.status == 'active' && !areMutuallyBlocked(request.auth.uid, memberUid))
);
// List roster: Active members or club management only
allow list: if isRegisteredUser() && (
isCallerClubOwner() ||
isClubAdmin(request.auth.uid) ||
(isActiveMember(request.auth.uid) && resource.data.status == 'active')
);
// Create membership doc: Owner creation, open join, request to join, or admin invitation
allow create: if isRegisteredUser() && !isBanned(memberUid) && (
// Owner creating self doc during club creation
(
request.auth.uid == memberUid
&& request.resource.data.role == 'owner'
&& request.resource.data.status == 'active'
&& request.resource.data.clubId == clubId
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.ownerUid == request.auth.uid
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == 1
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.archivedAt == null
&& getAfter(/databases/$(database)/documents/club_handles/$(getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.slug)).data.clubId == clubId
&& getAfter(/databases/$(database)/documents/club_handles/$(getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.slug)).data.ownerUid == request.auth.uid
) ||
// Rider joining open club
(
request.auth.uid == memberUid
&& get(/databases/$(database)/documents/clubs/$(clubId)).data.admissionPolicy == 'open'
&& request.resource.data.role == 'member'
&& request.resource.data.status == 'active'
&& !areMutuallyBlocked(request.auth.uid, get(/databases/$(database)/documents/clubs/$(clubId)).data.ownerUid)
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount + 1
) ||
// Rider requesting to join
(
request.auth.uid == memberUid
&& get(/databases/$(database)/documents/clubs/$(clubId)).data.admissionPolicy == 'request'
&& request.resource.data.role == 'member'
&& request.resource.data.status == 'requested'
&& !areMutuallyBlocked(request.auth.uid, get(/databases/$(database)/documents/clubs/$(clubId)).data.ownerUid)
) ||
// Admin / Owner inviting rider
(
isClubAdmin(request.auth.uid)
&& request.resource.data.status == 'invited'
&& request.resource.data.role == 'member'
&& !areMutuallyBlocked(request.auth.uid, memberUid)
)
);
// Transitions
allow update: if isRegisteredUser() && (
// Invitee accepts invite (invited -> active)
(
request.auth.uid == memberUid
&& resource.data.status == 'invited'
&& request.resource.data.status == 'active'
&& request.resource.data.role == resource.data.role
&& !isBanned(memberUid)
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount + 1
) ||
// Invitee declines invite (invited -> declined)
(request.auth.uid == memberUid && resource.data.status == 'invited' && request.resource.data.status == 'declined' && request.resource.data.role == resource.data.role) ||
// Member leaves (active -> left), non-owner only
(
request.auth.uid == memberUid
&& resource.data.status == 'active'
&& request.resource.data.status == 'left'
&& resource.data.role != 'owner'
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount - 1
) ||
// Requestor cancels request (requested -> left)
(request.auth.uid == memberUid && resource.data.status == 'requested' && request.resource.data.status == 'left') ||
// Admin/Owner approves request (requested -> active)
(
isClubAdmin(request.auth.uid)
&& resource.data.status == 'requested'
&& request.resource.data.status == 'active'
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount + 1
) ||
// Admin/Owner declines request (requested -> declined)
(isClubAdmin(request.auth.uid) && resource.data.status == 'requested' && request.resource.data.status == 'declined') ||
// Admin/Owner removes active member (active -> removed), hierarchy enforced
(
isClubAdmin(request.auth.uid)
&& resource.data.status == 'active'
&& request.resource.data.status == 'removed'
&& resource.data.role != 'owner'
&& (isCallerClubOwner() || resource.data.role != 'admin')
&& getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount - 1
) ||
// Admin/Owner removes non-active member (requested/invited -> removed)
(
isClubAdmin(request.auth.uid)
&& resource.data.status != 'active'
&& request.resource.data.status == 'removed'
&& resource.data.role != 'owner'
&& (isCallerClubOwner() || resource.data.role != 'admin')
) ||
// Owner updates role (role update or ownership transfer)
(isCallerClubOwner() && request.resource.data.status == resource.data.status) ||
// Display rank update by admin/owner
(isClubAdmin(request.auth.uid) && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['displayRank', 'updatedAt']))
);
allow delete: if false;
}
// --- ADMINISTRATIVE BANS SUBCOLLECTION ---
match /bans/{bannedUid} {
allow read, write: if isRegisteredUser() && isClubAdmin(request.auth.uid);
}
}
// --- COLLECTION GROUP: MEMBERS ---
// Scoped strictly so caller can list only documents belonging to their own UID.
match /{path=**}/members/{memberUid} {
allow list: if isRegisteredUser() && resource.data.uid == request.auth.uid;
}
match /{document=**} { match /{document=**} {
allow read, write: if false; allow read, write: if false;
} }

View File

@ -0,0 +1,607 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/foundation.dart';
/// Visibility setting governing discoverability and access to a Club.
enum ClubVisibility {
public,
unlisted,
private;
String get label {
switch (this) {
case ClubVisibility.public:
return 'Public';
case ClubVisibility.unlisted:
return 'Unlisted';
case ClubVisibility.private:
return 'Private';
}
}
static ClubVisibility fromString(String? value) {
switch (value) {
case 'unlisted':
return ClubVisibility.unlisted;
case 'private':
return ClubVisibility.private;
case 'public':
default:
return ClubVisibility.public;
}
}
}
/// Admission policy governing onboarding into a Club.
enum ClubAdmissionPolicy {
open,
request,
inviteOnly;
String get label {
switch (this) {
case ClubAdmissionPolicy.open:
return 'Open to Join';
case ClubAdmissionPolicy.request:
return 'Request to Join';
case ClubAdmissionPolicy.inviteOnly:
return 'Invite Only';
}
}
static ClubAdmissionPolicy fromString(String? value) {
switch (value) {
case 'open':
return ClubAdmissionPolicy.open;
case 'inviteOnly':
return ClubAdmissionPolicy.inviteOnly;
case 'request':
default:
return ClubAdmissionPolicy.request;
}
}
}
/// Security permission role within a Club.
enum ClubRole {
owner,
admin,
member;
String get label {
switch (this) {
case ClubRole.owner:
return 'Owner';
case ClubRole.admin:
return 'Admin';
case ClubRole.member:
return 'Member';
}
}
bool isAtLeast(ClubRole requiredRole) {
if (this == ClubRole.owner) return true;
if (this == ClubRole.admin) {
return requiredRole == ClubRole.admin || requiredRole == ClubRole.member;
}
return requiredRole == ClubRole.member;
}
static ClubRole fromString(String? value) {
switch (value) {
case 'owner':
return ClubRole.owner;
case 'admin':
return ClubRole.admin;
case 'member':
default:
return ClubRole.member;
}
}
}
/// Lifecycle status of an individual Club member.
enum ClubMemberStatus {
invited,
requested,
active,
declined,
left,
removed;
String get label {
switch (this) {
case ClubMemberStatus.invited:
return 'Invited';
case ClubMemberStatus.requested:
return 'Requested';
case ClubMemberStatus.active:
return 'Active';
case ClubMemberStatus.declined:
return 'Declined';
case ClubMemberStatus.left:
return 'Left';
case ClubMemberStatus.removed:
return 'Removed';
}
}
bool get isActive => this == ClubMemberStatus.active;
bool get isPending => this == ClubMemberStatus.invited || this == ClubMemberStatus.requested;
bool get isHistorical => this == ClubMemberStatus.declined || this == ClubMemberStatus.left || this == ClubMemberStatus.removed;
static ClubMemberStatus fromString(String? value) {
switch (value) {
case 'invited':
return ClubMemberStatus.invited;
case 'requested':
return ClubMemberStatus.requested;
case 'active':
return ClubMemberStatus.active;
case 'declined':
return ClubMemberStatus.declined;
case 'left':
return ClubMemberStatus.left;
case 'removed':
return ClubMemberStatus.removed;
default:
return ClubMemberStatus.declined;
}
}
}
DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
if (value is Timestamp) return value.toDate();
if (value is DateTime) return value;
if (value is String) return DateTime.tryParse(value);
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value);
return null;
}
dynamic _dateTimeToFirestore(DateTime? dt) {
if (dt == null) return null;
return Timestamp.fromDate(dt);
}
/// Core MotorSignal Club model.
@immutable
class Club {
final String id;
final String name;
final String slug;
final String description;
final ClubVisibility visibility;
final ClubAdmissionPolicy admissionPolicy;
final String ownerUid;
final int memberCount;
final DateTime createdAt;
final DateTime updatedAt;
final DateTime? archivedAt;
final String? logoUrl;
final String? bannerUrl;
final String? homeRegion;
final String? motorcycleFocus;
const Club({
required this.id,
required this.name,
required this.slug,
this.description = '',
this.visibility = ClubVisibility.public,
this.admissionPolicy = ClubAdmissionPolicy.request,
required this.ownerUid,
this.memberCount = 1,
required this.createdAt,
required this.updatedAt,
this.archivedAt,
this.logoUrl,
this.bannerUrl,
this.homeRegion,
this.motorcycleFocus,
});
bool get isArchived => archivedAt != null;
Map<String, dynamic> toFirestore() => {
'id': id,
'name': name,
'slug': slug,
'description': description,
'visibility': visibility.name,
'admissionPolicy': admissionPolicy.name,
'ownerUid': ownerUid,
'memberCount': memberCount,
'createdAt': _dateTimeToFirestore(createdAt),
'updatedAt': _dateTimeToFirestore(updatedAt),
if (archivedAt != null) 'archivedAt': _dateTimeToFirestore(archivedAt),
if (logoUrl != null) 'logoUrl': logoUrl,
if (bannerUrl != null) 'bannerUrl': bannerUrl,
if (homeRegion != null) 'homeRegion': homeRegion,
if (motorcycleFocus != null) 'motorcycleFocus': motorcycleFocus,
};
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'slug': slug,
'description': description,
'visibility': visibility.name,
'admissionPolicy': admissionPolicy.name,
'ownerUid': ownerUid,
'memberCount': memberCount,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
if (archivedAt != null) 'archivedAt': archivedAt!.toIso8601String(),
if (logoUrl != null) 'logoUrl': logoUrl,
if (bannerUrl != null) 'bannerUrl': bannerUrl,
if (homeRegion != null) 'homeRegion': homeRegion,
if (motorcycleFocus != null) 'motorcycleFocus': motorcycleFocus,
};
factory Club.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>? ?? {};
return Club.fromJson(data, idOverride: doc.id);
}
factory Club.fromJson(Map<String, dynamic> json, {String? idOverride}) {
final created = _parseDateTime(json['createdAt']) ?? DateTime.now();
final updated = _parseDateTime(json['updatedAt']) ?? created;
final archived = _parseDateTime(json['archivedAt']);
return Club(
id: idOverride ?? json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
slug: (json['slug'] as String? ?? '').toLowerCase().trim(),
description: json['description'] as String? ?? '',
visibility: ClubVisibility.fromString(json['visibility'] as String?),
admissionPolicy: ClubAdmissionPolicy.fromString(json['admissionPolicy'] as String?),
ownerUid: json['ownerUid'] as String? ?? '',
memberCount: (json['memberCount'] as num?)?.toInt() ?? 1,
createdAt: created,
updatedAt: updated,
archivedAt: archived,
logoUrl: json['logoUrl'] as String?,
bannerUrl: json['bannerUrl'] as String?,
homeRegion: json['homeRegion'] as String?,
motorcycleFocus: json['motorcycleFocus'] as String?,
);
}
Club copyWith({
String? id,
String? name,
String? slug,
String? description,
ClubVisibility? visibility,
ClubAdmissionPolicy? admissionPolicy,
String? ownerUid,
int? memberCount,
DateTime? createdAt,
DateTime? updatedAt,
DateTime? archivedAt,
String? logoUrl,
String? bannerUrl,
String? homeRegion,
String? motorcycleFocus,
bool clearArchivedAt = false,
}) {
return Club(
id: id ?? this.id,
name: name ?? this.name,
slug: slug ?? this.slug,
description: description ?? this.description,
visibility: visibility ?? this.visibility,
admissionPolicy: admissionPolicy ?? this.admissionPolicy,
ownerUid: ownerUid ?? this.ownerUid,
memberCount: memberCount ?? this.memberCount,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
archivedAt: clearArchivedAt ? null : (archivedAt ?? this.archivedAt),
logoUrl: logoUrl ?? this.logoUrl,
bannerUrl: bannerUrl ?? this.bannerUrl,
homeRegion: homeRegion ?? this.homeRegion,
motorcycleFocus: motorcycleFocus ?? this.motorcycleFocus,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Club &&
runtimeType == other.runtimeType &&
id == other.id &&
name == other.name &&
slug == other.slug &&
description == other.description &&
visibility == other.visibility &&
admissionPolicy == other.admissionPolicy &&
ownerUid == other.ownerUid &&
memberCount == other.memberCount &&
createdAt == other.createdAt &&
updatedAt == other.updatedAt &&
archivedAt == other.archivedAt &&
logoUrl == other.logoUrl &&
bannerUrl == other.bannerUrl &&
homeRegion == other.homeRegion &&
motorcycleFocus == other.motorcycleFocus;
@override
int get hashCode =>
id.hashCode ^
name.hashCode ^
slug.hashCode ^
description.hashCode ^
visibility.hashCode ^
admissionPolicy.hashCode ^
ownerUid.hashCode ^
memberCount.hashCode ^
createdAt.hashCode ^
updatedAt.hashCode ^
archivedAt.hashCode ^
logoUrl.hashCode ^
bannerUrl.hashCode ^
homeRegion.hashCode ^
motorcycleFocus.hashCode;
}
/// Lean ClubMember document maintaining organizational state.
/// Canonical rider identity remains in public_profiles/{uid}.
@immutable
class ClubMember {
final String uid;
final String clubId;
final ClubMemberStatus status;
final ClubRole role;
final String? displayRank;
final DateTime? joinedAt;
final DateTime? requestedAt;
final String? invitedByUid;
final DateTime updatedAt;
final DateTime? removedAt;
const ClubMember({
required this.uid,
required this.clubId,
required this.status,
required this.role,
this.displayRank,
this.joinedAt,
this.requestedAt,
this.invitedByUid,
required this.updatedAt,
this.removedAt,
});
bool get isActive => status == ClubMemberStatus.active;
bool get isOwner => role == ClubRole.owner && isActive;
bool get isAdmin => (role == ClubRole.admin || role == ClubRole.owner) && isActive;
Map<String, dynamic> toFirestore() => {
'uid': uid,
'clubId': clubId,
'status': status.name,
'role': role.name,
if (displayRank != null) 'displayRank': displayRank,
if (joinedAt != null) 'joinedAt': _dateTimeToFirestore(joinedAt),
if (requestedAt != null) 'requestedAt': _dateTimeToFirestore(requestedAt),
if (invitedByUid != null) 'invitedByUid': invitedByUid,
'updatedAt': _dateTimeToFirestore(updatedAt),
if (removedAt != null) 'removedAt': _dateTimeToFirestore(removedAt),
};
Map<String, dynamic> toJson() => {
'uid': uid,
'clubId': clubId,
'status': status.name,
'role': role.name,
if (displayRank != null) 'displayRank': displayRank,
if (joinedAt != null) 'joinedAt': joinedAt!.toIso8601String(),
if (requestedAt != null) 'requestedAt': requestedAt!.toIso8601String(),
if (invitedByUid != null) 'invitedByUid': invitedByUid,
'updatedAt': updatedAt.toIso8601String(),
if (removedAt != null) 'removedAt': removedAt!.toIso8601String(),
};
factory ClubMember.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>? ?? {};
return ClubMember.fromJson(data, uidOverride: doc.id);
}
factory ClubMember.fromJson(Map<String, dynamic> json, {String? uidOverride}) {
final updated = _parseDateTime(json['updatedAt']) ?? DateTime.now();
return ClubMember(
uid: uidOverride ?? json['uid'] as String? ?? '',
clubId: json['clubId'] as String? ?? '',
status: ClubMemberStatus.fromString(json['status'] as String?),
role: ClubRole.fromString(json['role'] as String?),
displayRank: json['displayRank'] as String?,
joinedAt: _parseDateTime(json['joinedAt']),
requestedAt: _parseDateTime(json['requestedAt']),
invitedByUid: json['invitedByUid'] as String?,
updatedAt: updated,
removedAt: _parseDateTime(json['removedAt']),
);
}
ClubMember copyWith({
String? uid,
String? clubId,
ClubMemberStatus? status,
ClubRole? role,
String? displayRank,
DateTime? joinedAt,
DateTime? requestedAt,
String? invitedByUid,
DateTime? updatedAt,
DateTime? removedAt,
bool clearDisplayRank = false,
}) {
return ClubMember(
uid: uid ?? this.uid,
clubId: clubId ?? this.clubId,
status: status ?? this.status,
role: role ?? this.role,
displayRank: clearDisplayRank ? null : (displayRank ?? this.displayRank),
joinedAt: joinedAt ?? this.joinedAt,
requestedAt: requestedAt ?? this.requestedAt,
invitedByUid: invitedByUid ?? this.invitedByUid,
updatedAt: updatedAt ?? this.updatedAt,
removedAt: removedAt ?? this.removedAt,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ClubMember &&
runtimeType == other.runtimeType &&
uid == other.uid &&
clubId == other.clubId &&
status == other.status &&
role == other.role &&
displayRank == other.displayRank &&
joinedAt == other.joinedAt &&
requestedAt == other.requestedAt &&
invitedByUid == other.invitedByUid &&
updatedAt == other.updatedAt &&
removedAt == other.removedAt;
@override
int get hashCode =>
uid.hashCode ^
clubId.hashCode ^
status.hashCode ^
role.hashCode ^
displayRank.hashCode ^
joinedAt.hashCode ^
requestedAt.hashCode ^
invitedByUid.hashCode ^
updatedAt.hashCode ^
removedAt.hashCode;
}
/// Administrative ban document stored at clubs/{clubId}/bans/{uid}.
/// Private to club leadership; never exposed on regular member roster.
@immutable
class ClubBan {
final String uid;
final String clubId;
final String bannedByUid;
final DateTime bannedAt;
final String? reason;
const ClubBan({
required this.uid,
required this.clubId,
required this.bannedByUid,
required this.bannedAt,
this.reason,
});
Map<String, dynamic> toFirestore() => {
'uid': uid,
'clubId': clubId,
'bannedByUid': bannedByUid,
'bannedAt': _dateTimeToFirestore(bannedAt),
if (reason != null) 'reason': reason,
};
Map<String, dynamic> toJson() => {
'uid': uid,
'clubId': clubId,
'bannedByUid': bannedByUid,
'bannedAt': bannedAt.toIso8601String(),
if (reason != null) 'reason': reason,
};
factory ClubBan.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>? ?? {};
return ClubBan.fromJson(data, uidOverride: doc.id);
}
factory ClubBan.fromJson(Map<String, dynamic> json, {String? uidOverride}) {
return ClubBan(
uid: uidOverride ?? json['uid'] as String? ?? '',
clubId: json['clubId'] as String? ?? '',
bannedByUid: json['bannedByUid'] as String? ?? '',
bannedAt: _parseDateTime(json['bannedAt']) ?? DateTime.now(),
reason: json['reason'] as String?,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ClubBan &&
runtimeType == other.runtimeType &&
uid == other.uid &&
clubId == other.clubId &&
bannedByUid == other.bannedByUid &&
bannedAt == other.bannedAt &&
reason == other.reason;
@override
int get hashCode =>
uid.hashCode ^
clubId.hashCode ^
bannedByUid.hashCode ^
bannedAt.hashCode ^
reason.hashCode;
}
/// Global slug reservation document at club_handles/{slug}.
@immutable
class ClubHandle {
final String slug;
final String clubId;
final String ownerUid;
final DateTime createdAt;
const ClubHandle({
required this.slug,
required this.clubId,
required this.ownerUid,
required this.createdAt,
});
Map<String, dynamic> toFirestore() => {
'slug': slug,
'clubId': clubId,
'ownerUid': ownerUid,
'createdAt': _dateTimeToFirestore(createdAt),
};
Map<String, dynamic> toJson() => {
'slug': slug,
'clubId': clubId,
'ownerUid': ownerUid,
'createdAt': createdAt.toIso8601String(),
};
factory ClubHandle.fromJson(Map<String, dynamic> json, {String? slugOverride}) {
return ClubHandle(
slug: slugOverride ?? json['slug'] as String? ?? '',
clubId: json['clubId'] as String? ?? '',
ownerUid: json['ownerUid'] as String? ?? '',
createdAt: _parseDateTime(json['createdAt']) ?? DateTime.now(),
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ClubHandle &&
runtimeType == other.runtimeType &&
slug == other.slug &&
clubId == other.clubId &&
ownerUid == other.ownerUid &&
createdAt == other.createdAt;
@override
int get hashCode =>
slug.hashCode ^
clubId.hashCode ^
ownerUid.hashCode ^
createdAt.hashCode;
}

View File

@ -0,0 +1,59 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_providers.dart';
import 'club_models.dart';
import 'club_service.dart';
/// Provider for ClubService.
final clubServiceProvider = Provider<ClubService>((ref) {
return ClubService();
});
/// Stream of the active user's active Club memberships.
/// Gated to registered, non-anonymous users.
final myClubsProvider = StreamProvider<List<ClubMember>>((ref) {
final user = ref.watch(currentUserProvider);
if (user == null || user.isAnonymous) {
return Stream.value(const []);
}
final service = ref.watch(clubServiceProvider);
return service.myActiveClubsStream(user.uid);
});
/// Stream of incoming club invitations for the active user.
/// Gated to registered, non-anonymous users.
final myClubInvitesProvider = StreamProvider<List<ClubMember>>((ref) {
final user = ref.watch(currentUserProvider);
if (user == null || user.isAnonymous) {
return Stream.value(const []);
}
final service = ref.watch(clubServiceProvider);
return service.myInvitationsStream(user.uid);
});
/// Realtime stream of a specific Club's root document.
final clubDetailProvider = StreamProvider.family<Club?, String>((ref, clubId) {
final service = ref.watch(clubServiceProvider);
return service.clubStream(clubId);
});
/// Realtime stream of the active caller's membership document within a specific Club.
final currentMemberProvider = StreamProvider.family<ClubMember?, String>((ref, clubId) {
final user = ref.watch(currentUserProvider);
if (user == null || user.isAnonymous) {
return Stream.value(null);
}
final service = ref.watch(clubServiceProvider);
return service.memberStream(clubId, user.uid);
});
/// Realtime stream of active members for a specific Club (paginated limit 50).
final clubMembersProvider = StreamProvider.family<List<ClubMember>, String>((ref, clubId) {
final service = ref.watch(clubServiceProvider);
return service.activeMembersStream(clubId);
});
/// Realtime stream of pending join requests for a specific Club (leadership only).
final pendingClubRequestsProvider = StreamProvider.family<List<ClubMember>, String>((ref, clubId) {
final service = ref.watch(clubServiceProvider);
return service.pendingRequestsStream(clubId);
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,102 @@
import '../social/handle_validator.dart';
/// Result of a Club slug validation check.
class ClubSlugValidationResult {
final bool isValid;
final String? error;
final String? normalizedSlug;
const ClubSlugValidationResult._({
required this.isValid,
this.error,
this.normalizedSlug,
});
factory ClubSlugValidationResult.valid(String normalized) =>
ClubSlugValidationResult._(isValid: true, normalizedSlug: normalized);
factory ClubSlugValidationResult.invalid(String error) =>
ClubSlugValidationResult._(isValid: false, error: error);
}
/// Normalization and validation logic for MotorSignal Club slugs.
class ClubSlugValidator {
static const int minLength = 3;
static const int maxLength = 30;
static final RegExp _formatRegex = RegExp(r'^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$');
/// Reserved words specific to clubs, chapters, and platform organizations.
static const Set<String> clubReservedWords = {
'club',
'clubs',
'motorcycle',
'motorcycles',
'mc',
'chapter',
'chapters',
'national',
'international',
'official',
'rally',
'groupride',
'grouprides',
'event',
'events',
'all',
'global',
'feed',
'roster',
'members',
'settings',
'admin',
};
/// Normalizes a raw slug: trims whitespace and lowercases.
static String normalize(String rawSlug) => rawSlug.trim().toLowerCase();
/// Validates a candidate slug format and checks against reserved words.
static ClubSlugValidationResult validate(String rawSlug) {
final normalized = normalize(rawSlug);
if (normalized.isEmpty) {
return ClubSlugValidationResult.invalid('Club slug cannot be empty');
}
if (normalized.length < minLength) {
return ClubSlugValidationResult.invalid(
'Club slug must be at least $minLength characters',
);
}
if (normalized.length > maxLength) {
return ClubSlugValidationResult.invalid(
'Club slug cannot exceed $maxLength characters',
);
}
if (normalized.contains('--')) {
return ClubSlugValidationResult.invalid(
'Club slug cannot contain consecutive hyphens',
);
}
if (!_formatRegex.hasMatch(normalized)) {
return ClubSlugValidationResult.invalid(
'Club slug must start and end with a letter or number, and contain only lowercase letters, numbers, and hyphens',
);
}
// Check against standard platform handles (admin, support, motorsignal, etc.)
if (HandleValidator.reservedHandles.contains(normalized)) {
return ClubSlugValidationResult.invalid('This club slug is reserved');
}
// Check against club-specific reserved words
if (clubReservedWords.contains(normalized)) {
return ClubSlugValidationResult.invalid('This club slug is reserved');
}
return ClubSlugValidationResult.valid(normalized);
}
}

247
test/club_models_test.dart Normal file
View File

@ -0,0 +1,247 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/services/clubs/club_models.dart';
void main() {
group('Club Enums', () {
test('ClubVisibility labels and parsing', () {
expect(ClubVisibility.public.label, equals('Public'));
expect(ClubVisibility.unlisted.label, equals('Unlisted'));
expect(ClubVisibility.private.label, equals('Private'));
expect(ClubVisibility.fromString('public'), equals(ClubVisibility.public));
expect(ClubVisibility.fromString('unlisted'), equals(ClubVisibility.unlisted));
expect(ClubVisibility.fromString('private'), equals(ClubVisibility.private));
expect(ClubVisibility.fromString('unknown'), equals(ClubVisibility.public));
expect(ClubVisibility.fromString(null), equals(ClubVisibility.public));
});
test('ClubAdmissionPolicy labels and parsing', () {
expect(ClubAdmissionPolicy.open.label, equals('Open to Join'));
expect(ClubAdmissionPolicy.request.label, equals('Request to Join'));
expect(ClubAdmissionPolicy.inviteOnly.label, equals('Invite Only'));
expect(ClubAdmissionPolicy.fromString('open'), equals(ClubAdmissionPolicy.open));
expect(ClubAdmissionPolicy.fromString('request'), equals(ClubAdmissionPolicy.request));
expect(ClubAdmissionPolicy.fromString('inviteOnly'), equals(ClubAdmissionPolicy.inviteOnly));
expect(ClubAdmissionPolicy.fromString('other'), equals(ClubAdmissionPolicy.request));
expect(ClubAdmissionPolicy.fromString(null), equals(ClubAdmissionPolicy.request));
});
test('ClubRole labels, rank hierarchy, and parsing', () {
expect(ClubRole.owner.label, equals('Owner'));
expect(ClubRole.admin.label, equals('Admin'));
expect(ClubRole.member.label, equals('Member'));
// Hierarchy
expect(ClubRole.owner.isAtLeast(ClubRole.owner), isTrue);
expect(ClubRole.owner.isAtLeast(ClubRole.admin), isTrue);
expect(ClubRole.owner.isAtLeast(ClubRole.member), isTrue);
expect(ClubRole.admin.isAtLeast(ClubRole.owner), isFalse);
expect(ClubRole.admin.isAtLeast(ClubRole.admin), isTrue);
expect(ClubRole.admin.isAtLeast(ClubRole.member), isTrue);
expect(ClubRole.member.isAtLeast(ClubRole.owner), isFalse);
expect(ClubRole.member.isAtLeast(ClubRole.admin), isFalse);
expect(ClubRole.member.isAtLeast(ClubRole.member), isTrue);
expect(ClubRole.fromString('owner'), equals(ClubRole.owner));
expect(ClubRole.fromString('admin'), equals(ClubRole.admin));
expect(ClubRole.fromString('member'), equals(ClubRole.member));
expect(ClubRole.fromString(null), equals(ClubRole.member));
});
test('ClubMemberStatus classification and parsing', () {
expect(ClubMemberStatus.active.isActive, isTrue);
expect(ClubMemberStatus.invited.isActive, isFalse);
expect(ClubMemberStatus.requested.isActive, isFalse);
expect(ClubMemberStatus.invited.isPending, isTrue);
expect(ClubMemberStatus.requested.isPending, isTrue);
expect(ClubMemberStatus.active.isPending, isFalse);
expect(ClubMemberStatus.declined.isHistorical, isTrue);
expect(ClubMemberStatus.left.isHistorical, isTrue);
expect(ClubMemberStatus.removed.isHistorical, isTrue);
expect(ClubMemberStatus.active.isHistorical, isFalse);
expect(ClubMemberStatus.fromString('active'), equals(ClubMemberStatus.active));
expect(ClubMemberStatus.fromString('invited'), equals(ClubMemberStatus.invited));
expect(ClubMemberStatus.fromString('requested'), equals(ClubMemberStatus.requested));
expect(ClubMemberStatus.fromString('left'), equals(ClubMemberStatus.left));
expect(ClubMemberStatus.fromString('removed'), equals(ClubMemberStatus.removed));
expect(ClubMemberStatus.fromString('declined'), equals(ClubMemberStatus.declined));
expect(ClubMemberStatus.fromString(null), equals(ClubMemberStatus.declined));
});
});
group('Club Model', () {
final now = DateTime(2026, 9, 6, 12, 0, 0);
test('full serialization roundtrip (JSON and Firestore)', () {
final club = Club(
id: 'club_1',
name: 'Bay Area Sport Riders',
slug: 'bay-area-sport-riders',
description: 'Twisties and canyon carving',
visibility: ClubVisibility.public,
admissionPolicy: ClubAdmissionPolicy.request,
ownerUid: 'user_alice',
memberCount: 24,
createdAt: now,
updatedAt: now,
archivedAt: null,
logoUrl: 'https://example.com/logo.png',
bannerUrl: 'https://example.com/banner.png',
homeRegion: 'San Francisco Bay Area',
motorcycleFocus: 'Sport / Canyon',
);
final json = club.toJson();
final fromJson = Club.fromJson(json);
expect(fromJson.id, equals(club.id));
expect(fromJson.name, equals(club.name));
expect(fromJson.slug, equals(club.slug));
expect(fromJson.description, equals(club.description));
expect(fromJson.visibility, equals(club.visibility));
expect(fromJson.admissionPolicy, equals(club.admissionPolicy));
expect(fromJson.ownerUid, equals(club.ownerUid));
expect(fromJson.memberCount, equals(club.memberCount));
expect(fromJson.createdAt, equals(club.createdAt));
expect(fromJson.updatedAt, equals(club.updatedAt));
expect(fromJson.archivedAt, isNull);
expect(fromJson.isArchived, isFalse);
expect(fromJson.logoUrl, equals(club.logoUrl));
expect(fromJson.bannerUrl, equals(club.bannerUrl));
expect(fromJson.homeRegion, equals(club.homeRegion));
expect(fromJson.motorcycleFocus, equals(club.motorcycleFocus));
// Firestore map serialization
final fsMap = club.toFirestore();
expect(fsMap['createdAt'], isA<Timestamp>());
expect(fsMap['updatedAt'], isA<Timestamp>());
// Deserializing from map with Timestamps
final fromFs = Club.fromJson(fsMap);
expect(fromFs.name, equals(club.name));
expect(fromFs.createdAt, equals(club.createdAt));
});
test('copyWith handles updates and null clearing', () {
final club = Club(
id: 'club_1',
name: 'Alpha Club',
slug: 'alpha-club',
ownerUid: 'alice',
createdAt: now,
updatedAt: now,
archivedAt: now,
);
expect(club.isArchived, isTrue);
final unarchived = club.copyWith(clearArchivedAt: true);
expect(unarchived.archivedAt, isNull);
expect(unarchived.isArchived, isFalse);
final updatedName = club.copyWith(name: 'Beta Club');
expect(updatedName.name, equals('Beta Club'));
expect(updatedName.slug, equals('alpha-club'));
});
});
group('ClubMember Model', () {
final now = DateTime(2026, 9, 6, 12, 0, 0);
test('lean serialization roundtrip without duplicated rider profile identity', () {
final member = ClubMember(
uid: 'user_bob',
clubId: 'club_1',
status: ClubMemberStatus.active,
role: ClubRole.admin,
displayRank: 'Road Captain',
joinedAt: now,
requestedAt: now.subtract(const Duration(days: 1)),
invitedByUid: 'user_alice',
updatedAt: now,
);
expect(member.isActive, isTrue);
expect(member.isAdmin, isTrue);
expect(member.isOwner, isFalse);
final json = member.toJson();
// Ensure no identity duplication
expect(json.containsKey('riderHandleSnapshot'), isFalse);
expect(json.containsKey('riderDisplayNameSnapshot'), isFalse);
expect(json.containsKey('riderAvatarPreset'), isFalse);
expect(json.containsKey('riderAvatarUrl'), isFalse);
final fromJson = ClubMember.fromJson(json);
expect(fromJson.uid, equals(member.uid));
expect(fromJson.clubId, equals(member.clubId));
expect(fromJson.status, equals(member.status));
expect(fromJson.role, equals(member.role));
expect(fromJson.displayRank, equals('Road Captain'));
expect(fromJson.invitedByUid, equals('user_alice'));
expect(fromJson.joinedAt, equals(member.joinedAt));
});
test('copyWith handles displayRank clearing', () {
final member = ClubMember(
uid: 'user_bob',
clubId: 'club_1',
status: ClubMemberStatus.active,
role: ClubRole.member,
displayRank: 'Tail Gunner',
updatedAt: now,
);
final cleared = member.copyWith(clearDisplayRank: true);
expect(cleared.displayRank, isNull);
});
});
group('ClubBan Model', () {
final now = DateTime(2026, 9, 6, 12, 0, 0);
test('ban document serialization preserves reason', () {
final ban = ClubBan(
uid: 'troublemaker',
clubId: 'club_1',
bannedByUid: 'alice',
bannedAt: now,
reason: 'Violating group ride safety rules',
);
final json = ban.toJson();
final fromJson = ClubBan.fromJson(json);
expect(fromJson.uid, equals('troublemaker'));
expect(fromJson.bannedByUid, equals('alice'));
expect(fromJson.bannedAt, equals(now));
expect(fromJson.reason, equals('Violating group ride safety rules'));
});
});
group('ClubHandle Model', () {
final now = DateTime(2026, 9, 6, 12, 0, 0);
test('handle reservation document serialization', () {
final handle = ClubHandle(
slug: 'norcal-adv',
clubId: 'club_norcal',
ownerUid: 'alice',
createdAt: now,
);
final json = handle.toJson();
final fromJson = ClubHandle.fromJson(json);
expect(fromJson.slug, equals('norcal-adv'));
expect(fromJson.clubId, equals('club_norcal'));
expect(fromJson.ownerUid, equals('alice'));
expect(fromJson.createdAt, equals(now));
});
});
}

139
test/club_service_test.dart Normal file
View File

@ -0,0 +1,139 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/services/clubs/club_service.dart';
void main() {
group('ClubService Validation & Invariant Contracts', () {
test('reports availability correctly when offline', () {
final service = ClubService();
expect(service.isAvailable, isFalse);
});
test('rejects club creation when unauthenticated or caller UID missing', () async {
final service = ClubService();
expect(
() => service.createClub(
name: 'Bay Area Riders',
slug: 'bay-area-riders',
),
throwsA(isA<ClubOperationException>().having(
(e) => e.code,
'code',
equals('unauthenticated'),
)),
);
});
test('rejects club creation with invalid name length', () async {
final service = ClubService();
// Too short (< 3)
expect(
() => service.createClub(
name: 'AB',
slug: 'valid-slug',
callerUid: 'alice',
),
throwsA(isA<ClubOperationException>().having(
(e) => e.code,
'code',
equals('invalid-name'),
)),
);
// Too long (> 50)
expect(
() => service.createClub(
name: 'A' * 51,
slug: 'valid-slug',
callerUid: 'alice',
),
throwsA(isA<ClubOperationException>().having(
(e) => e.code,
'code',
equals('invalid-name'),
)),
);
});
test('rejects club creation with invalid slug format or reserved keywords', () async {
final service = ClubService();
// Invalid slug format (underscores)
expect(
() => service.createClub(
name: 'Valid Name',
slug: 'invalid_slug_with_underscores',
callerUid: 'alice',
),
throwsA(isA<ClubOperationException>().having(
(e) => e.code,
'code',
equals('invalid-slug'),
)),
);
// Reserved slug
expect(
() => service.createClub(
name: 'Valid Name',
slug: 'admin',
callerUid: 'alice',
),
throwsA(isA<ClubOperationException>().having(
(e) => e.code,
'code',
equals('invalid-slug'),
)),
);
});
test('rejects ownership transfer to self', () async {
final service = ClubService();
expect(
() => service.transferOwnership(
'club_1',
'alice',
callerUid: 'alice',
),
throwsA(isA<ClubOperationException>().having(
(e) => e.code,
'code',
equals('invalid-target'),
)),
);
});
test('gracefully returns null/empty for streams when offline', () async {
final service = ClubService();
final club = await service.getClub('club_1');
expect(club, isNull);
final member = await service.getMember('club_1', 'alice');
expect(member, isNull);
final clubStream = service.clubStream('club_1');
expect(await clubStream.first, isNull);
final memberStream = service.memberStream('club_1', 'alice');
expect(await memberStream.first, isNull);
final activeStream = service.activeMembersStream('club_1');
expect(await activeStream.first, isEmpty);
final pendingStream = service.pendingRequestsStream('club_1');
expect(await pendingStream.first, isEmpty);
final myClubsStream = service.myActiveClubsStream('alice');
expect(await myClubsStream.first, isEmpty);
final myInvitesStream = service.myInvitationsStream('alice');
expect(await myInvitesStream.first, isEmpty);
final discovery = await service.discoverPublicClubs();
expect(discovery, isEmpty);
});
});
}

View File

@ -0,0 +1,107 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/services/clubs/club_slug_validator.dart';
void main() {
group('ClubSlugValidator', () {
test('accepts valid slugs', () {
final validCases = [
'bay-area-riders',
'sf-mc',
'apex-hunters-99',
'adv1200',
'track-day-paddock',
'moto-club-california',
'canyon-carvers',
];
for (final slug in validCases) {
final result = ClubSlugValidator.validate(slug);
expect(result.isValid, isTrue, reason: 'Expected $slug to be valid');
expect(result.normalizedSlug, equals(slug));
expect(result.error, isNull);
}
});
test('normalizes slugs with uppercase and whitespace', () {
final result = ClubSlugValidator.validate(' Bay-Area-Riders ');
expect(result.isValid, isTrue);
expect(result.normalizedSlug, equals('bay-area-riders'));
});
test('rejects empty or whitespace-only slug', () {
expect(ClubSlugValidator.validate('').isValid, isFalse);
expect(ClubSlugValidator.validate(' ').isValid, isFalse);
});
test('rejects slugs shorter than 3 characters', () {
final result = ClubSlugValidator.validate('ab');
expect(result.isValid, isFalse);
expect(result.error, contains('at least 3 characters'));
});
test('rejects slugs longer than 30 characters', () {
final longSlug = 'a' * 31;
final result = ClubSlugValidator.validate(longSlug);
expect(result.isValid, isFalse);
expect(result.error, contains('cannot exceed 30 characters'));
});
test('rejects consecutive hyphens', () {
final result = ClubSlugValidator.validate('bay--area');
expect(result.isValid, isFalse);
expect(result.error, contains('consecutive hyphens'));
});
test('rejects leading or trailing hyphens', () {
expect(ClubSlugValidator.validate('-bay-area').isValid, isFalse);
expect(ClubSlugValidator.validate('bay-area-').isValid, isFalse);
});
test('rejects underscores and special characters', () {
expect(ClubSlugValidator.validate('bay_area_mc').isValid, isFalse);
expect(ClubSlugValidator.validate('bay.area').isValid, isFalse);
expect(ClubSlugValidator.validate('bay@area').isValid, isFalse);
expect(ClubSlugValidator.validate('bay!area').isValid, isFalse);
});
test('rejects platform reserved keywords', () {
final reservedKeywords = [
'admin',
'administrator',
'motorsignal',
'support',
'system',
'root',
'api',
'mod',
];
for (final kw in reservedKeywords) {
final result = ClubSlugValidator.validate(kw);
expect(result.isValid, isFalse, reason: 'Expected $kw to be reserved');
expect(result.error, contains('reserved'));
}
});
test('rejects club-specific reserved words', () {
final clubReserved = [
'club',
'clubs',
'motorcycle',
'motorcycles',
'chapter',
'national',
'official',
'rally',
'groupride',
'event',
];
for (final kw in clubReserved) {
final result = ClubSlugValidator.validate(kw);
expect(result.isValid, isFalse, reason: 'Expected $kw to be reserved');
expect(result.error, contains('reserved'));
}
});
});
}