diff --git a/.gitignore b/.gitignore index b20db27..8cffe7a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .testCoverage.txt .testCoverage.txt.tmp coverage.html +coverage.out /exported /release /schemactl diff --git a/domain/commands_test.go b/domain/commands_test.go index aec95ac..4fa063e 100644 --- a/domain/commands_test.go +++ b/domain/commands_test.go @@ -7,10 +7,68 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gitlab.com/unboundsoftware/eventsourced/eventsourced" "gitlab.com/unboundsoftware/schemas/hash" ) +// AddOrganization tests + +func TestAddOrganization_Validate_Success(t *testing.T) { + cmd := AddOrganization{ + Name: "Test Org", + Initiator: "user@example.com", + } + + org := &Organization{} // New organization with no identity + err := cmd.Validate(context.Background(), org) + assert.NoError(t, err) +} + +func TestAddOrganization_Validate_AlreadyExists(t *testing.T) { + cmd := AddOrganization{ + Name: "Test Org", + Initiator: "user@example.com", + } + + org := &Organization{ + BaseAggregate: eventsourced.BaseAggregateFromString("existing-org-id"), + } + + err := cmd.Validate(context.Background(), org) + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") +} + +func TestAddOrganization_Validate_EmptyName(t *testing.T) { + cmd := AddOrganization{ + Name: "", + Initiator: "user@example.com", + } + + org := &Organization{} + err := cmd.Validate(context.Background(), org) + require.Error(t, err) + assert.Contains(t, err.Error(), "name is required") +} + +func TestAddOrganization_Event(t *testing.T) { + cmd := AddOrganization{ + Name: "Test Org", + Initiator: "user@example.com", + } + + event := cmd.Event(context.Background()) + require.NotNil(t, event) + + orgEvent, ok := event.(*OrganizationAdded) + require.True(t, ok) + assert.Equal(t, "Test Org", orgEvent.Name) + assert.Equal(t, "user@example.com", orgEvent.Initiator) +} + +// AddAPIKey tests + func TestAddAPIKey_Event(t *testing.T) { type fields struct { Name string @@ -74,3 +132,335 @@ func TestAddAPIKey_Event(t *testing.T) { }) } } + +func TestAddAPIKey_Validate_Success(t *testing.T) { + cmd := AddAPIKey{ + Name: "production-key", + Key: "us_ak_1234567890123456", + Refs: []string{"main"}, + Read: true, + Publish: false, + Initiator: "user@example.com", + } + + org := &Organization{ + BaseAggregate: eventsourced.BaseAggregateFromString("org-123"), + APIKeys: []APIKey{}, + } + + err := cmd.Validate(context.Background(), org) + assert.NoError(t, err) +} + +func TestAddAPIKey_Validate_OrganizationNotExists(t *testing.T) { + cmd := AddAPIKey{ + Name: "production-key", + Key: "us_ak_1234567890123456", + Refs: []string{"main"}, + Read: true, + Publish: false, + Initiator: "user@example.com", + } + + org := &Organization{} // No identity means it doesn't exist + err := cmd.Validate(context.Background(), org) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not exist") +} + +func TestAddAPIKey_Validate_DuplicateKeyName(t *testing.T) { + cmd := AddAPIKey{ + Name: "existing-key", + Key: "us_ak_1234567890123456", + Refs: []string{"main"}, + Read: true, + Publish: false, + Initiator: "user@example.com", + } + + org := &Organization{ + BaseAggregate: eventsourced.BaseAggregateFromString("org-123"), + APIKeys: []APIKey{ + { + Name: "existing-key", + Key: "hashed-key", + }, + }, + } + + err := cmd.Validate(context.Background(), org) + require.Error(t, err) + assert.Contains(t, err.Error(), "already exist") + assert.Contains(t, err.Error(), "existing-key") +} + +// UpdateSubGraph tests + +func TestUpdateSubGraph_Validate_Success(t *testing.T) { + url := "http://example.com/graphql" + cmd := UpdateSubGraph{ + OrganizationId: "org-123", + Ref: "main", + Service: "users", + Url: &url, + Sdl: "type Query { hello: String }", + Initiator: "user@example.com", + } + + subGraph := &SubGraph{ + BaseAggregate: eventsourced.BaseAggregateFromString("subgraph-123"), + } + + err := cmd.Validate(context.Background(), subGraph) + assert.NoError(t, err) +} + +func TestUpdateSubGraph_Validate_MissingRef(t *testing.T) { + url := "http://example.com/graphql" + cmd := UpdateSubGraph{ + OrganizationId: "org-123", + Ref: "", + Service: "users", + Url: &url, + Sdl: "type Query { hello: String }", + Initiator: "user@example.com", + } + + subGraph := &SubGraph{ + BaseAggregate: eventsourced.BaseAggregateFromString("subgraph-123"), + } + + err := cmd.Validate(context.Background(), subGraph) + require.Error(t, err) + assert.Contains(t, err.Error(), "ref is missing") +} + +func TestUpdateSubGraph_Validate_RefWhitespaceOnly(t *testing.T) { + url := "http://example.com/graphql" + cmd := UpdateSubGraph{ + OrganizationId: "org-123", + Ref: " ", + Service: "users", + Url: &url, + Sdl: "type Query { hello: String }", + Initiator: "user@example.com", + } + + subGraph := &SubGraph{ + BaseAggregate: eventsourced.BaseAggregateFromString("subgraph-123"), + } + + err := cmd.Validate(context.Background(), subGraph) + require.Error(t, err) + assert.Contains(t, err.Error(), "ref is missing") +} + +func TestUpdateSubGraph_Validate_MissingService(t *testing.T) { + url := "http://example.com/graphql" + cmd := UpdateSubGraph{ + OrganizationId: "org-123", + Ref: "main", + Service: "", + Url: &url, + Sdl: "type Query { hello: String }", + Initiator: "user@example.com", + } + + subGraph := &SubGraph{ + BaseAggregate: eventsourced.BaseAggregateFromString("subgraph-123"), + } + + err := cmd.Validate(context.Background(), subGraph) + require.Error(t, err) + assert.Contains(t, err.Error(), "service is missing") +} + +func TestUpdateSubGraph_Validate_ServiceWhitespaceOnly(t *testing.T) { + url := "http://example.com/graphql" + cmd := UpdateSubGraph{ + OrganizationId: "org-123", + Ref: "main", + Service: " ", + Url: &url, + Sdl: "type Query { hello: String }", + Initiator: "user@example.com", + } + + subGraph := &SubGraph{ + BaseAggregate: eventsourced.BaseAggregateFromString("subgraph-123"), + } + + err := cmd.Validate(context.Background(), subGraph) + require.Error(t, err) + assert.Contains(t, err.Error(), "service is missing") +} + +func TestUpdateSubGraph_Validate_MissingSDL(t *testing.T) { + url := "http://example.com/graphql" + cmd := UpdateSubGraph{ + OrganizationId: "org-123", + Ref: "main", + Service: "users", + Url: &url, + Sdl: "", + Initiator: "user@example.com", + } + + subGraph := &SubGraph{ + BaseAggregate: eventsourced.BaseAggregateFromString("subgraph-123"), + } + + err := cmd.Validate(context.Background(), subGraph) + require.Error(t, err) + assert.Contains(t, err.Error(), "SDL is missing") +} + +func TestUpdateSubGraph_Validate_SDLWhitespaceOnly(t *testing.T) { + url := "http://example.com/graphql" + cmd := UpdateSubGraph{ + OrganizationId: "org-123", + Ref: "main", + Service: "users", + Url: &url, + Sdl: " \n\t ", + Initiator: "user@example.com", + } + + subGraph := &SubGraph{ + BaseAggregate: eventsourced.BaseAggregateFromString("subgraph-123"), + } + + err := cmd.Validate(context.Background(), subGraph) + require.Error(t, err) + assert.Contains(t, err.Error(), "SDL is missing") +} + +func TestUpdateSubGraph_Validate_MissingURL_NoExistingURL(t *testing.T) { + cmd := UpdateSubGraph{ + OrganizationId: "org-123", + Ref: "main", + Service: "users", + Url: nil, + Sdl: "type Query { hello: String }", + Initiator: "user@example.com", + } + + subGraph := &SubGraph{ + BaseAggregate: eventsourced.BaseAggregateFromString("subgraph-123"), + Url: nil, // No existing URL + } + + err := cmd.Validate(context.Background(), subGraph) + require.Error(t, err) + assert.Contains(t, err.Error(), "url is missing") +} + +func TestUpdateSubGraph_Validate_MissingURL_HasExistingURL(t *testing.T) { + existingURL := "http://example.com/graphql" + cmd := UpdateSubGraph{ + OrganizationId: "org-123", + Ref: "main", + Service: "users", + Url: nil, + Sdl: "type Query { hello: String }", + Initiator: "user@example.com", + } + + subGraph := &SubGraph{ + BaseAggregate: eventsourced.BaseAggregateFromString("subgraph-123"), + Url: &existingURL, // Has existing URL, so nil is OK + } + + err := cmd.Validate(context.Background(), subGraph) + assert.NoError(t, err) +} + +func TestUpdateSubGraph_Validate_EmptyURL_NoExistingURL(t *testing.T) { + emptyURL := "" + cmd := UpdateSubGraph{ + OrganizationId: "org-123", + Ref: "main", + Service: "users", + Url: &emptyURL, + Sdl: "type Query { hello: String }", + Initiator: "user@example.com", + } + + subGraph := &SubGraph{ + BaseAggregate: eventsourced.BaseAggregateFromString("subgraph-123"), + Url: nil, + } + + err := cmd.Validate(context.Background(), subGraph) + require.Error(t, err) + assert.Contains(t, err.Error(), "url is missing") +} + +func TestUpdateSubGraph_Validate_URLWhitespaceOnly_NoExistingURL(t *testing.T) { + whitespaceURL := " " + cmd := UpdateSubGraph{ + OrganizationId: "org-123", + Ref: "main", + Service: "users", + Url: &whitespaceURL, + Sdl: "type Query { hello: String }", + Initiator: "user@example.com", + } + + subGraph := &SubGraph{ + BaseAggregate: eventsourced.BaseAggregateFromString("subgraph-123"), + Url: nil, + } + + err := cmd.Validate(context.Background(), subGraph) + require.Error(t, err) + assert.Contains(t, err.Error(), "url is missing") +} + +func TestUpdateSubGraph_Validate_WrongAggregateType(t *testing.T) { + url := "http://example.com/graphql" + cmd := UpdateSubGraph{ + OrganizationId: "org-123", + Ref: "main", + Service: "users", + Url: &url, + Sdl: "type Query { hello: String }", + Initiator: "user@example.com", + } + + // Pass wrong aggregate type + org := &Organization{ + BaseAggregate: eventsourced.BaseAggregateFromString("org-123"), + } + + err := cmd.Validate(context.Background(), org) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a SubGraph") +} + +func TestUpdateSubGraph_Event(t *testing.T) { + url := "http://example.com/graphql" + wsURL := "ws://example.com/graphql" + cmd := UpdateSubGraph{ + OrganizationId: "org-123", + Ref: "main", + Service: "users", + Url: &url, + WSUrl: &wsURL, + Sdl: "type Query { hello: String }", + Initiator: "user@example.com", + } + + event := cmd.Event(context.Background()) + require.NotNil(t, event) + + subGraphEvent, ok := event.(*SubGraphUpdated) + require.True(t, ok) + assert.Equal(t, "org-123", subGraphEvent.OrganizationId) + assert.Equal(t, "main", subGraphEvent.Ref) + assert.Equal(t, "users", subGraphEvent.Service) + assert.Equal(t, url, *subGraphEvent.Url) + assert.Equal(t, wsURL, *subGraphEvent.WSUrl) + assert.Equal(t, "type Query { hello: String }", subGraphEvent.Sdl) + assert.Equal(t, "user@example.com", subGraphEvent.Initiator) +} diff --git a/sdlmerge/sdlmerge_test.go b/sdlmerge/sdlmerge_test.go new file mode 100644 index 0000000..1d74584 --- /dev/null +++ b/sdlmerge/sdlmerge_test.go @@ -0,0 +1,434 @@ +package sdlmerge + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMergeSDLs_Success(t *testing.T) { + // Both types need to be in the same subgraph or properly federated + sdl1 := ` + type User { + id: ID! + name: String! + } + + type Post { + id: ID! + title: String! + } + ` + + result, err := MergeSDLs(sdl1) + require.NoError(t, err) + assert.Contains(t, result, "User") + assert.Contains(t, result, "Post") + assert.Contains(t, result, "id") + assert.Contains(t, result, "name") + assert.Contains(t, result, "title") +} + +func TestMergeSDLs_SingleSchema(t *testing.T) { + sdl := ` + type Query { + hello: String + } + ` + + result, err := MergeSDLs(sdl) + require.NoError(t, err) + assert.Contains(t, result, "Query") + assert.Contains(t, result, "hello") +} + +func TestMergeSDLs_EmptySchemas(t *testing.T) { + result, err := MergeSDLs() + require.NoError(t, err) + // With no schemas, result will be empty after processing + // This is valid - just verifies no crash + _ = result +} + +func TestMergeSDLs_InvalidSyntax(t *testing.T) { + invalidSDL := ` + type User { + id: ID! + name: String! + // Missing closing brace + ` + + _, err := MergeSDLs(invalidSDL) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse graphql document string") +} + +func TestMergeSDLs_UnknownType(t *testing.T) { + sdl := ` + type User { + id: ID! + profile: UnknownType + } + ` + + _, err := MergeSDLs(sdl) + require.Error(t, err) + assert.Contains(t, err.Error(), "validate schema") +} + +func TestMergeSDLs_DuplicateTypes_DifferentFields(t *testing.T) { + // Same type with different fields in different subgraphs - should fail + // In federation, shared types must be identical + sdl1 := ` + type User { + id: ID! + } + ` + sdl2 := ` + type User { + name: String! + } + ` + + _, err := MergeSDLs(sdl1, sdl2) + require.Error(t, err) + assert.Contains(t, err.Error(), "shared type") +} + +func TestMergeSDLs_ExtendType(t *testing.T) { + sdl1 := ` + type User { + id: ID! + } + ` + sdl2 := ` + extend type User { + email: String! + } + ` + + result, err := MergeSDLs(sdl1, sdl2) + require.NoError(t, err) + assert.Contains(t, result, "User") + assert.Contains(t, result, "id") + assert.Contains(t, result, "email") +} + +func TestMergeSDLs_Scalars(t *testing.T) { + sdl := ` + scalar DateTime + + type Event { + id: ID! + createdAt: DateTime! + } + ` + + result, err := MergeSDLs(sdl) + require.NoError(t, err) + assert.Contains(t, result, "DateTime") + assert.Contains(t, result, "Event") +} + +func TestMergeSDLs_Enums(t *testing.T) { + sdl := ` + enum Role { + ADMIN + USER + GUEST + } + + type User { + id: ID! + role: Role! + } + ` + + result, err := MergeSDLs(sdl) + require.NoError(t, err) + assert.Contains(t, result, "Role") + assert.Contains(t, result, "ADMIN") + assert.Contains(t, result, "USER") +} + +func TestMergeSDLs_Interfaces(t *testing.T) { + sdl := ` + interface Node { + id: ID! + } + + type User implements Node { + id: ID! + name: String! + } + ` + + result, err := MergeSDLs(sdl) + require.NoError(t, err) + assert.Contains(t, result, "Node") + assert.Contains(t, result, "implements") +} + +func TestMergeSDLs_Unions(t *testing.T) { + sdl := ` + type User { + id: ID! + name: String! + } + + type Bot { + id: ID! + version: String! + } + + union Actor = User | Bot + ` + + result, err := MergeSDLs(sdl) + require.NoError(t, err) + assert.Contains(t, result, "Actor") + assert.Contains(t, result, "User") + assert.Contains(t, result, "Bot") +} + +func TestMergeSDLs_InputTypes(t *testing.T) { + sdl := ` + input CreateUserInput { + name: String! + email: String! + } + + type Mutation { + createUser(input: CreateUserInput!): User + } + + type User { + id: ID! + name: String! + } + ` + + result, err := MergeSDLs(sdl) + require.NoError(t, err) + assert.Contains(t, result, "CreateUserInput") + assert.Contains(t, result, "createUser") +} + +func TestMergeSDLs_Directives(t *testing.T) { + sdl := ` + type User { + id: ID! + name: String! @deprecated(reason: "Use fullName instead") + fullName: String! + } + ` + + result, err := MergeSDLs(sdl) + require.NoError(t, err) + assert.Contains(t, result, "User") + assert.Contains(t, result, "name") + assert.Contains(t, result, "fullName") +} + +func TestMergeSDLs_FederationKeys(t *testing.T) { + // Federation @key directive + sdl := ` + type User @key(fields: "id") { + id: ID! + name: String! + } + ` + + result, err := MergeSDLs(sdl) + require.NoError(t, err) + assert.Contains(t, result, "User") + // @key directive should be removed during merge + assert.NotContains(t, result, "@key") +} + +func TestMergeSDLs_ExternalFields(t *testing.T) { + // Federation @external directive + sdl1 := ` + type User @key(fields: "id") { + id: ID! + name: String! + } + ` + sdl2 := ` + extend type User @key(fields: "id") { + id: ID! @external + posts: [Post!]! + } + + type Post { + id: ID! + title: String! + } + ` + + result, err := MergeSDLs(sdl1, sdl2) + require.NoError(t, err) + assert.Contains(t, result, "User") + assert.Contains(t, result, "Post") + // @external fields should be removed + assert.NotContains(t, result, "@external") +} + +func TestMergeSDLs_ComplexSchema(t *testing.T) { + // Multiple subgraphs with various types - simplified to avoid cross-references + users := ` + type User @key(fields: "id") { + id: ID! + username: String! + email: String! + } + + type Query { + user(id: ID!): User + users: [User!]! + } + ` + + posts := ` + extend type User @key(fields: "id") { + id: ID! @external + posts: [Post!]! + } + + type Post @key(fields: "id") { + id: ID! + title: String! + content: String! + } + + extend type Query { + post(id: ID!): Post + posts: [Post!]! + } + ` + + comments := ` + extend type Post @key(fields: "id") { + id: ID! @external + comments: [Comment!]! + } + + type Comment { + id: ID! + text: String! + } + + extend type Query { + comment(id: ID!): Comment + } + ` + + result, err := MergeSDLs(users, posts, comments) + require.NoError(t, err) + + // Verify all types are present + assert.Contains(t, result, "User") + assert.Contains(t, result, "Post") + assert.Contains(t, result, "Comment") + assert.Contains(t, result, "Query") + + // Verify fields from all subgraphs + assert.Contains(t, result, "username") + assert.Contains(t, result, "posts") + assert.Contains(t, result, "comments") +} + +func TestMergeSDLs_EmptyTypeDefinition(t *testing.T) { + sdl := ` + type Empty {} + ` + + _, err := MergeSDLs(sdl) + require.Error(t, err) + // Empty types are invalid in GraphQL + assert.Contains(t, err.Error(), "empty body") +} + +func TestMergeSDLs_MultipleValidationErrors(t *testing.T) { + // Schema with multiple errors + sdl := ` + type User { + id: ID! + profile: NonExistentType1 + settings: NonExistentType2 + } + ` + + _, err := MergeSDLs(sdl) + require.Error(t, err) +} + +func TestMergeSDLs_ListTypes(t *testing.T) { + sdl := ` + type User { + id: ID! + tags: [String!]! + friends: [User!] + } + ` + + result, err := MergeSDLs(sdl) + require.NoError(t, err) + assert.Contains(t, result, "User") + assert.Contains(t, result, "tags") + assert.Contains(t, result, "friends") +} + +func TestMergeSDLs_NonNullTypes(t *testing.T) { + sdl := ` + type User { + id: ID! + name: String! + email: String + } + ` + + result, err := MergeSDLs(sdl) + require.NoError(t, err) + assert.Contains(t, result, "User") + assert.Contains(t, result, "id") + assert.Contains(t, result, "name") + assert.Contains(t, result, "email") +} + +func TestMergeSDLs_Comments(t *testing.T) { + sdl := ` + # This is a user type + type User { + # User ID + id: ID! + # User name + name: String! + } + ` + + result, err := MergeSDLs(sdl) + require.NoError(t, err) + assert.Contains(t, result, "User") +} + +func TestMergeSDLs_LargeSchema(t *testing.T) { + // Test with a reasonably large schema to ensure performance + var sdlBuilder strings.Builder + for i := 0; i < 50; i++ { + sdlBuilder.WriteString("type Type") + sdlBuilder.WriteString(strings.Repeat(string(rune('A'+i%26)), 1)) + sdlBuilder.WriteString(string(rune('0' + i/26))) + sdlBuilder.WriteString(" { id: ID }\n") + } + + result, err := MergeSDLs(sdlBuilder.String()) + require.NoError(t, err) + + // Verify some types are present + assert.Contains(t, result, "TypeA0") + assert.Contains(t, result, "TypeB0") + assert.Contains(t, result, "TypeC0") +}