refactor: unit testable units and cleaner dep injection
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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", "[email protected]")
|
||||
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, "[email protected]")
|
||||
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", "[email protected]")
|
||||
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, "[email protected]")
|
||||
@@ -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, "[email protected]", particle.ListFilter{}, nil, 50)
|
||||
children, err := svc.List(ctx, networkID, &stream.ID, "[email protected]", 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", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
networkID := "net_123"
|
||||
|
||||
err = networkSvc.AddMembers(ctx, net.ID, []string{"member@example.com", "[email protected]"})
|
||||
assert.NoError(t, err)
|
||||
// admin is the creator (exempt from the check); member is a network member.
|
||||
expectIsMember(mockChecker, networkID, map[string]bool{
|
||||
"[email protected]": 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{"[email protected]", "[email protected]"},
|
||||
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", "[email protected]")
|
||||
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, "[email protected]")
|
||||
@@ -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", "[email protected]")
|
||||
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, "[email protected]")
|
||||
@@ -216,24 +228,21 @@ func TestParticleService_ListRootParticles(t *testing.T) {
|
||||
}
|
||||
|
||||
// List root particles (parentID = nil)
|
||||
list, err := svc.List(ctx, net.ID, nil, "[email protected]", particle.ListFilter{}, nil, 50)
|
||||
list, err := svc.List(ctx, networkID, nil, "[email protected]", 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", "[email protected]")
|
||||
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, "[email protected]")
|
||||
@@ -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", "[email protected]")
|
||||
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, "[email protected]")
|
||||
@@ -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", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
networkID := "net_123"
|
||||
|
||||
err = networkSvc.AddMembers(ctx, net.ID, []string{"member@example.com", "[email protected]"})
|
||||
assert.NoError(t, err)
|
||||
// admin is the creator (exempt); member is a network member.
|
||||
expectIsMember(mockChecker, networkID, map[string]bool{
|
||||
"[email protected]": 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{"[email protected]", "[email protected]"},
|
||||
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{"[email protected]"},
|
||||
Data: json.RawMessage(`{"name":"Members Stream","status":"open"}`),
|
||||
}, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
expectIsMember(mockChecker, networkID, map[string]bool{
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
})
|
||||
|
||||
err = svc.AddMembers(ctx, stream.ID,
|
||||
[]string{"[email protected]", "[email protected]"},
|
||||
"[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
members, err := svc.GetMembersMap(ctx, []string{stream.ID})
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, members[stream.ID], "[email protected]")
|
||||
assert.Contains(t, members[stream.ID], "[email protected]")
|
||||
}
|
||||
|
||||
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{"[email protected]"},
|
||||
Data: json.RawMessage(`{"name":"Strict Stream","status":"open"}`),
|
||||
}, "[email protected]")
|
||||
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{
|
||||
"[email protected]": true,
|
||||
"[email protected]": false,
|
||||
})
|
||||
|
||||
err = svc.AddMembers(ctx, stream.ID,
|
||||
[]string{"[email protected]", "[email protected]"},
|
||||
"[email protected]")
|
||||
assert.ErrorIs(t, err, particle.ErrInvalidMember)
|
||||
|
||||
members, err := svc.GetMembersMap(ctx, []string{stream.ID})
|
||||
assert.NoError(t, err)
|
||||
assert.NotContains(t, members[stream.ID], "[email protected]")
|
||||
assert.NotContains(t, members[stream.ID], "[email protected]")
|
||||
}
|
||||
|
||||
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{"[email protected]"},
|
||||
Data: json.RawMessage(`{"name":"Stream","status":"open"}`),
|
||||
}, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
checkerErr := errors.New("checker unavailable")
|
||||
mockChecker.EXPECT().
|
||||
IsMember(gomock.Any(), networkID, "[email protected]").
|
||||
Return(false, checkerErr)
|
||||
|
||||
err = svc.AddMembers(ctx, stream.ID,
|
||||
[]string{"[email protected]"}, "[email protected]")
|
||||
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{
|
||||
"[email protected]": false,
|
||||
})
|
||||
|
||||
_, err := svc.Create(ctx, particle.CreateInput{
|
||||
Type: particle.TypeStream,
|
||||
NetworkID: networkID,
|
||||
Visibility: particle.VisibilityCustom,
|
||||
Members: []string{"[email protected]", "[email protected]"},
|
||||
Data: json.RawMessage(`{"name":"Strict Stream","status":"open"}`),
|
||||
}, "[email protected]")
|
||||
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, "[email protected]").
|
||||
Return(false, checkerErr)
|
||||
|
||||
_, err := svc.Create(ctx, particle.CreateInput{
|
||||
Type: particle.TypeStream,
|
||||
NetworkID: networkID,
|
||||
Visibility: particle.VisibilityCustom,
|
||||
Members: []string{"[email protected]"},
|
||||
Data: json.RawMessage(`{"name":"Stream","status":"open"}`),
|
||||
}, "[email protected]")
|
||||
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"}`),
|
||||
}, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = svc.AddMembers(ctx, text.ID, []string{"[email protected]"}, "[email protected]")
|
||||
assert.ErrorIs(t, err, particle.ErrNotAContainer)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE particles
|
||||
ADD CONSTRAINT particles_network_id_fkey
|
||||
FOREIGN KEY (network_id) REFERENCES networks(id) ON DELETE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE particles
|
||||
DROP CONSTRAINT IF EXISTS particles_network_id_fkey;
|
||||
Reference in New Issue
Block a user