refactor: unit testable units and cleaner dep injection
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user