From 48d6d5cb07d525b88b6200c4c4e733170ab84f57 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Mon, 27 Apr 2026 16:07:28 -0700 Subject: [PATCH] refactor: unit testable units and cleaner dep injection --- go/cmd/emailnotifierjob/main.go | 7 +- go/cmd/memberreconciler/main.go | 8 +- go/cmd/orion/main.go | 3 +- go/cmd/pusherservice/main.go | 7 +- go/internal/auth/service.go | 4 +- go/internal/billing/mocks/service.go | 145 +++++++++ go/internal/billing/noop.go | 33 --- go/internal/billing/service.go | 2 + go/internal/livestore/membershippublisher.go | 42 +++ .../livestore/mocks/membershippublisher.go | 69 +++++ go/internal/network/reader.go | 94 ++++++ go/internal/network/repository.go | 8 +- go/internal/network/service.go | 132 +++------ go/internal/network/service_test.go | 13 +- go/internal/particle/errors.go | 1 + .../mocks/networkmembershipchecker.go | 56 ++++ .../particle/networkmembershipchecker.go | 10 + go/internal/particle/service.go | 76 +++-- go/internal/particle/service_test.go | 274 ++++++++++++++---- go/internal/pusher/authorizer.go | 8 +- go/internal/testhelper/mocks/aero/gen.go | 6 + .../testhelper/mocks/aero/mock_aero.go | 91 +----- .../mocks/mocknetwork/mock_network.go | 261 ----------------- go/internal/testhelper/network.go | 23 -- .../000015_particle_drop_network_fk.down.sql | 3 + .../000015_particle_drop_network_fk.up.sql | 2 + 26 files changed, 761 insertions(+), 617 deletions(-) create mode 100644 go/internal/billing/mocks/service.go delete mode 100644 go/internal/billing/noop.go create mode 100644 go/internal/livestore/membershippublisher.go create mode 100644 go/internal/livestore/mocks/membershippublisher.go create mode 100644 go/internal/network/reader.go create mode 100644 go/internal/particle/mocks/networkmembershipchecker.go create mode 100644 go/internal/particle/networkmembershipchecker.go create mode 100644 go/internal/testhelper/mocks/aero/gen.go delete mode 100644 go/internal/testhelper/mocks/mocknetwork/mock_network.go delete mode 100644 go/internal/testhelper/network.go create mode 100644 go/migrations/000015_particle_drop_network_fk.down.sql create mode 100644 go/migrations/000015_particle_drop_network_fk.up.sql diff --git a/go/cmd/emailnotifierjob/main.go b/go/cmd/emailnotifierjob/main.go index be74b21..0cfeab8 100644 --- a/go/cmd/emailnotifierjob/main.go +++ b/go/cmd/emailnotifierjob/main.go @@ -11,7 +11,6 @@ import ( "cloud.google.com/go/firestore" pbaero "github.com/flowy-live/llink/genproto/aero" pbpusher "github.com/flowy-live/llink/genproto/llink/pusher" - "github.com/flowy-live/llink/internal/billing" "github.com/flowy-live/llink/internal/db" "github.com/flowy-live/llink/internal/human" "github.com/flowy-live/llink/internal/network" @@ -68,7 +67,7 @@ func main() { // Initialize services humanSvc := human.NewService(db.Pool()) - networkSvc := network.NewService(db.Pool(), aeroSvc, billing.Noop(), nil) + networkSvc := network.NewReader(db.Pool()) slog.Info("starting email notification cycle") if err := runNotificationCycle(ctx, firestoreClient, aeroSvc, pusherSvc, humanSvc, networkSvc); err != nil { @@ -84,12 +83,12 @@ func runNotificationCycle( aeroSvc pbaero.PrimaryClient, pusherSvc pbpusher.PusherServiceClient, humanSvc human.Service, - networkSvc network.Service, + networkReader network.Reader, ) error { now := time.Now() // Load all networks - networks, err := networkSvc.ListAll(ctx) + networks, err := networkReader.ListAll(ctx) if err != nil { return fmt.Errorf("listing networks: %w", err) } diff --git a/go/cmd/memberreconciler/main.go b/go/cmd/memberreconciler/main.go index 2ad5212..a1d1c7c 100644 --- a/go/cmd/memberreconciler/main.go +++ b/go/cmd/memberreconciler/main.go @@ -18,7 +18,6 @@ import ( "time" "cloud.google.com/go/firestore" - "github.com/flowy-live/llink/internal/billing" "github.com/flowy-live/llink/internal/db" "github.com/flowy-live/llink/internal/human" "github.com/flowy-live/llink/internal/network" @@ -41,8 +40,7 @@ func main() { defer fs.Close() humanSvc := human.NewService(db.Pool()) - // billing/aero/fs unused here — we only call ListAllMemberships. - networkSvc := network.NewService(db.Pool(), nil, billing.Noop(), nil) + networkSvc := network.NewReader(db.Pool()) started := time.Now() written, scanned, err := reconcile(ctx, fs, humanSvc, networkSvc) @@ -61,14 +59,14 @@ func reconcile( ctx context.Context, fs *firestore.Client, humanSvc human.Service, - networkSvc network.Service, + networkReader network.Reader, ) (written, scanned int, err error) { humans, err := humanSvc.ListAll(ctx) if err != nil { return 0, 0, err } - memberships, err := networkSvc.ListAllMemberships(ctx) + memberships, err := networkReader.ListAllMemberships(ctx) if err != nil { return 0, 0, err } diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index 57e9d06..205c324 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -19,6 +19,7 @@ import ( "github.com/flowy-live/llink/internal/handler" "github.com/flowy-live/llink/internal/human" "github.com/flowy-live/llink/internal/livekit" + "github.com/flowy-live/llink/internal/livestore" "github.com/flowy-live/llink/internal/middleware" "github.com/flowy-live/llink/internal/network" "github.com/flowy-live/llink/internal/particle" @@ -98,7 +99,7 @@ func main() { } defer firestoreClient.Close() - networkSvc := network.NewService(db.Pool(), aeroSvc, billingSvc, firestoreClient) + networkSvc := network.NewService(db.Pool(), aeroSvc, billingSvc, livestore.NewMembershipPublisher(firestoreClient)) particleSvc := particle.NewService(db.Pool(), networkSvc) depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{ GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"), diff --git a/go/cmd/pusherservice/main.go b/go/cmd/pusherservice/main.go index 9bc5cb6..e850a20 100644 --- a/go/cmd/pusherservice/main.go +++ b/go/cmd/pusherservice/main.go @@ -12,7 +12,6 @@ import ( "github.com/flowy-live/llink/internal" "github.com/flowy-live/llink/internal/auth" - "github.com/flowy-live/llink/internal/billing" "github.com/flowy-live/llink/internal/db" "github.com/flowy-live/llink/internal/network" "github.com/flowy-live/llink/internal/pusher" @@ -37,8 +36,8 @@ func main() { pusherRedis := internal.ConnectAndTestRedis(db.RedisDBPusher) // Services - authSvc := auth.NewAuthService(authRedis, nil, nil) // nil aeroSvc / fbAuth — pusher only calls GetSession - networkSvc := network.NewService(db.Pool(), nil, billing.Noop(), nil) // nil aeroSvc / noop billing / nil firestore — pusher never mutates membership + authSvc := auth.NewAuthService(authRedis, nil, nil) // nil aeroSvc / fbAuth — pusher only calls GetSession + networkReader := network.NewReader(db.Pool()) // pusher only checks membership // Pod identity (use hostname in k8s, which is the pod name) podID, err := os.Hostname() @@ -52,7 +51,7 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - authorizer := pusher.NewAuthorizer(networkSvc) + authorizer := pusher.NewAuthorizer(networkReader) hub := pusher.NewHub(bridge, authorizer) bridge.SetHub(hub) server := pusher.NewServer(ctx, hub, bridge, authSvc) diff --git a/go/internal/auth/service.go b/go/internal/auth/service.go index f15b1f8..3091790 100644 --- a/go/internal/auth/service.go +++ b/go/internal/auth/service.go @@ -10,7 +10,7 @@ import ( "time" firebaseauth "firebase.google.com/go/v4/auth" - "github.com/flowy-live/llink/genproto/aero" + pbaero "github.com/flowy-live/llink/genproto/aero" "github.com/flowy-live/llink/internal/utils" "github.com/redis/go-redis/v9" "go.jetify.com/typeid" @@ -112,7 +112,7 @@ func (a *authServiceImpl) RequestSignInCode(ctx context.Context, email string) e } message := fmt.Sprintf("Here is your one-time code for signing into Flowy: %s\n\nPlease do not share this with anyone.\n\nBest, \nFlowy Team", code) - subject := fmt.Sprintf("Sign In - Your One-Time Code for Flowy.llink") + subject := fmt.Sprint("Sign In - Your One-Time Code for Flowy.llink") _, err = a.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{ ToEmails: []string{formattedEmail}, Subject: subject, diff --git a/go/internal/billing/mocks/service.go b/go/internal/billing/mocks/service.go new file mode 100644 index 0000000..0d3bdc9 --- /dev/null +++ b/go/internal/billing/mocks/service.go @@ -0,0 +1,145 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./service.go +// +// Generated by this command: +// +// mockgen -source ./service.go -destination ./mocks/service.go +// + +// Package mock_billing is a generated GoMock package. +package mock_billing + +import ( + context "context" + reflect "reflect" + time "time" + + billing "github.com/flowy-live/llink/internal/billing" + gomock "go.uber.org/mock/gomock" +) + +// MockService is a mock of Service interface. +type MockService struct { + ctrl *gomock.Controller + recorder *MockServiceMockRecorder + isgomock struct{} +} + +// MockServiceMockRecorder is the mock recorder for MockService. +type MockServiceMockRecorder struct { + mock *MockService +} + +// NewMockService creates a new mock instance. +func NewMockService(ctrl *gomock.Controller) *MockService { + mock := &MockService{ctrl: ctrl} + mock.recorder = &MockServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockService) EXPECT() *MockServiceMockRecorder { + return m.recorder +} + +// CreateCheckoutSession mocks base method. +func (m *MockService) CreateCheckoutSession(ctx context.Context, p billing.CheckoutParams) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateCheckoutSession", ctx, p) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateCheckoutSession indicates an expected call of CreateCheckoutSession. +func (mr *MockServiceMockRecorder) CreateCheckoutSession(ctx, p any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateCheckoutSession", reflect.TypeOf((*MockService)(nil).CreateCheckoutSession), ctx, p) +} + +// CreatePortalSession mocks base method. +func (m *MockService) CreatePortalSession(ctx context.Context, networkID string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreatePortalSession", ctx, networkID) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreatePortalSession indicates an expected call of CreatePortalSession. +func (mr *MockServiceMockRecorder) CreatePortalSession(ctx, networkID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePortalSession", reflect.TypeOf((*MockService)(nil).CreatePortalSession), ctx, networkID) +} + +// GetStatus mocks base method. +func (m *MockService) GetStatus(ctx context.Context, networkID string) (*billing.Status, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetStatus", ctx, networkID) + ret0, _ := ret[0].(*billing.Status) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetStatus indicates an expected call of GetStatus. +func (mr *MockServiceMockRecorder) GetStatus(ctx, networkID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStatus", reflect.TypeOf((*MockService)(nil).GetStatus), ctx, networkID) +} + +// GetUsage mocks base method. +func (m *MockService) GetUsage(ctx context.Context, networkID string) (*billing.Usage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUsage", ctx, networkID) + ret0, _ := ret[0].(*billing.Usage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUsage indicates an expected call of GetUsage. +func (mr *MockServiceMockRecorder) GetUsage(ctx, networkID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsage", reflect.TypeOf((*MockService)(nil).GetUsage), ctx, networkID) +} + +// HandleWebhook mocks base method. +func (m *MockService) HandleWebhook(ctx context.Context, payload []byte, signature string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HandleWebhook", ctx, payload, signature) + ret0, _ := ret[0].(error) + return ret0 +} + +// HandleWebhook indicates an expected call of HandleWebhook. +func (mr *MockServiceMockRecorder) HandleWebhook(ctx, payload, signature any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleWebhook", reflect.TypeOf((*MockService)(nil).HandleWebhook), ctx, payload, signature) +} + +// IncrementDailyUsage mocks base method. +func (m *MockService) IncrementDailyUsage(ctx context.Context, networkID string, at time.Time) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IncrementDailyUsage", ctx, networkID, at) + ret0, _ := ret[0].(error) + return ret0 +} + +// IncrementDailyUsage indicates an expected call of IncrementDailyUsage. +func (mr *MockServiceMockRecorder) IncrementDailyUsage(ctx, networkID, at any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementDailyUsage", reflect.TypeOf((*MockService)(nil).IncrementDailyUsage), ctx, networkID, at) +} + +// SyncSeats mocks base method. +func (m *MockService) SyncSeats(ctx context.Context, networkID string, seats int) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SyncSeats", ctx, networkID, seats) + ret0, _ := ret[0].(error) + return ret0 +} + +// SyncSeats indicates an expected call of SyncSeats. +func (mr *MockServiceMockRecorder) SyncSeats(ctx, networkID, seats any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncSeats", reflect.TypeOf((*MockService)(nil).SyncSeats), ctx, networkID, seats) +} diff --git a/go/internal/billing/noop.go b/go/internal/billing/noop.go deleted file mode 100644 index 2eabafc..0000000 --- a/go/internal/billing/noop.go +++ /dev/null @@ -1,33 +0,0 @@ -package billing - -import ( - "context" - "errors" - "time" -) - -// Noop returns a billing service for binaries that depend on network.Service -// but never mutate membership (jobs, pusher). Stripe isn't configured. -func Noop() Service { return noopService{} } - -type noopService struct{} - -var errNoopBilling = errors.New("billing: not configured in this process") - -func (noopService) GetStatus(context.Context, string) (*Status, error) { - return nil, errNoopBilling -} -func (noopService) CreateCheckoutSession(context.Context, CheckoutParams) (string, error) { - return "", errNoopBilling -} -func (noopService) CreatePortalSession(context.Context, string) (string, error) { - return "", errNoopBilling -} -func (noopService) SyncSeats(context.Context, string, int) error { return nil } -func (noopService) HandleWebhook(context.Context, []byte, string) error { return errNoopBilling } -func (noopService) GetUsage(context.Context, string) (*Usage, error) { - return nil, errNoopBilling -} -func (noopService) IncrementDailyUsage(context.Context, string, time.Time) error { - return errNoopBilling -} diff --git a/go/internal/billing/service.go b/go/internal/billing/service.go index 0e51a85..f2bfa65 100644 --- a/go/internal/billing/service.go +++ b/go/internal/billing/service.go @@ -15,6 +15,8 @@ import ( stripesub "github.com/stripe/stripe-go/v85/subscription" ) +//go:generate mockgen -source ./service.go -destination ./mocks/service.go + type Service interface { GetStatus(ctx context.Context, networkID string) (*Status, error) CreateCheckoutSession(ctx context.Context, p CheckoutParams) (url string, err error) diff --git a/go/internal/livestore/membershippublisher.go b/go/internal/livestore/membershippublisher.go new file mode 100644 index 0000000..b922a01 --- /dev/null +++ b/go/internal/livestore/membershippublisher.go @@ -0,0 +1,42 @@ +package livestore + +import ( + "context" + + "cloud.google.com/go/firestore" +) + +//go:generate mockgen -source ./membershippublisher.go -destination ./mocks/membershippublisher.go + +// MembershipPublisher publishes network membership changes to the live store +// (Firestore) that clients subscribe to. Postgres remains the source of truth; +// the membership reconciler heals any drift, so callers may log and ignore +// publish failures. +type MembershipPublisher interface { + Add(ctx context.Context, humanId, networkID string) error + Remove(ctx context.Context, humanId, networkID string) error +} + +func NewMembershipPublisher(fs *firestore.Client) MembershipPublisher { + return &firestoreMembershipPublisher{fs: fs} +} + +type firestoreMembershipPublisher struct { + fs *firestore.Client +} + +func (p *firestoreMembershipPublisher) Add(ctx context.Context, humanId, networkID string) error { + _, err := p.fs.Collection("humans").Doc(humanId).Set(ctx, map[string]any{ + "networks": firestore.ArrayUnion(networkID), + "updated_at": firestore.ServerTimestamp, + }, firestore.MergeAll) + return err +} + +func (p *firestoreMembershipPublisher) Remove(ctx context.Context, humanId, networkID string) error { + _, err := p.fs.Collection("humans").Doc(humanId).Set(ctx, map[string]any{ + "networks": firestore.ArrayRemove(networkID), + "updated_at": firestore.ServerTimestamp, + }, firestore.MergeAll) + return err +} diff --git a/go/internal/livestore/mocks/membershippublisher.go b/go/internal/livestore/mocks/membershippublisher.go new file mode 100644 index 0000000..2162943 --- /dev/null +++ b/go/internal/livestore/mocks/membershippublisher.go @@ -0,0 +1,69 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./membershippublisher.go +// +// Generated by this command: +// +// mockgen -source ./membershippublisher.go -destination ./mocks/membershippublisher.go +// + +// Package mock_livestore is a generated GoMock package. +package mock_livestore + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockMembershipPublisher is a mock of MembershipPublisher interface. +type MockMembershipPublisher struct { + ctrl *gomock.Controller + recorder *MockMembershipPublisherMockRecorder + isgomock struct{} +} + +// MockMembershipPublisherMockRecorder is the mock recorder for MockMembershipPublisher. +type MockMembershipPublisherMockRecorder struct { + mock *MockMembershipPublisher +} + +// NewMockMembershipPublisher creates a new mock instance. +func NewMockMembershipPublisher(ctrl *gomock.Controller) *MockMembershipPublisher { + mock := &MockMembershipPublisher{ctrl: ctrl} + mock.recorder = &MockMembershipPublisherMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockMembershipPublisher) EXPECT() *MockMembershipPublisherMockRecorder { + return m.recorder +} + +// Add mocks base method. +func (m *MockMembershipPublisher) Add(ctx context.Context, humanId, networkID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Add", ctx, humanId, networkID) + ret0, _ := ret[0].(error) + return ret0 +} + +// Add indicates an expected call of Add. +func (mr *MockMembershipPublisherMockRecorder) Add(ctx, humanId, networkID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Add", reflect.TypeOf((*MockMembershipPublisher)(nil).Add), ctx, humanId, networkID) +} + +// Remove mocks base method. +func (m *MockMembershipPublisher) Remove(ctx context.Context, humanId, networkID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Remove", ctx, humanId, networkID) + ret0, _ := ret[0].(error) + return ret0 +} + +// Remove indicates an expected call of Remove. +func (mr *MockMembershipPublisherMockRecorder) Remove(ctx, humanId, networkID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockMembershipPublisher)(nil).Remove), ctx, humanId, networkID) +} diff --git a/go/internal/network/reader.go b/go/internal/network/reader.go new file mode 100644 index 0000000..d886533 --- /dev/null +++ b/go/internal/network/reader.go @@ -0,0 +1,94 @@ +package network + +import ( + "context" + "errors" + "fmt" + + "github.com/flowy-live/llink/internal/utils" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Reader interface { + // GetByID returns ErrNotFound if network doesn't exist. + GetByID(ctx context.Context, id string) (*Network, error) + // ListForHuman returns ErrInvalidHumanId if humanId is empty. + ListForHuman(ctx context.Context, humanId string) ([]*Network, error) + // IsMember returns ErrInvalidHumanId if humanId is empty. + IsMember(ctx context.Context, networkID, humanId string) (bool, error) + // ListAll returns all networks with their members + ListAll(ctx context.Context) ([]*Network, error) + // ListAllMemberships returns humanId -> networkIds for every human with at + // least one membership. Humans with zero memberships are absent from the map. + // Used by the membership reconciler to diff the Firestore mirror. + ListAllMemberships(ctx context.Context) (map[string][]string, error) + + CountSeats(ctx context.Context, networkID string) (int, error) + // ListInvitationsForEmail returns ErrInvalidEmail if normalization fails. + ListInvitationsForEmail(ctx context.Context, email string) ([]*Invitation, error) + ListInvitationsForNetwork(ctx context.Context, networkID string) ([]*Invitation, error) +} + +type readerImpl struct { + pool *pgxpool.Pool + repo repository +} + +// newReader returns the concrete reader. Used by NewService to embed without +// going through the Reader interface (which would hide pool/repo). +func newReader(pool *pgxpool.Pool) *readerImpl { + return &readerImpl{ + pool: pool, + repo: newRepository(pool), + } +} + +func NewReader(pool *pgxpool.Pool) Reader { + return newReader(pool) +} + +func (r *readerImpl) GetByID(ctx context.Context, id string) (*Network, error) { + n, err := r.repo.getByID(ctx, id) + if errors.Is(err, errNotFound) { + return nil, ErrNotFound + } + return n, err +} + +func (r *readerImpl) ListForHuman(ctx context.Context, humanId string) ([]*Network, error) { + if humanId == "" { + return nil, ErrInvalidHumanId + } + return r.repo.getNetworksForHuman(ctx, humanId) +} + +func (r *readerImpl) IsMember(ctx context.Context, networkID, humanId string) (bool, error) { + if humanId == "" { + return false, ErrInvalidHumanId + } + return r.repo.isMember(ctx, networkID, humanId) +} + +func (r *readerImpl) ListAll(ctx context.Context) ([]*Network, error) { + return r.repo.listAll(ctx) +} + +func (r *readerImpl) ListAllMemberships(ctx context.Context) (map[string][]string, error) { + return r.repo.listAllMemberships(ctx) +} + +func (r *readerImpl) CountSeats(ctx context.Context, networkID string) (int, error) { + return r.repo.countSeats(ctx, r.pool, networkID) +} + +func (r *readerImpl) ListInvitationsForEmail(ctx context.Context, email string) ([]*Invitation, error) { + normalized, err := utils.NormalizeEmail(email) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidEmail, err) + } + return r.repo.getInvitationsByEmail(ctx, normalized) +} + +func (r *readerImpl) ListInvitationsForNetwork(ctx context.Context, networkID string) ([]*Invitation, error) { + return r.repo.getInvitationsByNetwork(ctx, networkID) +} diff --git a/go/internal/network/repository.go b/go/internal/network/repository.go index d755ce5..f7a5544 100644 --- a/go/internal/network/repository.go +++ b/go/internal/network/repository.go @@ -33,8 +33,6 @@ func newNetworkID() (networkID, error) { return typeid.New[networkID]() } -var errCapacityExceeded = errors.New("capacity exceeded") - type repository interface { create(ctx context.Context, name, adminHumanId string) (*Network, error) getByID(ctx context.Context, id string) (*Network, error) @@ -47,6 +45,9 @@ type repository interface { getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error) isMember(ctx context.Context, networkID, humanId string) (bool, error) listAll(ctx context.Context) ([]*Network, error) + + // listAllMemberships returns humanId -> networkIds for every human with at + // least one membership. Humans with zero memberships are absent from the map. listAllMemberships(ctx context.Context) (map[string][]string, error) // Invitations @@ -233,9 +234,6 @@ func (r *repositoryImpl) isMember(ctx context.Context, networkID, humanId string return isMember, err } -// listAllMemberships returns humanId -> networkIds for every human with at -// least one membership. Humans with zero memberships are absent from the map; -// callers layer them in separately. func (r *repositoryImpl) listAllMemberships(ctx context.Context) (map[string][]string, error) { rows, err := r.pool.Query(ctx, `SELECT human_id, network_id FROM network_members`) if err != nil { diff --git a/go/internal/network/service.go b/go/internal/network/service.go index aec8b82..8cacac9 100644 --- a/go/internal/network/service.go +++ b/go/internal/network/service.go @@ -9,10 +9,10 @@ import ( "slices" "strings" - "cloud.google.com/go/firestore" pbaero "github.com/flowy-live/llink/genproto/aero" "github.com/flowy-live/llink/internal/billing" "github.com/flowy-live/llink/internal/constants" + "github.com/flowy-live/llink/internal/livestore" "github.com/flowy-live/llink/internal/utils" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -20,53 +20,49 @@ import ( var ErrNotFound = errors.New("network not found") var ErrInvalidName = errors.New("name cannot be empty") +var ErrInvalidEmail = errors.New("invalid email") +var ErrInvalidHumanId = errors.New("invalid humanId") var ErrCapacityExceeded = errors.New("active stream capacity exceeded") var ErrInvalidRetentionHours = errors.New("message retention hours must be between 24 and 336") type Service interface { + Reader + // Create creates a network and adds adminHumanId as the first member. Returns ErrInvalidName if name is empty. Create(ctx context.Context, name, adminHumanId string) (*Network, error) - // GetByID returns ErrNotFound if network doesn't exist. - GetByID(ctx context.Context, id string) (*Network, error) // SetName returns ErrNotFound or ErrInvalidName. SetName(ctx context.Context, id, name string) error // AddMembers inserts members and syncs the new seat count to billing // atomically; a Stripe failure rolls the insert back. + // Returns ErrInvalidHumanId if any humanId is empty. AddMembers(ctx context.Context, networkID string, humanIds []string) error + // RemoveMember returns ErrInvalidHumanId if humanId is empty. RemoveMember(ctx context.Context, networkID, humanId string) error - CountSeats(ctx context.Context, networkID string) (int, error) - ListForHuman(ctx context.Context, humanId string) ([]*Network, error) - IsMember(ctx context.Context, networkID, humanId string) (bool, error) - // ListAll returns all networks with their members - ListAll(ctx context.Context) ([]*Network, error) - // ListAllMemberships returns humanId -> networkIds for every human with at - // least one membership. Humans with zero memberships are absent from the map. - // Used by the membership reconciler to diff the Firestore mirror. - ListAllMemberships(ctx context.Context) (map[string][]string, error) // Invitations (email-based, for users who haven't registered yet) + + // InviteByEmail returns ErrNotFound if the network doesn't exist + // or ErrInvalidEmail if any email fails normalization. InviteByEmail(ctx context.Context, networkID string, emails []string) error - ListInvitationsForEmail(ctx context.Context, email string) ([]*Invitation, error) - ListInvitationsForNetwork(ctx context.Context, networkID string) ([]*Invitation, error) + // AcceptInvitation returns ErrInvalidEmail or ErrInvalidHumanId. AcceptInvitation(ctx context.Context, networkID, email, humanId string) error + // RevokeInvitation returns ErrInvalidEmail. RevokeInvitation(ctx context.Context, networkID, email string) error } type serviceImpl struct { - pool *pgxpool.Pool - repo repository + *readerImpl aeroSvc pbaero.PrimaryClient billingSvc billing.Service - fs *firestore.Client + pub livestore.MembershipPublisher } -func NewService(pool *pgxpool.Pool, aeroSvc pbaero.PrimaryClient, billingSvc billing.Service, fs *firestore.Client) Service { +func NewService(pool *pgxpool.Pool, aeroSvc pbaero.PrimaryClient, billingSvc billing.Service, pub livestore.MembershipPublisher) Service { return &serviceImpl{ - pool: pool, - repo: newRepository(pool), + readerImpl: newReader(pool), aeroSvc: aeroSvc, billingSvc: billingSvc, - fs: fs, + pub: pub, } } @@ -101,14 +97,6 @@ func (s *serviceImpl) Create(ctx context.Context, name, adminHumanId string) (*N return network, nil } -func (s *serviceImpl) GetByID(ctx context.Context, id string) (*Network, error) { - n, err := s.repo.getByID(ctx, id) - if errors.Is(err, errNotFound) { - return nil, ErrNotFound - } - return n, err -} - func (s *serviceImpl) SetName(ctx context.Context, id, name string) error { name = strings.TrimSpace(name) if name == "" { @@ -124,7 +112,7 @@ func (s *serviceImpl) SetName(ctx context.Context, id, name string) error { func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, humanIds []string) error { if slices.Contains(humanIds, "") { - return fmt.Errorf("invalid humanId") + return ErrInvalidHumanId } if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error { for _, humanId := range humanIds { @@ -145,7 +133,7 @@ func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, humanIds func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, humanId string) error { if humanId == "" { - return fmt.Errorf("invalid humanId") + return ErrInvalidHumanId } if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error { return s.repo.removeMember(ctx, tx, networkID, humanId) @@ -157,34 +145,19 @@ func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, humanId strin return nil } -// mirrorAddMembership / mirrorRemoveMembership keep the Firestore membership -// mirror (humans/{humanId}.networks) in sync with Postgres. Called after the -// Postgres transaction commits. Failures are logged but not returned: Postgres -// is the source of truth and the reconciler will heal drift. A missing fs -// client (pusher) no-ops. +// mirrorAddMembership / mirrorRemoveMembership keep the live store membership +// projection (humans/{humanId}.networks) in sync with Postgres. Called after +// the Postgres transaction commits. Failures are logged but not returned: +// Postgres is the source of truth and the reconciler will heal drift. func (s *serviceImpl) mirrorAddMembership(ctx context.Context, humanId, networkID string) { - if s.fs == nil { - return - } - _, err := s.fs.Collection("humans").Doc(humanId).Set(ctx, map[string]any{ - "networks": firestore.ArrayUnion(networkID), - "updated_at": firestore.ServerTimestamp, - }, firestore.MergeAll) - if err != nil { - slog.Error("firestore mirror add failed", "error", err, "humanId", humanId, "networkID", networkID) + if err := s.pub.Add(ctx, humanId, networkID); err != nil { + slog.Error("membership publish add failed", "error", err, "humanId", humanId, "networkID", networkID) } } func (s *serviceImpl) mirrorRemoveMembership(ctx context.Context, humanId, networkID string) { - if s.fs == nil { - return - } - _, err := s.fs.Collection("humans").Doc(humanId).Set(ctx, map[string]any{ - "networks": firestore.ArrayRemove(networkID), - "updated_at": firestore.ServerTimestamp, - }, firestore.MergeAll) - if err != nil { - slog.Error("firestore mirror remove failed", "error", err, "humanId", humanId, "networkID", networkID) + if err := s.pub.Remove(ctx, humanId, networkID); err != nil { + slog.Error("membership publish remove failed", "error", err, "humanId", humanId, "networkID", networkID) } } @@ -216,42 +189,19 @@ func (s *serviceImpl) mutateMembers(ctx context.Context, networkID string, fn fu return nil } -func (s *serviceImpl) CountSeats(ctx context.Context, networkID string) (int, error) { - return s.repo.countSeats(ctx, s.pool, networkID) -} - -func (s *serviceImpl) ListForHuman(ctx context.Context, humanId string) ([]*Network, error) { - if humanId == "" { - return nil, fmt.Errorf("invalid humanId") - } - return s.repo.getNetworksForHuman(ctx, humanId) -} - -func (s *serviceImpl) IsMember(ctx context.Context, networkID, humanId string) (bool, error) { - if humanId == "" { - return false, fmt.Errorf("invalid humanId") - } - return s.repo.isMember(ctx, networkID, humanId) -} - -func (s *serviceImpl) ListAll(ctx context.Context) ([]*Network, error) { - return s.repo.listAll(ctx) -} - -func (s *serviceImpl) ListAllMemberships(ctx context.Context) (map[string][]string, error) { - return s.repo.listAllMemberships(ctx) -} - func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, emails []string) error { network, err := s.repo.getByID(ctx, networkID) if err != nil { - return ErrNotFound + if errors.Is(err, errNotFound) { + return ErrNotFound + } + return err } for _, email := range emails { normalized, err := utils.NormalizeEmail(email) if err != nil { - return fmt.Errorf("invalid email %q: %w", email, err) + return fmt.Errorf("%q: %w: %w", email, ErrInvalidEmail, err) } if err := s.repo.createInvitation(ctx, networkID, normalized); err != nil { return err @@ -273,25 +223,13 @@ func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, email return nil } -func (s *serviceImpl) ListInvitationsForEmail(ctx context.Context, email string) ([]*Invitation, error) { - normalized, err := utils.NormalizeEmail(email) - if err != nil { - return nil, fmt.Errorf("invalid email: %w", err) - } - return s.repo.getInvitationsByEmail(ctx, normalized) -} - -func (s *serviceImpl) ListInvitationsForNetwork(ctx context.Context, networkID string) ([]*Invitation, error) { - return s.repo.getInvitationsByNetwork(ctx, networkID) -} - func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, humanId string) error { normalized, err := utils.NormalizeEmail(email) if err != nil { - return fmt.Errorf("invalid email: %w", err) + return fmt.Errorf("%w: %w", ErrInvalidEmail, err) } if humanId == "" { - return fmt.Errorf("invalid humanId") + return ErrInvalidHumanId } if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error { @@ -311,7 +249,7 @@ func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, hu func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email string) error { normalized, err := utils.NormalizeEmail(email) if err != nil { - return fmt.Errorf("invalid email: %w", err) + return fmt.Errorf("%w: %w", ErrInvalidEmail, err) } return s.repo.deleteInvitation(ctx, s.pool, networkID, normalized) } diff --git a/go/internal/network/service_test.go b/go/internal/network/service_test.go index 9d13439..9e06152 100644 --- a/go/internal/network/service_test.go +++ b/go/internal/network/service_test.go @@ -6,7 +6,8 @@ import ( "testing" pbaero "github.com/flowy-live/llink/genproto/aero" - "github.com/flowy-live/llink/internal/billing" + mock_billing "github.com/flowy-live/llink/internal/billing/mocks" + mock_livestore "github.com/flowy-live/llink/internal/livestore/mocks" "github.com/flowy-live/llink/internal/network" "github.com/flowy-live/llink/internal/testhelper" "github.com/flowy-live/llink/internal/testhelper/mocks/aero" @@ -32,7 +33,15 @@ func newTestService(t *testing.T) network.Service { ShootEmail(gomock.Any(), gomock.Any()). Return(&pbaero.ShootEmailResponse{}, nil). AnyTimes() - return network.NewService(dbPool, mockAero, billing.Noop(), nil) + + mockBilling := mock_billing.NewMockService(ctrl) + mockBilling.EXPECT().SyncSeats(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + mockPub := mock_livestore.NewMockMembershipPublisher(ctrl) + mockPub.EXPECT().Add(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mockPub.EXPECT().Remove(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + return network.NewService(dbPool, mockAero, mockBilling, mockPub) } func TestNetworkService(t *testing.T) { diff --git a/go/internal/particle/errors.go b/go/internal/particle/errors.go index dafef01..8874683 100644 --- a/go/internal/particle/errors.go +++ b/go/internal/particle/errors.go @@ -16,4 +16,5 @@ var ( ErrMembersRequired = errors.New("custom visibility requires at least one member") ErrInheritedAtRoot = errors.New("root particles cannot use inherited visibility") ErrNotAContainer = errors.New("only streams can have members") + ErrInvalidMember = errors.New("member is not in the network") ) diff --git a/go/internal/particle/mocks/networkmembershipchecker.go b/go/internal/particle/mocks/networkmembershipchecker.go new file mode 100644 index 0000000..f8bc6b8 --- /dev/null +++ b/go/internal/particle/mocks/networkmembershipchecker.go @@ -0,0 +1,56 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./networkmembershipchecker.go +// +// Generated by this command: +// +// mockgen -source ./networkmembershipchecker.go -destination ./mocks/networkmembershipchecker.go +// + +// Package mock_particle is a generated GoMock package. +package mock_particle + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockNetworkMembershipChecker is a mock of NetworkMembershipChecker interface. +type MockNetworkMembershipChecker struct { + ctrl *gomock.Controller + recorder *MockNetworkMembershipCheckerMockRecorder + isgomock struct{} +} + +// MockNetworkMembershipCheckerMockRecorder is the mock recorder for MockNetworkMembershipChecker. +type MockNetworkMembershipCheckerMockRecorder struct { + mock *MockNetworkMembershipChecker +} + +// NewMockNetworkMembershipChecker creates a new mock instance. +func NewMockNetworkMembershipChecker(ctrl *gomock.Controller) *MockNetworkMembershipChecker { + mock := &MockNetworkMembershipChecker{ctrl: ctrl} + mock.recorder = &MockNetworkMembershipCheckerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockNetworkMembershipChecker) EXPECT() *MockNetworkMembershipCheckerMockRecorder { + return m.recorder +} + +// IsMember mocks base method. +func (m *MockNetworkMembershipChecker) IsMember(ctx context.Context, networkID, humanId string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsMember", ctx, networkID, humanId) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsMember indicates an expected call of IsMember. +func (mr *MockNetworkMembershipCheckerMockRecorder) IsMember(ctx, networkID, humanId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsMember", reflect.TypeOf((*MockNetworkMembershipChecker)(nil).IsMember), ctx, networkID, humanId) +} diff --git a/go/internal/particle/networkmembershipchecker.go b/go/internal/particle/networkmembershipchecker.go new file mode 100644 index 0000000..3d7bd7b --- /dev/null +++ b/go/internal/particle/networkmembershipchecker.go @@ -0,0 +1,10 @@ +package particle + +import "context" + +//go:generate mockgen -source ./networkmembershipchecker.go -destination ./mocks/networkmembershipchecker.go + +type NetworkMembershipChecker interface { + // IsMember returns true if the humanId is a member of the network. + IsMember(ctx context.Context, networkID, humanId string) (bool, error) +} diff --git a/go/internal/particle/service.go b/go/internal/particle/service.go index ce99953..80b2d92 100644 --- a/go/internal/particle/service.go +++ b/go/internal/particle/service.go @@ -4,9 +4,9 @@ import ( "context" "encoding/json" "errors" + "fmt" "time" - "github.com/flowy-live/llink/internal/network" "github.com/flowy-live/llink/internal/utils" "github.com/jackc/pgx/v5/pgxpool" ) @@ -58,14 +58,14 @@ type Service interface { } type serviceImpl struct { - repo repository - networkSvc network.Service + repo repository + networkMembershipChecker NetworkMembershipChecker } -func NewService(pool *pgxpool.Pool, networkSvc network.Service) Service { +func NewService(pool *pgxpool.Pool, networkReader NetworkMembershipChecker) Service { return &serviceImpl{ - repo: newRepository(pool), - networkSvc: networkSvc, + repo: newRepository(pool), + networkMembershipChecker: networkReader, } } @@ -152,6 +152,7 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm } // Custom visibility requires at least one member and must be a stream + var customMembers []string if input.Visibility == VisibilityCustom { if input.Type != TypeStream { return nil, ErrNotAContainer @@ -159,6 +160,30 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm if len(input.Members) == 0 { return nil, ErrMembersRequired } + + // Validate every supplied member against the network checker before touching the DB. + customMembers = make([]string, 0, len(input.Members)+1) + customMembers = append(customMembers, requesterEmail) + seen := map[string]bool{requesterEmail: true} + for _, email := range input.Members { + normalized, err := utils.NormalizeEmail(email) + if err != nil { + return nil, errors.Join(ErrInvalidData, err) + } + if seen[normalized] { + continue + } + seen[normalized] = true + + isMember, err := s.networkMembershipChecker.IsMember(ctx, input.NetworkID, normalized) + if err != nil { + return nil, err + } + if !isMember { + return nil, fmt.Errorf("%w: %s", ErrInvalidMember, normalized) + } + customMembers = append(customMembers, normalized) + } } // Network membership is verified by handler - we only check particle visibility @@ -206,22 +231,9 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm return nil, err } - // Add members if custom visibility (only streams for MVP) - if input.Visibility == VisibilityCustom && len(input.Members) > 0 { - normalizedEmails := make([]string, 0, len(input.Members)+1) - // Always include the creator - normalizedEmails = append(normalizedEmails, requesterEmail) - for _, email := range input.Members { - normalized, err := utils.NormalizeEmail(email) - if err != nil { - continue // Skip invalid emails - } - if normalized == requesterEmail { - continue // Already added - } - normalizedEmails = append(normalizedEmails, normalized) - } - if err := s.repo.addMembers(ctx, created.ID, normalizedEmails); err != nil { + // Add the pre-validated member list for custom visibility. + if len(customMembers) > 0 { + if err := s.repo.addMembers(ctx, created.ID, customMembers); err != nil { return nil, err } } @@ -614,20 +626,28 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string return ErrNotAContainer } - // Validate and normalize emails, check network membership + // Validate and normalize emails, checking network membership upfront. + // Strict: a normalize failure, checker error, or non-member aborts the + // whole operation before any rows are written. normalizedEmails := make([]string, 0, len(emails)) + seen := make(map[string]bool, len(emails)) for _, email := range emails { normalized, err := utils.NormalizeEmail(email) if err != nil { + return errors.Join(ErrInvalidData, err) + } + if seen[normalized] { continue } + seen[normalized] = true - // Root stream - check network membership - isMember, err := s.networkSvc.IsMember(ctx, p.NetworkID, normalized) - if err != nil || !isMember { - continue + isMember, err := s.networkMembershipChecker.IsMember(ctx, p.NetworkID, normalized) + if err != nil { + return err + } + if !isMember { + return fmt.Errorf("%w: %s", ErrInvalidMember, normalized) } - normalizedEmails = append(normalizedEmails, normalized) } diff --git a/go/internal/particle/service_test.go b/go/internal/particle/service_test.go index 587f3a3..dbf1961 100644 --- a/go/internal/particle/service_test.go +++ b/go/internal/particle/service_test.go @@ -3,13 +3,16 @@ package particle_test import ( "context" "encoding/json" + "errors" "os" "testing" "github.com/flowy-live/llink/internal/particle" + mock_particle "github.com/flowy-live/llink/internal/particle/mocks" "github.com/flowy-live/llink/internal/testhelper" "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" ) var dbPool *pgxpool.Pool @@ -30,27 +33,46 @@ func getStreamStatus(data json.RawMessage) string { return d.Status } +// newTestService returns a particle.Service backed by the test DB and a fresh +// MockNetworkMembershipChecker. The mock is returned so each test can set its +// own EXPECT() calls for whatever membership behavior it needs. Tests that +// never trigger AddMembers (the only path that consults the checker) can +// discard the mock with _ — gomock will fail the test if it's called +// unexpectedly. +func newTestService(t *testing.T) (particle.Service, *mock_particle.MockNetworkMembershipChecker) { + ctrl := gomock.NewController(t) + mockChecker := mock_particle.NewMockNetworkMembershipChecker(ctrl) + return particle.NewService(dbPool, mockChecker), mockChecker +} + +// expectIsMember configures the mock so that IsMember(networkID, email) returns +// the given result. Each entry is matched once — the test fails if a configured +// email is never queried, or if any unconfigured email is. +func expectIsMember(mock *mock_particle.MockNetworkMembershipChecker, networkID string, results map[string]bool) { + for email, isMember := range results { + mock.EXPECT(). + IsMember(gomock.Any(), networkID, email). + Return(isMember, nil) + } +} + func TestParticleService_CreateAndGet(t *testing.T) { ctx := context.Background() - networkSvc := testhelper.NewNetworkService(t, dbPool) - svc := particle.NewService(dbPool, networkSvc) - - // Create a network first - net, err := networkSvc.Create(ctx, "Test Network", "admin@example.com") - assert.NoError(t, err) + svc, _ := newTestService(t) + networkID := "net_123" // Test Create stream particle data := json.RawMessage(`{"name":"My Stream","status":"open","description":"A test stream"}`) input := particle.CreateInput{ Type: particle.TypeStream, - NetworkID: net.ID, + NetworkID: networkID, Data: data, } created, err := svc.Create(ctx, input, "admin@example.com") assert.NoError(t, err) assert.NotEmpty(t, created.ID) assert.Equal(t, particle.TypeStream, created.Type) - assert.Equal(t, net.ID, created.NetworkID) + assert.Equal(t, networkID, created.NetworkID) assert.Nil(t, created.ParentID) assert.Equal(t, particle.VisibilityNetworkAll, created.Visibility) assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(created.Data)) @@ -71,17 +93,14 @@ func TestParticleService_CreateAndGet(t *testing.T) { func TestParticleService_NestedParticles(t *testing.T) { ctx := context.Background() - networkSvc := testhelper.NewNetworkService(t, dbPool) - svc := particle.NewService(dbPool, networkSvc) + svc, _ := newTestService(t) - // Create a network - net, err := networkSvc.Create(ctx, "Nested Test Network", "admin@example.com") - assert.NoError(t, err) + networkID := "net_123" // Create a parent stream streamInput := particle.CreateInput{ Type: particle.TypeStream, - NetworkID: net.ID, + NetworkID: networkID, Data: json.RawMessage(`{"name":"Parent Stream","status":"open"}`), } stream, err := svc.Create(ctx, streamInput, "admin@example.com") @@ -90,7 +109,7 @@ func TestParticleService_NestedParticles(t *testing.T) { // Create a text particle as child textInput := particle.CreateInput{ Type: particle.TypeText, - NetworkID: net.ID, + NetworkID: networkID, ParentID: &stream.ID, Data: json.RawMessage(`{"content":"Hello world"}`), } @@ -101,7 +120,7 @@ func TestParticleService_NestedParticles(t *testing.T) { // Create a file as child of stream fileInput := particle.CreateInput{ Type: particle.TypeFile, - NetworkID: net.ID, + NetworkID: networkID, ParentID: &stream.ID, Data: json.RawMessage(`{"object_id":"obj_abc123","filename":"test.pdf","mime_type":"application/pdf","size":1024}`), } @@ -110,27 +129,26 @@ func TestParticleService_NestedParticles(t *testing.T) { assert.Equal(t, stream.ID, *file.ParentID) // List children of stream - children, err := svc.List(ctx, net.ID, &stream.ID, "admin@example.com", particle.ListFilter{}, nil, 50) + children, err := svc.List(ctx, networkID, &stream.ID, "admin@example.com", particle.ListFilter{}, nil, 50) assert.NoError(t, err) assert.Len(t, children.Particles, 2) } func TestParticleService_CustomVisibility(t *testing.T) { ctx := context.Background() - networkSvc := testhelper.NewNetworkService(t, dbPool) - svc := particle.NewService(dbPool, networkSvc) + svc, mockChecker := newTestService(t) - // Create a network with a member - net, err := networkSvc.Create(ctx, "Visibility Test Network", "admin@example.com") - assert.NoError(t, err) + networkID := "net_123" - err = networkSvc.AddMembers(ctx, net.ID, []string{"member@example.com", "other@example.com"}) - assert.NoError(t, err) + // admin is the creator (exempt from the check); member is a network member. + expectIsMember(mockChecker, networkID, map[string]bool{ + "member@example.com": true, + }) // Create a stream with custom visibility including only admin and member streamInput := particle.CreateInput{ Type: particle.TypeStream, - NetworkID: net.ID, + NetworkID: networkID, Visibility: particle.VisibilityCustom, Members: []string{"admin@example.com", "member@example.com"}, Data: json.RawMessage(`{"name":"Private Stream","status":"open"}`), @@ -159,17 +177,14 @@ func TestParticleService_CustomVisibility(t *testing.T) { func TestParticleService_UpdateAndDelete(t *testing.T) { ctx := context.Background() - networkSvc := testhelper.NewNetworkService(t, dbPool) - svc := particle.NewService(dbPool, networkSvc) + svc, _ := newTestService(t) - // Create a network - net, err := networkSvc.Create(ctx, "Update Test Network", "admin@example.com") - assert.NoError(t, err) + networkID := "net_123" // Create a text particle input := particle.CreateInput{ Type: particle.TypeText, - NetworkID: net.ID, + NetworkID: networkID, Data: json.RawMessage(`{"content":"Original content"}`), } created, err := svc.Create(ctx, input, "admin@example.com") @@ -197,18 +212,15 @@ func TestParticleService_UpdateAndDelete(t *testing.T) { func TestParticleService_ListRootParticles(t *testing.T) { ctx := context.Background() - networkSvc := testhelper.NewNetworkService(t, dbPool) - svc := particle.NewService(dbPool, networkSvc) + svc, _ := newTestService(t) - // Create a network - net, err := networkSvc.Create(ctx, "List Test Network", "admin@example.com") - assert.NoError(t, err) + networkID := "net_123" // Create multiple root particles for i := 0; i < 3; i++ { input := particle.CreateInput{ Type: particle.TypeStream, - NetworkID: net.ID, + NetworkID: networkID, Data: json.RawMessage(`{"name":"Stream","status":"open"}`), } _, err := svc.Create(ctx, input, "admin@example.com") @@ -216,24 +228,21 @@ func TestParticleService_ListRootParticles(t *testing.T) { } // List root particles (parentID = nil) - list, err := svc.List(ctx, net.ID, nil, "admin@example.com", particle.ListFilter{}, nil, 50) + list, err := svc.List(ctx, networkID, nil, "admin@example.com", particle.ListFilter{}, nil, 50) assert.NoError(t, err) assert.GreaterOrEqual(t, len(list.Particles), 3) } func TestParticleService_OpenCloseStream(t *testing.T) { ctx := context.Background() - networkSvc := testhelper.NewNetworkService(t, dbPool) - svc := particle.NewService(dbPool, networkSvc) + svc, _ := newTestService(t) - // Create a network - net, err := networkSvc.Create(ctx, "Open Close Test Network", "admin@example.com") - assert.NoError(t, err) + networkID := "net_123" // Create a stream input := particle.CreateInput{ Type: particle.TypeStream, - NetworkID: net.ID, + NetworkID: networkID, Data: json.RawMessage(`{"name":"Test Stream","status":"open"}`), } stream, err := svc.Create(ctx, input, "admin@example.com") @@ -271,17 +280,14 @@ func TestParticleService_OpenCloseStream(t *testing.T) { func TestParticleService_NotAStream(t *testing.T) { ctx := context.Background() - networkSvc := testhelper.NewNetworkService(t, dbPool) - svc := particle.NewService(dbPool, networkSvc) + svc, _ := newTestService(t) - // Create a network - net, err := networkSvc.Create(ctx, "Not Stream Test Network", "admin@example.com") - assert.NoError(t, err) + networkID := "net_123" // Create a text particle input := particle.CreateInput{ Type: particle.TypeText, - NetworkID: net.ID, + NetworkID: networkID, Data: json.RawMessage(`{"content":"Hello"}`), } text, err := svc.Create(ctx, input, "admin@example.com") @@ -300,20 +306,19 @@ func TestParticleService_NotAStream(t *testing.T) { func TestParticleService_AccessInheritance(t *testing.T) { ctx := context.Background() - networkSvc := testhelper.NewNetworkService(t, dbPool) - svc := particle.NewService(dbPool, networkSvc) + svc, mockChecker := newTestService(t) - // Create a network with members - net, err := networkSvc.Create(ctx, "Access Inheritance Test Network", "admin@example.com") - assert.NoError(t, err) + networkID := "net_123" - err = networkSvc.AddMembers(ctx, net.ID, []string{"member@example.com", "other@example.com"}) - assert.NoError(t, err) + // admin is the creator (exempt); member is a network member. + expectIsMember(mockChecker, networkID, map[string]bool{ + "member@example.com": true, + }) // Create a stream with custom visibility (admin and member only) streamInput := particle.CreateInput{ Type: particle.TypeStream, - NetworkID: net.ID, + NetworkID: networkID, Visibility: particle.VisibilityCustom, Members: []string{"admin@example.com", "member@example.com"}, Data: json.RawMessage(`{"name":"Private Stream","status":"open"}`), @@ -324,7 +329,7 @@ func TestParticleService_AccessInheritance(t *testing.T) { // Create a child text (network_all visibility) textInput := particle.CreateInput{ Type: particle.TypeText, - NetworkID: net.ID, + NetworkID: networkID, ParentID: &stream.ID, Data: json.RawMessage(`{"content":"Child text"}`), } @@ -344,3 +349,154 @@ func TestParticleService_AccessInheritance(t *testing.T) { assert.Error(t, err) assert.ErrorIs(t, err, particle.ErrAccessDenied) } + +func TestParticleService_AddMembers(t *testing.T) { + ctx := context.Background() + svc, mockChecker := newTestService(t) + + networkID := "net_addmembers" + + // Create a custom-visibility stream owned by admin. + stream, err := svc.Create(ctx, particle.CreateInput{ + Type: particle.TypeStream, + NetworkID: networkID, + Visibility: particle.VisibilityCustom, + Members: []string{"admin@example.com"}, + Data: json.RawMessage(`{"name":"Members Stream","status":"open"}`), + }, "admin@example.com") + assert.NoError(t, err) + + expectIsMember(mockChecker, networkID, map[string]bool{ + "alice@example.com": true, + "bob@example.com": true, + }) + + err = svc.AddMembers(ctx, stream.ID, + []string{"alice@example.com", "bob@example.com"}, + "admin@example.com") + assert.NoError(t, err) + + members, err := svc.GetMembersMap(ctx, []string{stream.ID}) + assert.NoError(t, err) + assert.Contains(t, members[stream.ID], "alice@example.com") + assert.Contains(t, members[stream.ID], "bob@example.com") +} + +func TestParticleService_AddMembers_RejectsNonNetworkMember(t *testing.T) { + ctx := context.Background() + svc, mockChecker := newTestService(t) + + networkID := "net_addmembers_strict" + + stream, err := svc.Create(ctx, particle.CreateInput{ + Type: particle.TypeStream, + NetworkID: networkID, + Visibility: particle.VisibilityCustom, + Members: []string{"admin@example.com"}, + Data: json.RawMessage(`{"name":"Strict Stream","status":"open"}`), + }, "admin@example.com") + assert.NoError(t, err) + + // alice is in the network and is processed first; stranger is not, so the + // whole operation must abort and neither row should be persisted. + expectIsMember(mockChecker, networkID, map[string]bool{ + "alice@example.com": true, + "stranger@example.com": false, + }) + + err = svc.AddMembers(ctx, stream.ID, + []string{"alice@example.com", "stranger@example.com"}, + "admin@example.com") + assert.ErrorIs(t, err, particle.ErrInvalidMember) + + members, err := svc.GetMembersMap(ctx, []string{stream.ID}) + assert.NoError(t, err) + assert.NotContains(t, members[stream.ID], "alice@example.com") + assert.NotContains(t, members[stream.ID], "stranger@example.com") +} + +func TestParticleService_AddMembers_BubblesCheckerError(t *testing.T) { + ctx := context.Background() + svc, mockChecker := newTestService(t) + + networkID := "net_addmembers_checker_err" + + stream, err := svc.Create(ctx, particle.CreateInput{ + Type: particle.TypeStream, + NetworkID: networkID, + Visibility: particle.VisibilityCustom, + Members: []string{"admin@example.com"}, + Data: json.RawMessage(`{"name":"Stream","status":"open"}`), + }, "admin@example.com") + assert.NoError(t, err) + + checkerErr := errors.New("checker unavailable") + mockChecker.EXPECT(). + IsMember(gomock.Any(), networkID, "alice@example.com"). + Return(false, checkerErr) + + err = svc.AddMembers(ctx, stream.ID, + []string{"alice@example.com"}, "admin@example.com") + assert.ErrorIs(t, err, checkerErr) +} + +func TestParticleService_Create_RejectsNonNetworkMember(t *testing.T) { + ctx := context.Background() + svc, mockChecker := newTestService(t) + + networkID := "net_create_strict" + + // stranger is not in the network — Create must reject the call before + // writing anything to the DB. + expectIsMember(mockChecker, networkID, map[string]bool{ + "stranger@example.com": false, + }) + + _, err := svc.Create(ctx, particle.CreateInput{ + Type: particle.TypeStream, + NetworkID: networkID, + Visibility: particle.VisibilityCustom, + Members: []string{"admin@example.com", "stranger@example.com"}, + Data: json.RawMessage(`{"name":"Strict Stream","status":"open"}`), + }, "admin@example.com") + assert.ErrorIs(t, err, particle.ErrInvalidMember) +} + +func TestParticleService_Create_BubblesCheckerError(t *testing.T) { + ctx := context.Background() + svc, mockChecker := newTestService(t) + + networkID := "net_create_checker_err" + + checkerErr := errors.New("checker unavailable") + mockChecker.EXPECT(). + IsMember(gomock.Any(), networkID, "alice@example.com"). + Return(false, checkerErr) + + _, err := svc.Create(ctx, particle.CreateInput{ + Type: particle.TypeStream, + NetworkID: networkID, + Visibility: particle.VisibilityCustom, + Members: []string{"alice@example.com"}, + Data: json.RawMessage(`{"name":"Stream","status":"open"}`), + }, "admin@example.com") + assert.ErrorIs(t, err, checkerErr) +} + +func TestParticleService_AddMembers_NotAStream(t *testing.T) { + ctx := context.Background() + // No EXPECT calls — AddMembers should fail on type check before hitting the checker. + svc, _ := newTestService(t) + + networkID := "net_addmembers_notstream" + + text, err := svc.Create(ctx, particle.CreateInput{ + Type: particle.TypeText, + NetworkID: networkID, + Data: json.RawMessage(`{"content":"hi"}`), + }, "admin@example.com") + assert.NoError(t, err) + + err = svc.AddMembers(ctx, text.ID, []string{"alice@example.com"}, "admin@example.com") + assert.ErrorIs(t, err, particle.ErrNotAContainer) +} diff --git a/go/internal/pusher/authorizer.go b/go/internal/pusher/authorizer.go index 9f60089..a67f4c6 100644 --- a/go/internal/pusher/authorizer.go +++ b/go/internal/pusher/authorizer.go @@ -12,12 +12,12 @@ var ErrUnauthorized = errors.New("unauthorized") // Authorizer validates whether a user can access a given channel. type Authorizer struct { - networkSvc network.Service + networkReader network.Reader } // NewAuthorizer creates a new channel authorizer. -func NewAuthorizer(networkSvc network.Service) *Authorizer { - return &Authorizer{networkSvc: networkSvc} +func NewAuthorizer(networkReader network.Reader) *Authorizer { + return &Authorizer{networkReader: networkReader} } // Authorize checks if the given humanID is allowed to subscribe to the channel. @@ -51,7 +51,7 @@ func (a *Authorizer) Authorize(ctx context.Context, channelID, humanID string) e } func (a *Authorizer) authorizeNetwork(ctx context.Context, networkID, humanID string) error { - isMember, err := a.networkSvc.IsMember(ctx, networkID, humanID) + isMember, err := a.networkReader.IsMember(ctx, networkID, humanID) if err != nil { return err } diff --git a/go/internal/testhelper/mocks/aero/gen.go b/go/internal/testhelper/mocks/aero/gen.go new file mode 100644 index 0000000..d4de8e5 --- /dev/null +++ b/go/internal/testhelper/mocks/aero/gen.go @@ -0,0 +1,6 @@ +// Package aero hosts a mock for the third-party aero gRPC client. The +// interface is generated protobuf code we don't own, so the //go:generate +// directive lives here rather than next to the source. +package aero + +//go:generate mockgen -destination ./mock_aero.go -package aero github.com/flowy-live/llink/genproto/aero PrimaryClient diff --git a/go/internal/testhelper/mocks/aero/mock_aero.go b/go/internal/testhelper/mocks/aero/mock_aero.go index 0cfd029..5efa903 100644 --- a/go/internal/testhelper/mocks/aero/mock_aero.go +++ b/go/internal/testhelper/mocks/aero/mock_aero.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: /Users/talksik/Documents/code/llink/go/genproto/aero/main_grpc.pb.go +// Source: github.com/flowy-live/llink/genproto/aero (interfaces: PrimaryClient) // // Generated by this command: // -// mockgen -source=/Users/talksik/Documents/code/llink/go/genproto/aero/main_grpc.pb.go -destination=/Users/talksik/Documents/code/llink/go/internal/testhelper/mocks/aero/mock_aero.go -package=aero +// mockgen -destination ./mock_aero.go -package aero github.com/flowy-live/llink/genproto/aero PrimaryClient // // Package aero is a generated GoMock package. @@ -61,90 +61,3 @@ func (mr *MockPrimaryClientMockRecorder) ShootEmail(ctx, in any, opts ...any) *g varargs := append([]any{ctx, in}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ShootEmail", reflect.TypeOf((*MockPrimaryClient)(nil).ShootEmail), varargs...) } - -// MockPrimaryServer is a mock of PrimaryServer interface. -type MockPrimaryServer struct { - ctrl *gomock.Controller - recorder *MockPrimaryServerMockRecorder - isgomock struct{} -} - -// MockPrimaryServerMockRecorder is the mock recorder for MockPrimaryServer. -type MockPrimaryServerMockRecorder struct { - mock *MockPrimaryServer -} - -// NewMockPrimaryServer creates a new mock instance. -func NewMockPrimaryServer(ctrl *gomock.Controller) *MockPrimaryServer { - mock := &MockPrimaryServer{ctrl: ctrl} - mock.recorder = &MockPrimaryServerMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockPrimaryServer) EXPECT() *MockPrimaryServerMockRecorder { - return m.recorder -} - -// ShootEmail mocks base method. -func (m *MockPrimaryServer) ShootEmail(arg0 context.Context, arg1 *pbaero.ShootEmailRequest) (*pbaero.ShootEmailResponse, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ShootEmail", arg0, arg1) - ret0, _ := ret[0].(*pbaero.ShootEmailResponse) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ShootEmail indicates an expected call of ShootEmail. -func (mr *MockPrimaryServerMockRecorder) ShootEmail(arg0, arg1 any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ShootEmail", reflect.TypeOf((*MockPrimaryServer)(nil).ShootEmail), arg0, arg1) -} - -// mustEmbedUnimplementedPrimaryServer mocks base method. -func (m *MockPrimaryServer) mustEmbedUnimplementedPrimaryServer() { - m.ctrl.T.Helper() - m.ctrl.Call(m, "mustEmbedUnimplementedPrimaryServer") -} - -// mustEmbedUnimplementedPrimaryServer indicates an expected call of mustEmbedUnimplementedPrimaryServer. -func (mr *MockPrimaryServerMockRecorder) mustEmbedUnimplementedPrimaryServer() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "mustEmbedUnimplementedPrimaryServer", reflect.TypeOf((*MockPrimaryServer)(nil).mustEmbedUnimplementedPrimaryServer)) -} - -// MockUnsafePrimaryServer is a mock of UnsafePrimaryServer interface. -type MockUnsafePrimaryServer struct { - ctrl *gomock.Controller - recorder *MockUnsafePrimaryServerMockRecorder - isgomock struct{} -} - -// MockUnsafePrimaryServerMockRecorder is the mock recorder for MockUnsafePrimaryServer. -type MockUnsafePrimaryServerMockRecorder struct { - mock *MockUnsafePrimaryServer -} - -// NewMockUnsafePrimaryServer creates a new mock instance. -func NewMockUnsafePrimaryServer(ctrl *gomock.Controller) *MockUnsafePrimaryServer { - mock := &MockUnsafePrimaryServer{ctrl: ctrl} - mock.recorder = &MockUnsafePrimaryServerMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockUnsafePrimaryServer) EXPECT() *MockUnsafePrimaryServerMockRecorder { - return m.recorder -} - -// mustEmbedUnimplementedPrimaryServer mocks base method. -func (m *MockUnsafePrimaryServer) mustEmbedUnimplementedPrimaryServer() { - m.ctrl.T.Helper() - m.ctrl.Call(m, "mustEmbedUnimplementedPrimaryServer") -} - -// mustEmbedUnimplementedPrimaryServer indicates an expected call of mustEmbedUnimplementedPrimaryServer. -func (mr *MockUnsafePrimaryServerMockRecorder) mustEmbedUnimplementedPrimaryServer() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "mustEmbedUnimplementedPrimaryServer", reflect.TypeOf((*MockUnsafePrimaryServer)(nil).mustEmbedUnimplementedPrimaryServer)) -} diff --git a/go/internal/testhelper/mocks/mocknetwork/mock_network.go b/go/internal/testhelper/mocks/mocknetwork/mock_network.go deleted file mode 100644 index 7e9642b..0000000 --- a/go/internal/testhelper/mocks/mocknetwork/mock_network.go +++ /dev/null @@ -1,261 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: /Users/talksik/Documents/code/llink/go/internal/network/service.go -// -// Generated by this command: -// -// mockgen -source=/Users/talksik/Documents/code/llink/go/internal/network/service.go -destination=/Users/talksik/Documents/code/llink/go/internal/testhelper/mocks/mocknetwork/mock_network.go -package=mocknetwork -// - -// Package mocknetwork is a generated GoMock package. -package mocknetwork - -import ( - context "context" - reflect "reflect" - - network "github.com/flowy-live/llink/internal/network" - gomock "go.uber.org/mock/gomock" -) - -// MockService is a mock of Service interface. -type MockService struct { - ctrl *gomock.Controller - recorder *MockServiceMockRecorder - isgomock struct{} -} - -// MockServiceMockRecorder is the mock recorder for MockService. -type MockServiceMockRecorder struct { - mock *MockService -} - -// NewMockService creates a new mock instance. -func NewMockService(ctrl *gomock.Controller) *MockService { - mock := &MockService{ctrl: ctrl} - mock.recorder = &MockServiceMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockService) EXPECT() *MockServiceMockRecorder { - return m.recorder -} - -// AcceptInvitation mocks base method. -func (m *MockService) AcceptInvitation(ctx context.Context, networkID, email, humanId string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "AcceptInvitation", ctx, networkID, email, humanId) - ret0, _ := ret[0].(error) - return ret0 -} - -// AcceptInvitation indicates an expected call of AcceptInvitation. -func (mr *MockServiceMockRecorder) AcceptInvitation(ctx, networkID, email, humanId any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AcceptInvitation", reflect.TypeOf((*MockService)(nil).AcceptInvitation), ctx, networkID, email, humanId) -} - -// AddMembers mocks base method. -func (m *MockService) AddMembers(ctx context.Context, networkID string, humanIds []string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "AddMembers", ctx, networkID, humanIds) - ret0, _ := ret[0].(error) - return ret0 -} - -// AddMembers indicates an expected call of AddMembers. -func (mr *MockServiceMockRecorder) AddMembers(ctx, networkID, humanIds any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddMembers", reflect.TypeOf((*MockService)(nil).AddMembers), ctx, networkID, humanIds) -} - -// CountSeats mocks base method. -func (m *MockService) CountSeats(ctx context.Context, networkID string) (int, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CountSeats", ctx, networkID) - ret0, _ := ret[0].(int) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// CountSeats indicates an expected call of CountSeats. -func (mr *MockServiceMockRecorder) CountSeats(ctx, networkID any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountSeats", reflect.TypeOf((*MockService)(nil).CountSeats), ctx, networkID) -} - -// Create mocks base method. -func (m *MockService) Create(ctx context.Context, name, adminHumanId string) (*network.Network, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", ctx, name, adminHumanId) - ret0, _ := ret[0].(*network.Network) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Create indicates an expected call of Create. -func (mr *MockServiceMockRecorder) Create(ctx, name, adminHumanId any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockService)(nil).Create), ctx, name, adminHumanId) -} - -// GetByID mocks base method. -func (m *MockService) GetByID(ctx context.Context, id string) (*network.Network, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetByID", ctx, id) - ret0, _ := ret[0].(*network.Network) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetByID indicates an expected call of GetByID. -func (mr *MockServiceMockRecorder) GetByID(ctx, id any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByID", reflect.TypeOf((*MockService)(nil).GetByID), ctx, id) -} - -// InviteByEmail mocks base method. -func (m *MockService) InviteByEmail(ctx context.Context, networkID string, emails []string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InviteByEmail", ctx, networkID, emails) - ret0, _ := ret[0].(error) - return ret0 -} - -// InviteByEmail indicates an expected call of InviteByEmail. -func (mr *MockServiceMockRecorder) InviteByEmail(ctx, networkID, emails any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InviteByEmail", reflect.TypeOf((*MockService)(nil).InviteByEmail), ctx, networkID, emails) -} - -// IsMember mocks base method. -func (m *MockService) IsMember(ctx context.Context, networkID, humanId string) (bool, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "IsMember", ctx, networkID, humanId) - ret0, _ := ret[0].(bool) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// IsMember indicates an expected call of IsMember. -func (mr *MockServiceMockRecorder) IsMember(ctx, networkID, humanId any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsMember", reflect.TypeOf((*MockService)(nil).IsMember), ctx, networkID, humanId) -} - -// ListAll mocks base method. -func (m *MockService) ListAll(ctx context.Context) ([]*network.Network, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListAll", ctx) - ret0, _ := ret[0].([]*network.Network) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ListAll indicates an expected call of ListAll. -func (mr *MockServiceMockRecorder) ListAll(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAll", reflect.TypeOf((*MockService)(nil).ListAll), ctx) -} - -// ListAllMemberships mocks base method. -func (m *MockService) ListAllMemberships(ctx context.Context) (map[string][]string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListAllMemberships", ctx) - ret0, _ := ret[0].(map[string][]string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ListAllMemberships indicates an expected call of ListAllMemberships. -func (mr *MockServiceMockRecorder) ListAllMemberships(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAllMemberships", reflect.TypeOf((*MockService)(nil).ListAllMemberships), ctx) -} - -// ListForHuman mocks base method. -func (m *MockService) ListForHuman(ctx context.Context, humanId string) ([]*network.Network, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListForHuman", ctx, humanId) - ret0, _ := ret[0].([]*network.Network) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ListForHuman indicates an expected call of ListForHuman. -func (mr *MockServiceMockRecorder) ListForHuman(ctx, humanId any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListForHuman", reflect.TypeOf((*MockService)(nil).ListForHuman), ctx, humanId) -} - -// ListInvitationsForEmail mocks base method. -func (m *MockService) ListInvitationsForEmail(ctx context.Context, email string) ([]*network.Invitation, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListInvitationsForEmail", ctx, email) - ret0, _ := ret[0].([]*network.Invitation) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ListInvitationsForEmail indicates an expected call of ListInvitationsForEmail. -func (mr *MockServiceMockRecorder) ListInvitationsForEmail(ctx, email any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListInvitationsForEmail", reflect.TypeOf((*MockService)(nil).ListInvitationsForEmail), ctx, email) -} - -// ListInvitationsForNetwork mocks base method. -func (m *MockService) ListInvitationsForNetwork(ctx context.Context, networkID string) ([]*network.Invitation, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListInvitationsForNetwork", ctx, networkID) - ret0, _ := ret[0].([]*network.Invitation) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ListInvitationsForNetwork indicates an expected call of ListInvitationsForNetwork. -func (mr *MockServiceMockRecorder) ListInvitationsForNetwork(ctx, networkID any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListInvitationsForNetwork", reflect.TypeOf((*MockService)(nil).ListInvitationsForNetwork), ctx, networkID) -} - -// RemoveMember mocks base method. -func (m *MockService) RemoveMember(ctx context.Context, networkID, humanId string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RemoveMember", ctx, networkID, humanId) - ret0, _ := ret[0].(error) - return ret0 -} - -// RemoveMember indicates an expected call of RemoveMember. -func (mr *MockServiceMockRecorder) RemoveMember(ctx, networkID, humanId any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveMember", reflect.TypeOf((*MockService)(nil).RemoveMember), ctx, networkID, humanId) -} - -// RevokeInvitation mocks base method. -func (m *MockService) RevokeInvitation(ctx context.Context, networkID, email string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RevokeInvitation", ctx, networkID, email) - ret0, _ := ret[0].(error) - return ret0 -} - -// RevokeInvitation indicates an expected call of RevokeInvitation. -func (mr *MockServiceMockRecorder) RevokeInvitation(ctx, networkID, email any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeInvitation", reflect.TypeOf((*MockService)(nil).RevokeInvitation), ctx, networkID, email) -} - -// SetName mocks base method. -func (m *MockService) SetName(ctx context.Context, id, name string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetName", ctx, id, name) - ret0, _ := ret[0].(error) - return ret0 -} - -// SetName indicates an expected call of SetName. -func (mr *MockServiceMockRecorder) SetName(ctx, id, name any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetName", reflect.TypeOf((*MockService)(nil).SetName), ctx, id, name) -} diff --git a/go/internal/testhelper/network.go b/go/internal/testhelper/network.go deleted file mode 100644 index f959885..0000000 --- a/go/internal/testhelper/network.go +++ /dev/null @@ -1,23 +0,0 @@ -package testhelper - -import ( - "testing" - - pbaero "github.com/flowy-live/llink/genproto/aero" - "github.com/flowy-live/llink/internal/billing" - "github.com/flowy-live/llink/internal/network" - "github.com/flowy-live/llink/internal/testhelper/mocks/aero" - "github.com/jackc/pgx/v5/pgxpool" - "go.uber.org/mock/gomock" -) - -func NewNetworkService(t *testing.T, pool *pgxpool.Pool) network.Service { - t.Helper() - ctrl := gomock.NewController(t) - mockAero := aero.NewMockPrimaryClient(ctrl) - mockAero.EXPECT(). - ShootEmail(gomock.Any(), gomock.Any()). - Return(&pbaero.ShootEmailResponse{}, nil). - AnyTimes() - return network.NewService(pool, mockAero, billing.Noop(), nil) -} diff --git a/go/migrations/000015_particle_drop_network_fk.down.sql b/go/migrations/000015_particle_drop_network_fk.down.sql new file mode 100644 index 0000000..aec4a52 --- /dev/null +++ b/go/migrations/000015_particle_drop_network_fk.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE particles + ADD CONSTRAINT particles_network_id_fkey + FOREIGN KEY (network_id) REFERENCES networks(id) ON DELETE CASCADE; diff --git a/go/migrations/000015_particle_drop_network_fk.up.sql b/go/migrations/000015_particle_drop_network_fk.up.sql new file mode 100644 index 0000000..2d60ee0 --- /dev/null +++ b/go/migrations/000015_particle_drop_network_fk.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE particles + DROP CONSTRAINT IF EXISTS particles_network_id_fkey;