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 func TestMain(m *testing.M) { dbPool = testhelper.SetupTestDB() defer testhelper.TeardownTestDB() ret := m.Run() os.Exit(ret) } func getStreamStatus(data json.RawMessage) string { var d struct { Status string `json:"status"` } json.Unmarshal(data, &d) 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() 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: networkID, Data: data, } created, err := svc.Create(ctx, input, "admin@example.com") assert.NoError(t, err) assert.NotEmpty(t, created.ID) assert.Equal(t, particle.TypeStream, created.Type) assert.Equal(t, networkID, created.NetworkID) assert.Nil(t, created.ParentID) assert.Equal(t, particle.VisibilityNetworkAll, created.Visibility) assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(created.Data)) // Test GetByID found, err := svc.GetByID(ctx, created.ID, "admin@example.com") assert.NoError(t, err) assert.Equal(t, created.ID, found.ID) // Test GetByID with non-existent id _, err = svc.GetByID(ctx, "particle_nonexistent", "admin@example.com") assert.Error(t, err) assert.ErrorIs(t, err, particle.ErrNotFound) // Note: Network membership check is handler's responsibility // Service assumes caller is already verified as network member } func TestParticleService_NestedParticles(t *testing.T) { ctx := context.Background() svc, _ := newTestService(t) networkID := "net_123" // Create a parent stream streamInput := particle.CreateInput{ Type: particle.TypeStream, NetworkID: networkID, Data: json.RawMessage(`{"name":"Parent Stream","status":"open"}`), } stream, err := svc.Create(ctx, streamInput, "admin@example.com") assert.NoError(t, err) // Create a text particle as child textInput := particle.CreateInput{ Type: particle.TypeText, NetworkID: networkID, ParentID: &stream.ID, Data: json.RawMessage(`{"content":"Hello world"}`), } text, err := svc.Create(ctx, textInput, "admin@example.com") assert.NoError(t, err) assert.Equal(t, stream.ID, *text.ParentID) // Create a file as child of stream fileInput := particle.CreateInput{ Type: particle.TypeFile, NetworkID: networkID, ParentID: &stream.ID, Data: json.RawMessage(`{"object_id":"obj_abc123","filename":"test.pdf","mime_type":"application/pdf","size":1024}`), } file, err := svc.Create(ctx, fileInput, "admin@example.com") assert.NoError(t, err) assert.Equal(t, stream.ID, *file.ParentID) // List children of stream children, err := svc.List(ctx, networkID, &stream.ID, "admin@example.com", particle.ListFilter{}, nil, 50) assert.NoError(t, err) assert.Len(t, children.Particles, 2) } func TestParticleService_CustomVisibility(t *testing.T) { ctx := context.Background() svc, mockChecker := newTestService(t) networkID := "net_123" // admin is the creator (exempt from the check); member is a network member. expectIsMember(mockChecker, networkID, map[string]bool{ "member@example.com": true, }) // Create a stream with custom visibility including only admin and member streamInput := particle.CreateInput{ Type: particle.TypeStream, NetworkID: networkID, Visibility: particle.VisibilityCustom, Members: []string{"admin@example.com", "member@example.com"}, Data: json.RawMessage(`{"name":"Private Stream","status":"open"}`), } stream, err := svc.Create(ctx, streamInput, "admin@example.com") assert.NoError(t, err) // Admin can access _, err = svc.GetByID(ctx, stream.ID, "admin@example.com") assert.NoError(t, err) // Member can access _, err = svc.GetByID(ctx, stream.ID, "member@example.com") assert.NoError(t, err) // Other network member cannot access _, err = svc.GetByID(ctx, stream.ID, "other@example.com") assert.Error(t, err) assert.ErrorIs(t, err, particle.ErrAccessDenied) // Non-network member cannot access _, err = svc.GetByID(ctx, stream.ID, "stranger@example.com") assert.Error(t, err) assert.ErrorIs(t, err, particle.ErrAccessDenied) } func TestParticleService_UpdateAndDelete(t *testing.T) { ctx := context.Background() svc, _ := newTestService(t) networkID := "net_123" // Create a text particle input := particle.CreateInput{ Type: particle.TypeText, NetworkID: networkID, Data: json.RawMessage(`{"content":"Original content"}`), } created, err := svc.Create(ctx, input, "admin@example.com") assert.NoError(t, err) // Update the particle newData := json.RawMessage(`{"content":"Updated content"}`) updated, err := svc.Update(ctx, created.ID, newData, "admin@example.com") assert.NoError(t, err) // PostgreSQL normalizes JSON, so compare unmarshaled values var expected, actual map[string]interface{} json.Unmarshal(newData, &expected) json.Unmarshal(updated.Data, &actual) assert.Equal(t, expected, actual) // Delete the particle err = svc.Delete(ctx, created.ID, "admin@example.com") assert.NoError(t, err) // Verify it's gone _, err = svc.GetByID(ctx, created.ID, "admin@example.com") assert.Error(t, err) assert.ErrorIs(t, err, particle.ErrNotFound) } func TestParticleService_ListRootParticles(t *testing.T) { ctx := context.Background() svc, _ := newTestService(t) networkID := "net_123" // Create multiple root particles for i := 0; i < 3; i++ { input := particle.CreateInput{ Type: particle.TypeStream, NetworkID: networkID, Data: json.RawMessage(`{"name":"Stream","status":"open"}`), } _, err := svc.Create(ctx, input, "admin@example.com") assert.NoError(t, err) } // List root particles (parentID = nil) list, err := svc.List(ctx, networkID, nil, "admin@example.com", particle.ListFilter{}, nil, 50) assert.NoError(t, err) assert.GreaterOrEqual(t, len(list.Particles), 3) } func TestParticleService_OpenCloseStream(t *testing.T) { ctx := context.Background() svc, _ := newTestService(t) networkID := "net_123" // Create a stream input := particle.CreateInput{ Type: particle.TypeStream, NetworkID: networkID, Data: json.RawMessage(`{"name":"Test Stream","status":"open"}`), } stream, err := svc.Create(ctx, input, "admin@example.com") assert.NoError(t, err) assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(stream.Data)) // Close the stream err = svc.CloseStream(ctx, stream.ID, "admin@example.com") assert.NoError(t, err) // Verify it's closed found, err := svc.GetByID(ctx, stream.ID, "admin@example.com") assert.NoError(t, err) assert.Equal(t, string(particle.StreamStatusClosed), getStreamStatus(found.Data)) // Try to close again - should error err = svc.CloseStream(ctx, stream.ID, "admin@example.com") assert.Error(t, err) assert.ErrorIs(t, err, particle.ErrStreamAlreadyClosed) // Reopen the stream err = svc.OpenStream(ctx, stream.ID, "admin@example.com") assert.NoError(t, err) // Verify it's open found, err = svc.GetByID(ctx, stream.ID, "admin@example.com") assert.NoError(t, err) assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(found.Data)) // Try to open again - should error err = svc.OpenStream(ctx, stream.ID, "admin@example.com") assert.Error(t, err) assert.ErrorIs(t, err, particle.ErrStreamAlreadyOpen) } func TestParticleService_NotAStream(t *testing.T) { ctx := context.Background() svc, _ := newTestService(t) networkID := "net_123" // Create a text particle input := particle.CreateInput{ Type: particle.TypeText, NetworkID: networkID, Data: json.RawMessage(`{"content":"Hello"}`), } text, err := svc.Create(ctx, input, "admin@example.com") assert.NoError(t, err) // Try to open it as a stream err = svc.OpenStream(ctx, text.ID, "admin@example.com") assert.Error(t, err) assert.ErrorIs(t, err, particle.ErrNotAStream) // Try to close it as a stream err = svc.CloseStream(ctx, text.ID, "admin@example.com") assert.Error(t, err) assert.ErrorIs(t, err, particle.ErrNotAStream) } func TestParticleService_AccessInheritance(t *testing.T) { ctx := context.Background() svc, mockChecker := newTestService(t) networkID := "net_123" // admin is the creator (exempt); member is a network member. expectIsMember(mockChecker, networkID, map[string]bool{ "member@example.com": true, }) // Create a stream with custom visibility (admin and member only) streamInput := particle.CreateInput{ Type: particle.TypeStream, NetworkID: networkID, Visibility: particle.VisibilityCustom, Members: []string{"admin@example.com", "member@example.com"}, Data: json.RawMessage(`{"name":"Private Stream","status":"open"}`), } stream, err := svc.Create(ctx, streamInput, "admin@example.com") assert.NoError(t, err) // Create a child text (network_all visibility) textInput := particle.CreateInput{ Type: particle.TypeText, NetworkID: networkID, ParentID: &stream.ID, Data: json.RawMessage(`{"content":"Child text"}`), } text, err := svc.Create(ctx, textInput, "admin@example.com") assert.NoError(t, err) // Admin can access child _, err = svc.GetByID(ctx, text.ID, "admin@example.com") assert.NoError(t, err) // Member can access child _, err = svc.GetByID(ctx, text.ID, "member@example.com") assert.NoError(t, err) // Other cannot access child (even though child is network_all, parent restricts) _, err = svc.GetByID(ctx, text.ID, "other@example.com") assert.Error(t, err) assert.ErrorIs(t, err, particle.ErrAccessDenied) } func TestParticleService_AddMembers(t *testing.T) { ctx := context.Background() svc, mockChecker := newTestService(t) networkID := "net_addmembers" // Create a custom-visibility stream owned by admin. stream, err := svc.Create(ctx, particle.CreateInput{ Type: particle.TypeStream, NetworkID: networkID, Visibility: particle.VisibilityCustom, Members: []string{"admin@example.com"}, Data: json.RawMessage(`{"name":"Members Stream","status":"open"}`), }, "admin@example.com") assert.NoError(t, err) expectIsMember(mockChecker, networkID, map[string]bool{ "alice@example.com": true, "bob@example.com": true, }) err = svc.AddMembers(ctx, stream.ID, []string{"alice@example.com", "bob@example.com"}, "admin@example.com") assert.NoError(t, err) members, err := svc.GetMembersMap(ctx, []string{stream.ID}) assert.NoError(t, err) assert.Contains(t, members[stream.ID], "alice@example.com") assert.Contains(t, members[stream.ID], "bob@example.com") } func TestParticleService_AddMembers_RejectsNonNetworkMember(t *testing.T) { ctx := context.Background() svc, mockChecker := newTestService(t) networkID := "net_addmembers_strict" stream, err := svc.Create(ctx, particle.CreateInput{ Type: particle.TypeStream, NetworkID: networkID, Visibility: particle.VisibilityCustom, Members: []string{"admin@example.com"}, Data: json.RawMessage(`{"name":"Strict Stream","status":"open"}`), }, "admin@example.com") assert.NoError(t, err) // alice is in the network and is processed first; stranger is not, so the // whole operation must abort and neither row should be persisted. expectIsMember(mockChecker, networkID, map[string]bool{ "alice@example.com": true, "stranger@example.com": false, }) err = svc.AddMembers(ctx, stream.ID, []string{"alice@example.com", "stranger@example.com"}, "admin@example.com") assert.ErrorIs(t, err, particle.ErrInvalidMember) members, err := svc.GetMembersMap(ctx, []string{stream.ID}) assert.NoError(t, err) assert.NotContains(t, members[stream.ID], "alice@example.com") assert.NotContains(t, members[stream.ID], "stranger@example.com") } func TestParticleService_AddMembers_BubblesCheckerError(t *testing.T) { ctx := context.Background() svc, mockChecker := newTestService(t) networkID := "net_addmembers_checker_err" stream, err := svc.Create(ctx, particle.CreateInput{ Type: particle.TypeStream, NetworkID: networkID, Visibility: particle.VisibilityCustom, Members: []string{"admin@example.com"}, Data: json.RawMessage(`{"name":"Stream","status":"open"}`), }, "admin@example.com") assert.NoError(t, err) checkerErr := errors.New("checker unavailable") mockChecker.EXPECT(). IsMember(gomock.Any(), networkID, "alice@example.com"). Return(false, checkerErr) err = svc.AddMembers(ctx, stream.ID, []string{"alice@example.com"}, "admin@example.com") assert.ErrorIs(t, err, checkerErr) } func TestParticleService_Create_RejectsNonNetworkMember(t *testing.T) { ctx := context.Background() svc, mockChecker := newTestService(t) networkID := "net_create_strict" // stranger is not in the network — Create must reject the call before // writing anything to the DB. expectIsMember(mockChecker, networkID, map[string]bool{ "stranger@example.com": false, }) _, err := svc.Create(ctx, particle.CreateInput{ Type: particle.TypeStream, NetworkID: networkID, Visibility: particle.VisibilityCustom, Members: []string{"admin@example.com", "stranger@example.com"}, Data: json.RawMessage(`{"name":"Strict Stream","status":"open"}`), }, "admin@example.com") assert.ErrorIs(t, err, particle.ErrInvalidMember) } func TestParticleService_Create_BubblesCheckerError(t *testing.T) { ctx := context.Background() svc, mockChecker := newTestService(t) networkID := "net_create_checker_err" checkerErr := errors.New("checker unavailable") mockChecker.EXPECT(). IsMember(gomock.Any(), networkID, "alice@example.com"). Return(false, checkerErr) _, err := svc.Create(ctx, particle.CreateInput{ Type: particle.TypeStream, NetworkID: networkID, Visibility: particle.VisibilityCustom, Members: []string{"alice@example.com"}, Data: json.RawMessage(`{"name":"Stream","status":"open"}`), }, "admin@example.com") assert.ErrorIs(t, err, checkerErr) } func TestParticleService_AddMembers_NotAStream(t *testing.T) { ctx := context.Background() // No EXPECT calls — AddMembers should fail on type check before hitting the checker. svc, _ := newTestService(t) networkID := "net_addmembers_notstream" text, err := svc.Create(ctx, particle.CreateInput{ Type: particle.TypeText, NetworkID: networkID, Data: json.RawMessage(`{"content":"hi"}`), }, "admin@example.com") assert.NoError(t, err) err = svc.AddMembers(ctx, text.ID, []string{"alice@example.com"}, "admin@example.com") assert.ErrorIs(t, err, particle.ErrNotAContainer) }