503 lines
16 KiB
Go
503 lines
16 KiB
Go
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)
|
|
}
|