diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index f00ef3d6..661b5ac9 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -38,7 +38,7 @@ jobs: run: echo "Artifact ID from previous job is $COV_FILE_ID" - name: Checkout source code for this repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # ratchet:actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # ratchet:actions/checkout@v5 - name: Install Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # ratchet:actions/setup-go@v5 @@ -60,7 +60,7 @@ jobs: printf 'Coverage Value: %s\n' "$current_coverage" - name: Download Main Coverage - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # ratchet:actions/download-artifact@v8 with: artifact-ids: ${{ needs.call-run-upload-main-coverage-files.outputs.coverage-artifact-id }} path: tmp/ @@ -87,9 +87,11 @@ jobs: integration-test-pg: name: Run integration tests (pg) runs-on: ubuntu-latest + permissions: + contents: read services: postgres: - image: postgres:18 + image: index.docker.io/library/postgres@sha256:06cad38a5d9f5d24b4d83d86def30795d5e4b757fedbf5281172b576dedcd941 # ratchet:postgres:18 env: POSTGRES_USER: dawgs POSTGRES_PASSWORD: weneedbetterpasswords @@ -103,7 +105,9 @@ jobs: --health-retries 5 steps: - name: Checkout source code for this repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # ratchet:actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # ratchet:actions/checkout@v5 + with: + persist-credentials: false - name: Install Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # ratchet:actions/setup-go@v5 @@ -121,9 +125,11 @@ jobs: integration-test-neo4j: name: Run integration tests (neo4j) runs-on: ubuntu-latest + permissions: + contents: read services: neo4j: - image: neo4j:4.4.42 + image: index.docker.io/library/neo4j@sha256:73b5a47eb9952f47fcc42fcc80bdd6bd0d6b71eea9a1a345c216675498c790e4 # ratchet:neo4j:4.4.42 env: NEO4J_AUTH: neo4j/weneedbetterpasswords ports: @@ -136,7 +142,9 @@ jobs: --health-start-period 40s steps: - name: Checkout source code for this repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # ratchet:actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # ratchet:actions/checkout@v5 + with: + persist-credentials: false - name: Install Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # ratchet:actions/setup-go@v5 @@ -150,3 +158,78 @@ jobs: CONNECTION_STRING: neo4j://neo4j:weneedbetterpasswords@localhost:7687 run: | make test_integration + + bdd-integration-test-pg: + name: Run BDD integration tests (pg) + runs-on: ubuntu-latest + permissions: + contents: read + services: + postgres: + image: index.docker.io/library/postgres@sha256:06cad38a5d9f5d24b4d83d86def30795d5e4b757fedbf5281172b576dedcd941 # ratchet:postgres:18 + env: + POSTGRES_USER: dawgs + POSTGRES_PASSWORD: weneedbetterpasswords + POSTGRES_DB: dawgs + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U dawgs -d dawgs" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: Checkout source code for this repository + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # ratchet:actions/checkout@v5 + with: + persist-credentials: false + + - name: Install Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # ratchet:actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + check-latest: true + + - name: Run BDD integration tests + env: + CONNECTION_STRING: postgres://dawgs:weneedbetterpasswords@localhost:5432/dawgs?sslmode=disable + run: | + make test_bdd_integration + + bdd-integration-test-neo4j: + name: Run BDD integration tests (neo4j) + runs-on: ubuntu-latest + permissions: + contents: read + services: + neo4j: + image: index.docker.io/library/neo4j@sha256:73b5a47eb9952f47fcc42fcc80bdd6bd0d6b71eea9a1a345c216675498c790e4 # ratchet:neo4j:4.4.42 + env: + NEO4J_AUTH: neo4j/weneedbetterpasswords + ports: + - 7687:7687 + options: >- + --health-cmd "wget -q -O /dev/null http://localhost:7474 || exit 1" + --health-interval 15s + --health-timeout 10s + --health-retries 10 + --health-start-period 40s + steps: + - name: Checkout source code for this repository + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # ratchet:actions/checkout@v5 + with: + persist-credentials: false + + - name: Install Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # ratchet:actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + check-latest: true + + - name: Run BDD integration tests + env: + CONNECTION_STRING: neo4j://neo4j:weneedbetterpasswords@localhost:7687 + run: | + make test_bdd_integration diff --git a/Makefile b/Makefile index 31cad4a9..af713148 100644 --- a/Makefile +++ b/Makefile @@ -56,7 +56,7 @@ QUALITY_INPUTS += -mutation-report $(MUTATION_REPORT) endif QUALITY_INPUTS += -benchmark-regression $(BENCHMARK_REGRESSION) -.PHONY: default all build deps tidy lint format test test_all test_integration test_neo4j test_pg test_update plan_corpus complexity complexity_check crap crap_check quality quality_check quality_backend quality_bench metrics metrics_check generate clean help +.PHONY: default all build deps tidy lint format test test_all test_integration test_bdd_integration test_neo4j test_pg test_update plan_corpus complexity complexity_check crap crap_check quality quality_check quality_backend quality_bench metrics metrics_check generate clean help # Default target default: help @@ -92,12 +92,14 @@ test: $(METRICS_DIR) @$(GO_CMD) tool cover -func=$(COVERAGE_PROFILE) > $(COVERAGE_FUNC_REPORT) @echo "Coverage report written to $(COVERAGE_FUNC_REPORT)" -test_all: test test_integration +test_all: test test_integration test_bdd_integration test_integration: @echo "Running all integration tests..." @$(GO_CMD) test -tags 'manual_integration integration' -race -cover -count=1 -p=1 -parallel=1 $(MAIN_PACKAGES) - +test_bdd_integration: + @echo "Running all BDD integration tests..." + @$(GO_CMD) test -v -tags 'bdd_integration' -count=1 ./bdd test_bench: @echo "Running benchmarks..." @$(GO_CMD) test -run '^$$' -bench '$(BENCH)' -benchmem -count=$(BENCH_COUNT) -benchtime=$(BENCH_TIME) $(MAIN_PACKAGES) diff --git a/README.md b/README.md index 39fec353..bd9d1731 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,18 @@ make test Run integration tests when a backend is available: +Export the `CONNECTION_STRING` environment variable for the PostgreSQL or Neo4j driver. + ```bash export CONNECTION_STRING="postgresql://dawgs:weneedbetterpasswords@localhost:65432/dawgs" make test_integration ``` +```bash +export CONNECTION_STRING="postgresql://dawgs:weneedbetterpasswords@localhost:65432/dawgs" +make test_bdd_integration +``` + Use this module from another Go project: ```bash diff --git a/bdd/features/matching.feature b/bdd/features/matching.feature new file mode 100644 index 00000000..00e8a93a --- /dev/null +++ b/bdd/features/matching.feature @@ -0,0 +1,28 @@ +Feature: Match nodes + + Scenario: Match non existed nodes + Given an empty graph + When executing query: + """ + MATCH (n) + RETURN n + """ + Then the result should be: + | n | + + Scenario: Matching all nodes + Given an empty graph + And having executed: + """ + CREATE (:A), (:B {prefix: 'c', name: 'b'}), ({name: 'c'}) + """ + When executing query: + """ + MATCH (n) + RETURN n + """ + Then the result should be: + | n | + | (:A) | + | (:B{name: 'b', prefix: 'c'}) | + | ({name: 'c'}) | diff --git a/bdd/steps_test.go b/bdd/steps_test.go new file mode 100644 index 00000000..9842d626 --- /dev/null +++ b/bdd/steps_test.go @@ -0,0 +1,84 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// + +//go:build bdd_integration + +package bdd + +import ( + "context" + "log" + "testing" + + "github.com/cucumber/godog" + "github.com/specterops/dawgs/graph" + "github.com/specterops/dawgs/integration" +) + +func InitializeTestSuite(ctxtestsuite *godog.TestSuiteContext, ctx context.Context, c *dbContext) { + err := c.db.WriteTransaction(ctx, func(tx graph.Transaction) error { + if err := tx.Nodes().Delete(); err != nil { + return err + } + return nil + }) + if err != nil { + log.Fatalf("Failed to clear database %v", err) + } +} + +func InitializeScenario(ctx *godog.ScenarioContext, dbCtx *dbContext) { + ctx.Step(`^an empty graph$`, dbCtx.anEmptyGraph) + ctx.Step(`^having executed:$`, dbCtx.havingExecuted) + ctx.Step(`^executing query:$`, dbCtx.executingQuery) + ctx.Step(`^the result should be:$`, dbCtx.theResultShouldBe) +} + +func TestFeatures(t *testing.T) { + backgroundCtx := context.Background() + // establish database connection + session := integration.Open(t, integration.Options{ + Schema: &graph.Schema{ + DefaultGraph: graph.Graph{ + Name: "dawgs-bdd", + }, + }, + }) + + dbCtx := &dbContext{ + db: session.DB, + } + suite := godog.TestSuite{ + Name: "OpenCypher-TCK", + TestSuiteInitializer: func(ctx *godog.TestSuiteContext) { + InitializeTestSuite(ctx, backgroundCtx, dbCtx) + }, + ScenarioInitializer: func(ctx *godog.ScenarioContext) { + InitializeScenario(ctx, dbCtx) + }, + Options: &godog.Options{ + Format: "pretty", + // TODO create env variable pointing to the TCK features via CI + Paths: []string{"features"}, + TestingT: t, + }, + } + + if num := suite.Run(); num != 0 { + t.Fatalf("TestSuite execution failed with status %d", num) + } +} diff --git a/bdd/utils.go b/bdd/utils.go new file mode 100644 index 00000000..b1e7badf --- /dev/null +++ b/bdd/utils.go @@ -0,0 +1,187 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package bdd + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/cucumber/godog" + "github.com/specterops/dawgs/graph" +) + +type dbContext struct { + db graph.Database + rowCount int + actualRows []string +} + +// anEmptyGraph removes all nodes to give each scenario a clean graph. +func (c *dbContext) anEmptyGraph(ctx context.Context) error { + err := c.db.WriteTransaction(ctx, func(tx graph.Transaction) error { + if err := tx.Nodes().Delete(); err != nil { + return err + } + return nil + }) + if err != nil { + return err + } + return nil +} + +// executingQuery runs a read query and records its rows for later comparison. +func (c *dbContext) executingQuery(ctx context.Context, input *godog.DocString) error { + c.actualRows = nil + + err := c.db.ReadTransaction(ctx, func(tx graph.Transaction) error { + var rowCount int64 + result := tx.Query(input.Content, nil) + + defer result.Close() + + for result.Next() { + var nodes []graph.Node + + rowCount++ + + for _, value := range result.Values() { + var node graph.Node + + mapper := result.Mapper() + mapper.Map(value, &node) + nodes = append(nodes, node) + } + + // format graph nodes and their properties into a cypher query + formatted, err := formatGraphResults(nodes) + if err != nil { + return fmt.Errorf("failed to format graph result: %w", err) + } + c.actualRows = append(c.actualRows, formatted...) + } + + c.rowCount = int(rowCount) + if result.Error() != nil { + return result.Error() + } + return nil + }) + + if err != nil { + return err + } + + return nil +} + +// havingExecuted runs a write query to seed the graph for a scenario. +func (c *dbContext) havingExecuted(ctx context.Context, input *godog.DocString) error { + err := c.db.WriteTransaction(ctx, func(tx graph.Transaction) error { + result := tx.Query(input.Content, nil) + + defer result.Close() + + if result.Error() != nil { + return result.Error() + } + return nil + }) + if err != nil { + return err + } + return nil +} + +// theResultShouldBe compares the recorded query rows with the expected table. +func (c *dbContext) theResultShouldBe(expectedTable *godog.Table) error { + var expectedRows []string + for _, value := range expectedTable.Rows { + for _, cell := range value.Cells { + if cell.Value != "n" { + expectedRows = append(expectedRows, formatString(cell.Value)) + } + } + } + + if c.rowCount != len(expectedRows) { + return fmt.Errorf("Invalid row count expected %d actual %d", len(expectedRows), c.rowCount) + } + + for i := 0; i < len(expectedRows); i++ { + // TODO normalize exptected actual rows by sorting kinds and their properties + if formatString(expectedRows[i]) != formatString(c.actualRows[i]) { + return fmt.Errorf("Detected a drift expected %s, actual %s", expectedRows[i], c.actualRows[i]) + } + } + + return nil +} + +func formatString(s string) string { + removeSpace := strings.ReplaceAll(s, " ", "") + if strings.Contains(s, `"`) { + return strings.ReplaceAll(removeSpace, `"`, `'`) + } + return removeSpace +} + +func formatGraphResults(nodes []graph.Node) ([]string, error) { + sb := strings.Builder{} + for _, node := range nodes { + for _, kind := range node.Kinds { + sb.WriteString("(:") + sb.WriteString(kind.String()) + } + if len(node.Kinds) == 0 { + sb.WriteString("(") + } + if node.Properties.Len() != 0 { + // TODO sort node properties of feature files + slices.Sort(node.Properties.Keys(nil)) + sb.WriteString("{") + for index, key := range node.Properties.Keys(nil) { + if index > 0 { + sb.WriteString(", ") + } + value := node.Properties.Get(key) + sb.WriteString(key) + sb.WriteString(": ") + strValue, _ := value.String() + sb.WriteString("'") + sb.WriteString(strValue) + sb.WriteString("'") + } + sb.WriteString("}") + } + if node.Properties != nil { + sb.WriteString(")\n") + } + } + + var result []string + list := strings.Split(sb.String(), "\n") + for _, item := range list { + if item != "" { + result = append(result, item) + } + } + + return result, nil +} diff --git a/bdd/utils_test.go b/bdd/utils_test.go new file mode 100644 index 00000000..ffeda0df --- /dev/null +++ b/bdd/utils_test.go @@ -0,0 +1,407 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +package bdd + +import ( + "context" + "errors" + "testing" + + "github.com/cucumber/godog" + "github.com/cucumber/messages/go/v34" + "github.com/specterops/dawgs/graph" + "github.com/stretchr/testify/require" +) + +type stubDatabase struct { + graph.Database + + transaction graph.Transaction + readTransactionCalled bool + readTransactionContext context.Context + writeTransactionErr error + writeTransactionCalled bool + writeTransactionContext context.Context +} + +func (s *stubDatabase) ReadTransaction(ctx context.Context, delegate graph.TransactionDelegate, _ ...graph.TransactionOption) error { + s.readTransactionCalled = true + s.readTransactionContext = ctx + + return delegate(s.transaction) +} + +func (s *stubDatabase) WriteTransaction(ctx context.Context, delegate graph.TransactionDelegate, _ ...graph.TransactionOption) error { + s.writeTransactionCalled = true + s.writeTransactionContext = ctx + + if s.writeTransactionErr != nil { + return s.writeTransactionErr + } + + return delegate(s.transaction) +} + +type stubTransaction struct { + graph.Transaction + + nodeQuery graph.NodeQuery + result graph.Result + queryCalled bool + query string + queryParameters map[string]any +} + +func (s *stubTransaction) Nodes() graph.NodeQuery { + return s.nodeQuery +} + +func (s *stubTransaction) Query(query string, parameters map[string]any) graph.Result { + s.queryCalled = true + s.query = query + s.queryParameters = parameters + + return s.result +} + +type stubResult struct { + graph.Result + + rows [][]any + currentRow int + err error + closed bool +} + +func (s *stubResult) Next() bool { + if s.currentRow+1 >= len(s.rows) { + return false + } + + s.currentRow++ + return true +} + +func (s *stubResult) Values() []any { + return s.rows[s.currentRow] +} + +func (s *stubResult) Mapper() graph.ValueMapper { + return graph.NewValueMapper(func(value, target any) bool { + node, valueIsNode := value.(graph.Node) + targetNode, targetIsNode := target.(*graph.Node) + if !valueIsNode || !targetIsNode { + return false + } + + *targetNode = node + return true + }) +} + +func (s *stubResult) Error() error { + return s.err +} + +func (s *stubResult) Close() { + s.closed = true +} + +type stubNodeQuery struct { + graph.NodeQuery + + deleteErr error + deleteCalled bool +} + +func (s *stubNodeQuery) Delete() error { + s.deleteCalled = true + return s.deleteErr +} + +func TestDBContextAnEmptyGraph(t *testing.T) { + deleteErr := errors.New("failed to delete nodes") + writeTransactionErr := errors.New("failed to open write transaction") + + tests := []struct { + name string + deleteErr error + writeTransactionErr error + expectDelete bool + expectedErr error + }{ + { + name: "deletes all nodes", + expectDelete: true, + }, + { + name: "returns node deletion error", + deleteErr: deleteErr, + expectDelete: true, + expectedErr: deleteErr, + }, + { + name: "returns write transaction error", + writeTransactionErr: writeTransactionErr, + expectedErr: writeTransactionErr, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + testContext := context.WithValue(context.Background(), struct{}{}, test.name) + nodeQuery := &stubNodeQuery{deleteErr: test.deleteErr} + database := &stubDatabase{ + transaction: &stubTransaction{nodeQuery: nodeQuery}, + writeTransactionErr: test.writeTransactionErr, + } + context := &dbContext{db: database} + + err := context.anEmptyGraph(testContext) + + if test.expectedErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, test.expectedErr) + } + require.True(t, database.writeTransactionCalled) + require.Equal(t, testContext, database.writeTransactionContext) + require.Equal(t, test.expectDelete, nodeQuery.deleteCalled) + }) + } +} + +func TestDBContextExecutingQuery(t *testing.T) { + nodeA := *graph.NewNode(1, graph.NewProperties(), graph.StringKind("A")) + nodeB := *graph.NewNode(2, graph.NewProperties(), graph.StringKind("B")) + nodeC := *graph.NewNode(3, graph.NewProperties(), graph.StringKind("C")) + result := &stubResult{ + rows: [][]any{{nodeA, nodeB}, {nodeC}}, + currentRow: -1, + } + transaction := &stubTransaction{result: result} + database := &stubDatabase{transaction: transaction} + databaseContext := &dbContext{ + db: database, + rowCount: 99, + actualRows: []string{"stale result"}, + } + testContext := context.Background() + query := "MATCH (n) RETURN n" + + err := databaseContext.executingQuery(testContext, &godog.DocString{Content: query}) + + require.NoError(t, err) + require.True(t, database.readTransactionCalled) + require.Equal(t, testContext, database.readTransactionContext) + require.True(t, transaction.queryCalled) + require.Equal(t, query, transaction.query) + require.Nil(t, transaction.queryParameters) + require.True(t, result.closed) + require.Equal(t, 2, databaseContext.rowCount) + require.Equal(t, []string{"(:A)", "(:B)", "(:C)"}, databaseContext.actualRows) +} + +func TestDBContextHavingExecuted(t *testing.T) { + resultErr := errors.New("query failed") + writeTransactionErr := errors.New("write transaction failed") + + tests := []struct { + name string + resultErr error + writeTransactionErr error + expectQuery bool + expectClose bool + expectedErr error + }{ + { + name: "executes write query", + expectQuery: true, + expectClose: true, + }, + { + name: "returns result error", + resultErr: resultErr, + expectQuery: true, + expectClose: true, + expectedErr: resultErr, + }, + { + name: "returns write transaction error", + writeTransactionErr: writeTransactionErr, + expectedErr: writeTransactionErr, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := &stubResult{err: test.resultErr} + transaction := &stubTransaction{result: result} + database := &stubDatabase{ + transaction: transaction, + writeTransactionErr: test.writeTransactionErr, + } + databaseContext := &dbContext{db: database} + testContext := context.Background() + query := "CREATE (:A)" + + err := databaseContext.havingExecuted(testContext, &godog.DocString{Content: query}) + + if test.expectedErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, test.expectedErr) + } + require.True(t, database.writeTransactionCalled) + require.Equal(t, testContext, database.writeTransactionContext) + require.Equal(t, test.expectQuery, transaction.queryCalled) + require.Equal(t, test.expectClose, result.closed) + + if test.expectQuery { + require.Equal(t, query, transaction.query) + require.Nil(t, transaction.queryParameters) + } + }) + } +} + +func TestDBContextTheResultShouldBe(t *testing.T) { + tests := []struct { + name string + context dbContext + expectedTable *godog.Table + expectedError string + }{ + { + name: "matches normalized rows", + context: dbContext{ + rowCount: 2, + actualRows: []string{"(:A{name:'a'})", "(:B)"}, + }, + expectedTable: newResultTable(`(:A {name: "a"})`, "(:B)"), + }, + { + name: "matches an empty result", + expectedTable: newResultTable(), + }, + { + name: "returns row count mismatch", + context: dbContext{ + rowCount: 1, + actualRows: []string{"(:A)"}, + }, + expectedTable: newResultTable("(:A)", "(:B)"), + expectedError: "Invalid row count expected 2 actual 1", + }, + { + name: "returns row content drift", + context: dbContext{ + rowCount: 1, + actualRows: []string{"(:B)"}, + }, + expectedTable: newResultTable("(:A)"), + expectedError: "Detected a drift expected (:A), actual (:B)", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.context.theResultShouldBe(test.expectedTable) + + if test.expectedError == "" { + require.NoError(t, err) + } else { + require.EqualError(t, err, test.expectedError) + } + }) + } +} + +func newResultTable(values ...string) *godog.Table { + rows := []*messages.PickleTableRow{ + {Cells: []*messages.PickleTableCell{{Value: "n"}}}, + } + + for _, value := range values { + rows = append(rows, &messages.PickleTableRow{ + Cells: []*messages.PickleTableCell{{Value: value}}, + }) + } + + return &godog.Table{Rows: rows} +} + +func TestFormatGraphResults(t *testing.T) { + nodes := []graph.Node{ + { + ID: 1, + Kinds: graph.Kinds{graph.StringKind("A")}, + Properties: &graph.Properties{ + Map: map[string]any{"name": "a"}, + }, + }, + { + ID: 2, + Kinds: graph.Kinds{graph.StringKind("B")}, + Properties: &graph.Properties{ + Map: map[string]any{"name": "b"}, + }, + }, + { + ID: 3, + Properties: &graph.Properties{ + Map: map[string]any{"name": "c"}, + }, + }, + } + actualList, err := formatGraphResults(nodes) + require.Nil(t, err) + + expectedList := []string{"(:A{name: 'a'})", "(:B{name: 'b'})", "({name: 'c'})"} + + for i := range len(expectedList) { + require.Equal(t, actualList[i], expectedList[i]) + } +} + +func TestFormatString(t *testing.T) { + tests := []struct { + input string + expectedOutput string + }{ + { + input: `(:B {prefix: 'c', name: 'b'})`, + expectedOutput: `(:B{prefix:'c',name:'b'})`, + }, + { + input: `(:B {prefix: "c", name: "b"})`, + expectedOutput: `(:B{prefix:'c',name:'b'})`, + }, + { + input: `(:B{prefix:"c",name:"b"})`, + expectedOutput: `(:B{prefix:'c',name:'b'})`, + }, + { + input: `(:B{prefix:'c',name:'b'})`, + expectedOutput: `(:B{prefix:'c',name:'b'})`, + }, + } + + for _, test := range tests { + actual := formatString(test.input) + require.Equal(t, test.expectedOutput, actual) + } +} diff --git a/go.mod b/go.mod index b51bb4c6..c54273ed 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/axiomhq/hyperloglog v0.2.6 github.com/bits-and-blooms/bitset v1.24.5 github.com/cespare/xxhash/v2 v2.3.0 + github.com/cucumber/godog v0.16.0 github.com/fzipp/gocyclo v0.6.0 github.com/gammazero/deque v1.2.1 github.com/jackc/pgtype v1.14.4 @@ -82,6 +83,8 @@ require ( github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cockroachdb/apd/v3 v3.2.3 // indirect + github.com/cucumber/gherkin/go/v42 v42.0.0 // indirect + github.com/cucumber/messages/go/v34 v34.2.0 // indirect github.com/curioswitch/go-reassign v0.3.0 // indirect github.com/daixiang0/gci v0.13.7 // indirect github.com/dave/dst v0.27.3 // indirect @@ -120,13 +123,17 @@ require ( github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gordonklaus/ineffassign v0.2.0 // indirect github.com/gostaticanalysis/analysisutil v0.7.1 // indirect github.com/gostaticanalysis/comment v1.5.0 // indirect github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect github.com/gostaticanalysis/nilerr v0.1.2 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect + github.com/hashicorp/go-memdb v1.3.5 // indirect github.com/hashicorp/go-version v1.8.0 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index c910d000..38a9251c 100644 --- a/go.sum +++ b/go.sum @@ -121,6 +121,12 @@ github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7 github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/cucumber/gherkin/go/v42 v42.0.0 h1:Ulh3E2awUUSSja+wonP/IOQ+ycmiZwZbgmzqk5H8JNI= +github.com/cucumber/gherkin/go/v42 v42.0.0/go.mod h1:CsaumaO2dR9XvBc6ZyiGLMhWCKtTRDxgoxqJigSjSSg= +github.com/cucumber/godog v0.16.0 h1:ezQbgItuWqZrjPUQwLJ3muwIlvzXBOfZso5QZfG7efE= +github.com/cucumber/godog v0.16.0/go.mod h1:EDUX9yCqANK+GpbftMDeu61sUDtdLuo1JJgXD2n3bbM= +github.com/cucumber/messages/go/v34 v34.2.0 h1:VCbcNOMz+f8ccjjOOx1NLBNhwvE7/X49Atc8klJa+i8= +github.com/cucumber/messages/go/v34 v34.2.0/go.mod h1:LYUPjqlTS1kS0pdkdf6sS5uirnjwiIzEGyXPezXNhL8= github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs= github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ= @@ -250,13 +256,21 @@ github.com/gostaticanalysis/nilerr v0.1.2/go.mod h1:A19UHhoY3y8ahoL7YKz6sdjDtduw github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8= github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo= github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw= +github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo= +github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= diff --git a/util/context_test.go b/util/context_test.go new file mode 100644 index 00000000..031b85f3 --- /dev/null +++ b/util/context_test.go @@ -0,0 +1,22 @@ +package util + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsContextLive(t *testing.T) { + ctx := context.Background() + actual := IsContextLive(ctx) + assert.Equal(t, true, actual) +} + +func TestIsContextLiveCanceled(t *testing.T) { + // create a child context from background and cancel to force an error + ctx, cancel := context.WithCancel(context.Background()) + cancel() + actual := IsContextLive(ctx) + assert.Equal(t, false, actual) +}