package particle_test import ( "context" "encoding/json" "os" "testing" "github.com/flowy-live/llink/internal/network" "github.com/flowy-live/llink/internal/particle" "github.com/flowy-live/llink/internal/testhelper" "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/assert" ) 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 } func TestParticleService_CreateAndGet(t *testing.T) { ctx := context.Background() networkSvc := network.NewService(dbPool) svc := particle.NewService(dbPool, networkSvc) // Create a network first net, err := networkSvc.Create(ctx, "Test Network", "admin@example.com") assert.NoError(t, err) // 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, 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, net.ID, 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_StreamCapacity(t *testing.T) { ctx := context.Background() networkSvc := network.NewService(dbPool) svc := particle.NewService(dbPool, networkSvc) // Create a network with capacity 2 net, err := networkSvc.Create(ctx, "Capacity Test Network", "admin@example.com") assert.NoError(t, err) err = networkSvc.SetOpenStreamCapacity(ctx, net.ID, 2) assert.NoError(t, err) // Create first stream - should succeed input := particle.CreateInput{ Type: particle.TypeStream, NetworkID: net.ID, Data: json.RawMessage(`{"name":"Stream 1","status":"open"}`), } stream1, err := svc.Create(ctx, input, "admin@example.com") assert.NoError(t, err) // Create second stream - should succeed input.Data = json.RawMessage(`{"name":"Stream 2","status":"open"}`) stream2, err := svc.Create(ctx, input, "admin@example.com") assert.NoError(t, err) // Create third stream - should fail with capacity exceeded input.Data = json.RawMessage(`{"name":"Stream 3","status":"open"}`) _, err = svc.Create(ctx, input, "admin@example.com") assert.Error(t, err) assert.ErrorIs(t, err, particle.ErrCapacityExceeded) // Close a stream err = svc.CloseStream(ctx, stream1.ID, "admin@example.com") assert.NoError(t, err) // Now we can create another stream stream3, err := svc.Create(ctx, input, "admin@example.com") assert.NoError(t, err) assert.NotEmpty(t, stream3.ID) // Verify stream2 is still open found, err := svc.GetByID(ctx, stream2.ID, "admin@example.com") assert.NoError(t, err) assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(found.Data)) // Verify stream1 is closed found, err = svc.GetByID(ctx, stream1.ID, "admin@example.com") assert.NoError(t, err) assert.Equal(t, string(particle.StreamStatusClosed), getStreamStatus(found.Data)) } func TestParticleService_NestedParticles(t *testing.T) { ctx := context.Background() networkSvc := network.NewService(dbPool) svc := particle.NewService(dbPool, networkSvc) // Create a network net, err := networkSvc.Create(ctx, "Nested Test Network", "admin@example.com") assert.NoError(t, err) // Create a parent stream streamInput := particle.CreateInput{ Type: particle.TypeStream, NetworkID: net.ID, 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: net.ID, 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: net.ID, 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, net.ID, &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() networkSvc := network.NewService(dbPool) svc := particle.NewService(dbPool, networkSvc) // Create a network with a member net, err := networkSvc.Create(ctx, "Visibility Test Network", "admin@example.com") assert.NoError(t, err) err = networkSvc.AddMembers(ctx, net.ID, []string{"member@example.com", "other@example.com"}) assert.NoError(t, err) // Create a stream with custom visibility including only admin and member streamInput := particle.CreateInput{ Type: particle.TypeStream, NetworkID: net.ID, 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() networkSvc := network.NewService(dbPool) svc := particle.NewService(dbPool, networkSvc) // Create a network net, err := networkSvc.Create(ctx, "Update Test Network", "admin@example.com") assert.NoError(t, err) // Create a text particle input := particle.CreateInput{ Type: particle.TypeText, NetworkID: net.ID, 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() networkSvc := network.NewService(dbPool) svc := particle.NewService(dbPool, networkSvc) // Create a network net, err := networkSvc.Create(ctx, "List Test Network", "admin@example.com") assert.NoError(t, err) // Create multiple root particles for i := 0; i < 3; i++ { input := particle.CreateInput{ Type: particle.TypeStream, NetworkID: net.ID, 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, net.ID, 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() networkSvc := network.NewService(dbPool) svc := particle.NewService(dbPool, networkSvc) // Create a network net, err := networkSvc.Create(ctx, "Open Close Test Network", "admin@example.com") assert.NoError(t, err) // Create a stream input := particle.CreateInput{ Type: particle.TypeStream, NetworkID: net.ID, 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() networkSvc := network.NewService(dbPool) svc := particle.NewService(dbPool, networkSvc) // Create a network net, err := networkSvc.Create(ctx, "Not Stream Test Network", "admin@example.com") assert.NoError(t, err) // Create a text particle input := particle.CreateInput{ Type: particle.TypeText, NetworkID: net.ID, 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() networkSvc := network.NewService(dbPool) svc := particle.NewService(dbPool, networkSvc) // Create a network with members net, err := networkSvc.Create(ctx, "Access Inheritance Test Network", "admin@example.com") assert.NoError(t, err) err = networkSvc.AddMembers(ctx, net.ID, []string{"member@example.com", "other@example.com"}) assert.NoError(t, err) // Create a stream with custom visibility (admin and member only) streamInput := particle.CreateInput{ Type: particle.TypeStream, NetworkID: net.ID, 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: net.ID, 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) }