refactor: unit testable units and cleaner dep injection

This commit is contained in:
Arjun Patel
2026-04-27 16:07:28 -07:00
parent 013c453dd3
commit 48d6d5cb07
26 changed files with 761 additions and 617 deletions
+215 -59
View File
@@ -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)
}