diff --git a/consumer/http_v4.go b/consumer/http_v4.go index 8dd986b79..e57aa0f6b 100644 --- a/consumer/http_v4.go +++ b/consumer/http_v4.go @@ -92,6 +92,15 @@ func (i *V4UnconfiguredInteraction) UponReceiving(description string) *V4Unconfi return i } +// AddExternalReference records a reference to an external resource (such as a ticket or +// pull request) against the interaction. References appear under +// comments.references[group][name] in the Pact file. May be called multiple times. +func (i *V4UnconfiguredInteraction) AddExternalReference(group, name, value string) *V4UnconfiguredInteraction { + i.interaction.interaction.WithReference(group, name, value) + + return i +} + // WithRequest provides a builder for the expected request func (i *V4UnconfiguredInteraction) WithCompleteRequest(request Request) *V4InteractionWithCompleteRequest { i.interaction.WithCompleteRequest(request) diff --git a/consumer/http_v4_test.go b/consumer/http_v4_test.go index 33165496b..efb421bde 100644 --- a/consumer/http_v4_test.go +++ b/consumer/http_v4_test.go @@ -2,6 +2,7 @@ package consumer import ( "fmt" + "net/http" "os" "strings" "testing" @@ -85,6 +86,25 @@ func TestHttpV4TypeSystem(t *testing.T) { } +func TestV4HTTPAddExternalReference(t *testing.T) { + p, err := NewV4Pact(MockHTTPProviderConfig{ + Consumer: "consumer", + Provider: "provider", + }) + assert.NoError(t, err) + + err = p.AddInteraction(). + UponReceiving("a request with an external reference"). + AddExternalReference("Jira", "TICKET-123", "https://jira.example.com/browse/TICKET-123"). + WithRequest("GET", "/", func(b *V4RequestBuilder) {}). + WillRespondWith(200, func(b *V4ResponseBuilder) {}). + ExecuteTest(t, func(msc MockServerConfig) error { + _, err := http.Get(fmt.Sprintf("http://%s:%d/", msc.Host, msc.Port)) + return err + }) + assert.NoError(t, err) +} + var Like = matchers.Like var EachLike = matchers.EachLike var Term = matchers.Term diff --git a/installer/installer.go b/installer/installer.go index 30965e1fa..89eb9fddd 100644 --- a/installer/installer.go +++ b/installer/installer.go @@ -388,7 +388,7 @@ const ( var packages = map[string]packageInfo{ FFIPackage: { libName: "libpact_ffi", - version: "0.4.28", + version: "0.5.4", semverRange: ">= 0.4.0, < 1.0.0", }, } diff --git a/internal/native/message_server.go b/internal/native/message_server.go index bd6e6ec3a..539d56d9d 100644 --- a/internal/native/message_server.go +++ b/internal/native/message_server.go @@ -596,3 +596,19 @@ func (m *MessageServer) WritePactFileForServer(port int, dir string, overwrite b return fmt.Errorf("an unknown error ocurred when writing to pact file") } } + +// WithReference records an external reference (e.g. a ticket or pull request) +// against the interaction. References are stored under comments.references[group][name] +// in the Pact file. This is a V4-only feature. +func (m *Message) WithReference(group, name, value string) *Message { + cGroup := C.CString(group) + defer free(cGroup) + cName := C.CString(name) + defer free(cName) + cValue := C.CString(value) + defer free(cValue) + + C.pactffi_add_interaction_reference(m.handle, cGroup, cName, cValue) + + return m +} diff --git a/internal/native/mock_server.go b/internal/native/mock_server.go index 38ab72d36..6d9e4a43a 100644 --- a/internal/native/mock_server.go +++ b/internal/native/mock_server.go @@ -6,13 +6,25 @@ package native import "C" import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/tls" "crypto/x509" + "crypto/x509/pkix" "encoding/json" + "encoding/pem" "fmt" "log" + "math/big" + "net" + "net/http" + "net/http/httputil" + "net/url" "os" + "strconv" "strings" + "time" "unsafe" ) @@ -113,6 +125,18 @@ type MockServer struct { pact *Pact messagePact *MessagePact interactions []*Interaction + tlsProxy net.Listener // non-nil when a Go-level TLS proxy is in use + tlsServer *http.Server // HTTP server for the TLS proxy (enables graceful shutdown) + internalPort int // actual pact FFI server port when TLS proxy is active +} + +// pactPort returns the port the FFI mock server is bound to. +// When a TLS proxy is active, this differs from the external port callers see. +func (m *MockServer) pactPort(visiblePort int) int { + if m.internalPort != 0 { + return m.internalPort + } + return visiblePort } // NewHTTPPact creates a new HTTP mock server for a given consumer/provider @@ -134,52 +158,6 @@ func (m *MockServer) WithSpecificationVersion(version specificationVersion) { C.pactffi_with_specification(m.pact.handle, C.int(version)) } -// CreateMockServer creates a new Mock Server from a given Pact file. -// Returns the port number it started on or an error if failed -func (m *MockServer) CreateMockServer(pact string, address string, tls bool) (int, error) { - log.Println("[DEBUG] mock server starting on address:", address) - cPact := C.CString(pact) - cAddress := C.CString(address) - defer free(cPact) - defer free(cAddress) - tlsEnabled := false - if tls { - tlsEnabled = true - } - - p := C.pactffi_create_mock_server(cPact, cAddress, C.bool(tlsEnabled)) - - // | Error | Description | - // |-------|-------------| - // | -1 | A null pointer was received | - // | -2 | The pact JSON could not be parsed | - // | -3 | The mock server could not be started | - // | -4 | The method panicked | - // | -5 | The address is not valid | - // | -6 | Could not create the TLS configuration with the self-signed certificate | - port := int(p) - switch port { - case -1: - return 0, ErrInvalidMockServerConfig - case -2: - return 0, ErrInvalidPact - case -3: - return 0, ErrMockServerUnableToStart - case -4: - return 0, ErrMockServerPanic - case -5: - return 0, ErrInvalidAddress - case -6: - return 0, ErrMockServerTLSConfiguration - default: - if port > 0 { - log.Println("[DEBUG] mock server running on port:", port) - return port, nil - } - return port, fmt.Errorf("an unknown error (code: %v) occurred when starting a mock server for the test", port) - } -} - // Verify verifies that all interactions were successful. If not, returns a slice // of Mismatch-es. Does not write the pact or cleanup server. func (m *MockServer) Verify(port int, dir string) (bool, []MismatchedRequest) { @@ -195,7 +173,7 @@ func (m *MockServer) MockServerMismatchedRequests(port int) []MismatchedRequest log.Println("[DEBUG] mock server determining mismatches:", port) var res []MismatchedRequest - mismatches := C.pactffi_mock_server_mismatches(C.int(port)) + mismatches := C.pactffi_mock_server_mismatches(C.int(m.pactPort(port))) // This method can return a nil pointer, in which case, it // should be considered a failure (or at least, an issue) // converting it to a string might also do nasty things here! @@ -218,7 +196,19 @@ func (m *MockServer) CleanupMockServer(port int) bool { return true } log.Println("[DEBUG] mock server cleaning up port:", port) - res := C.pactffi_cleanup_mock_server(C.int(port)) + + // Shut down the TLS proxy cleanly if active. + if m.tlsServer != nil { + _ = m.tlsServer.Close() + m.tlsServer = nil + } + if m.tlsProxy != nil { + _ = m.tlsProxy.Close() + m.tlsProxy = nil + m.internalPort = 0 + } + + res := C.pactffi_cleanup_mock_server(C.int(m.pactPort(port))) return bool(res) } @@ -236,7 +226,7 @@ func (m *MockServer) WritePactFile(port int, dir string) error { // } // res := int(C.pactffi_write_pact_file(C.int(port), cDir, C.int(overwritePact))) - res := int(C.pactffi_write_pact_file(C.int(port), cDir, C.bool(false))) + res := int(C.pactffi_write_pact_file(C.int(m.pactPort(port)), cDir, C.bool(false))) // | Error | Description | // |-------|-------------| @@ -280,52 +270,161 @@ func libRustFree(str *C.char) { C.pactffi_free_string(str) } -// Start starts up the mock HTTP server on the given address:port and TLS config -// https://docs.rs/pact_mock_server_ffi/0.0.7/pact_mock_server_ffi/fn.create_mock_server_for_pact.html -func (m *MockServer) Start(address string, tls bool) (int, error) { +// Start starts up the mock HTTP server on the given address:port and TLS config. +// When tlsEnabled is true, a plain HTTP pact mock server is started on an +// auto-assigned port and a Go-level TLS termination proxy is placed in front +// of it on the requested port. All FFI verification/cleanup calls are directed +// to the internal plain HTTP port via pactPort(). +// https://docs.rs/pact_ffi/latest/pact_ffi/mock_server/fn.pactffi_create_mock_server_for_transport.html +func (m *MockServer) Start(address string, tlsEnabled bool) (int, error) { if len(m.interactions) == 0 { return 0, ErrNoInteractions } log.Println("[DEBUG] mock server starting on address:", address) - cAddress := C.CString(address) - defer free(cAddress) - tlsEnabled := false - if tls { - tlsEnabled = true + + host, portStr, err := net.SplitHostPort(address) + if err != nil { + return 0, ErrInvalidAddress + } + requestedPort, err := strconv.Atoi(portStr) + if err != nil { + return 0, ErrInvalidAddress + } + + // When TLS is requested, let the OS pick the internal port for the plain HTTP + // pact mock server so it doesn't collide with the TLS proxy port. + ffiPort := requestedPort + if tlsEnabled { + ffiPort = 0 } - p := C.pactffi_create_mock_server_for_pact(m.pact.handle, cAddress, C.bool(tlsEnabled)) + internalPort, err := m.startFFIMockServer(host, ffiPort) + if err != nil { + return 0, err + } - // | Error | Description | - // |-------|-------------| - // | -1 | A null pointer was received | - // | -2 | The pact JSON could not be parsed | - // | -3 | The mock server could not be started | - // | -4 | The method panicked | - // | -5 | The address is not valid | - // | -6 | Could not create the TLS configuration with the self-signed certificate | - port := int(p) - switch port { + if !tlsEnabled { + return internalPort, nil + } + + // Stand up a Go TLS reverse proxy in front of the plain HTTP pact server. + tlsListener, err := m.startTLSProxy(host, requestedPort, internalPort) + if err != nil { + C.pactffi_cleanup_mock_server(C.int(internalPort)) + return 0, fmt.Errorf("failed to start TLS proxy: %w", err) + } + + m.internalPort = internalPort + m.tlsProxy = tlsListener + + proxyPort := tlsListener.Addr().(*net.TCPAddr).Port + log.Println("[DEBUG] TLS proxy running on port:", proxyPort, "-> internal port:", internalPort) + return proxyPort, nil +} + +// startFFIMockServer starts the pact FFI mock server as plain HTTP. +func (m *MockServer) startFFIMockServer(host string, port int) (int, error) { + cAddress := C.CString(host) + defer free(cAddress) + cTransport := C.CString("http") + defer free(cTransport) + cConfig := C.CString("{}") + defer free(cConfig) + + p := C.pactffi_create_mock_server_for_transport(m.pact.handle, cAddress, C.ushort(port), cTransport, cConfig) + + // | Error | Description + // |-------|------------- + // | -1 | An invalid handle was received. Handles should be created with pactffi_new_pact + // | -2 | transport_config is not valid JSON + // | -3 | The mock server could not be started + // | -4 | The method panicked + // | -5 | The address is not valid + msPort := int(p) + switch msPort { case -1: return 0, ErrInvalidMockServerConfig case -2: - return 0, ErrInvalidPact + return 0, ErrInvalidMockServerConfig case -3: return 0, ErrMockServerUnableToStart case -4: return 0, ErrMockServerPanic case -5: return 0, ErrInvalidAddress - case -6: - return 0, ErrMockServerTLSConfiguration default: - if port > 0 { - log.Println("[DEBUG] mock server running on port:", port) - return port, nil + if msPort > 0 { + log.Println("[DEBUG] mock server running on port:", msPort) + return msPort, nil + } + return msPort, fmt.Errorf("an unknown error (code: %v) occurred when starting a mock server for the test", msPort) + } +} + +// startTLSProxy creates a self-signed certificate, starts a TLS listener on +// proxyPort (0 = OS-assigned) and launches a reverse-proxy goroutine that +// forwards plain HTTP to the pact mock server on backendPort. +func (m *MockServer) startTLSProxy(host string, proxyPort int, backendPort int) (net.Listener, error) { + cert, err := generateSelfSignedCert() + if err != nil { + return nil, err + } + + tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}} + listener, err := tls.Listen("tcp", fmt.Sprintf("%s:%d", host, proxyPort), tlsConfig) + if err != nil { + return nil, err + } + + backendURL := &url.URL{ + Scheme: "http", + Host: fmt.Sprintf("127.0.0.1:%d", backendPort), + } + proxy := httputil.NewSingleHostReverseProxy(backendURL) + srv := &http.Server{Handler: proxy} + m.tlsServer = srv + go func() { + if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed { + log.Println("[ERROR] TLS proxy serve error:", err) } - return port, fmt.Errorf("an unknown error (code: %v) occurred when starting a mock server for the test", port) + }() + + return listener, nil +} + +// generateSelfSignedCert creates an ECDSA P-256 self-signed certificate +// valid for localhost / 127.0.0.1 for 24 hours. +func generateSelfSignedCert() (tls.Certificate, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, err + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "pact-go"}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, + DNSNames: []string{"localhost"}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return tls.Certificate{}, err } + + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return tls.Certificate{}, err + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + return tls.X509KeyPair(certPEM, keyPEM) } // StartTransport starts up a mock server on the given address:port for the given transport @@ -643,6 +742,22 @@ func (i *Interaction) WithStatus(status int) *Interaction { return i } +// WithReference records an external reference (e.g. a ticket or pull request) +// against the interaction. References are stored under comments.references[group][name] +// in the Pact file. This is a V4-only feature. +func (i *Interaction) WithReference(group, name, value string) *Interaction { + cGroup := C.CString(group) + defer free(cGroup) + cName := C.CString(name) + defer free(cName) + cValue := C.CString(value) + defer free(cValue) + + C.pactffi_add_interaction_reference(i.handle, cGroup, cName, cValue) + + return i +} + type stringLike interface { String() string } diff --git a/internal/native/mock_server_test.go b/internal/native/mock_server_test.go index aa79a6373..25f8cc135 100644 --- a/internal/native/mock_server_test.go +++ b/internal/native/mock_server_test.go @@ -17,9 +17,34 @@ func init() { Init("") } +// newSimpleMockServer creates a mock server with a simple GET /foobar → 200 interaction +// using the programmatic API, replacing the removed CreateMockServer function. +func newSimpleMockServer(t *testing.T) (*MockServer, int) { + t.Helper() + m := NewHTTPPact("consumer", "provider") + m.NewInteraction("Some name for the test"). + UponReceiving("Some name for the test"). + Given("Some state"). + WithRequest("GET", "/foobar"). + WithStatus(200) + port, err := m.Start("0.0.0.0:0", false) + if err != nil { + t.Fatalf("failed to start mock server: %v", err) + } + return m, port +} + func TestMockServer_CreateAndCleanupMockServer(t *testing.T) { - m := MockServer{} - port, _ := m.CreateMockServer(pactComplex, "0.0.0.0:0", false) + m := NewHTTPPact("consumer", "provider") + m.NewInteraction("Some complex interaction"). + UponReceiving("Some complex interaction"). + Given("Some state"). + WithRequest("GET", "/foobar"). + WithStatus(200) + port, err := m.Start("0.0.0.0:0", false) + if err != nil { + t.Fatal("failed to start mock server:", err) + } defer m.CleanupMockServer(port) if port <= 0 { @@ -28,8 +53,7 @@ func TestMockServer_CreateAndCleanupMockServer(t *testing.T) { } func TestMockServer_MismatchesSuccess(t *testing.T) { - m := MockServer{} - port, _ := m.CreateMockServer(pactSimple, "0.0.0.0:0", false) + m, port := newSimpleMockServer(t) defer m.CleanupMockServer(port) res, err := http.Get(fmt.Sprintf("http://localhost:%d/foobar", port)) @@ -48,8 +72,7 @@ func TestMockServer_MismatchesSuccess(t *testing.T) { } func TestMockServer_MismatchesFail(t *testing.T) { - m := MockServer{} - port, _ := m.CreateMockServer(pactSimple, "0.0.0.0:0", false) + m, port := newSimpleMockServer(t) defer m.CleanupMockServer(port) mismatches := m.MockServerMismatchedRequests(port) @@ -62,8 +85,7 @@ func TestMockServer_VerifySuccess(t *testing.T) { tmpPactFolder, err := os.MkdirTemp("", "pact-go") assert.NoError(t, err) - m := MockServer{} - port, _ := m.CreateMockServer(pactSimple, "0.0.0.0:0", false) + m, port := newSimpleMockServer(t) defer m.CleanupMockServer(port) _, err = http.Get(fmt.Sprintf("http://localhost:%d/foobar", port)) @@ -84,8 +106,7 @@ func TestMockServer_VerifySuccess(t *testing.T) { func TestMockServer_VerifyFail(t *testing.T) { tmpPactFolder, err := os.MkdirTemp("", "pact-go") assert.NoError(t, err) - m := MockServer{} - port, _ := m.CreateMockServer(pactSimple, "0.0.0.0:0", false) + m, port := newSimpleMockServer(t) success, mismatches := m.Verify(port, tmpPactFolder) if success { @@ -101,8 +122,7 @@ func TestMockServer_WritePactfile(t *testing.T) { tmpPactFolder, err := os.MkdirTemp("", "pact-go") assert.NoError(t, err) - m := MockServer{} - port, _ := m.CreateMockServer(pactSimple, "0.0.0.0:0", false) + m, port := newSimpleMockServer(t) defer m.CleanupMockServer(port) _, err = http.Get(fmt.Sprintf("http://localhost:%d/foobar", port)) @@ -224,72 +244,3 @@ func TestPluginInteraction(t *testing.T) { assert.NoError(t, err) } -var pactSimple = `{ - "consumer": { - "name": "consumer" - }, - "provider": { - "name": "provider" - }, - "interactions": [ - { - "description": "Some name for the test", - "request": { - "method": "GET", - "path": "/foobar" - }, - "response": { - "status": 200 - }, - "description": "Some name for the test", - "provider_state": "Some state" - }] -}` - -var pactComplex = `{ - "consumer": { - "name": "consumer" - }, - "provider": { - "name": "provider" - }, - "interactions": [ - { - "request": { - "method": "GET", - "path": "/foobar", - "body": { - "pass": 1234, - "user": { - "address": "some address", - "name": "someusername", - "phone": 12345678, - "plaintext": "plaintext" - } - } - }, - "response": { - "status": 200 - }, - "description": "Some name for the test", - "provider_state": "Some state", - "matchingRules": { - "$.body.pass": { - "match": "regex", - "regex": "\\d+" - }, - "$.body.user.address": { - "match": "regex", - "regex": "\\s+" - }, - "$.body.user.name": { - "match": "regex", - "regex": "\\s+" - }, - "$.body.user.phone": { - "match": "regex", - "regex": "\\d+" - } - } - }] -}` diff --git a/internal/native/pact.h b/internal/native/pact.h index c5865d040..df2d4e7a1 100644 --- a/internal/native/pact.h +++ b/internal/native/pact.h @@ -4018,6 +4018,16 @@ bool pactffi_set_comment(InteractionHandle interaction, const char *key, const c */ bool pactffi_add_text_comment(InteractionHandle interaction, const char *comment); +/** + * Add an external reference to the interaction. + * + * References are stored under `comments.references[group][name]` in the Pact file. + * The group, name and value parameters must be valid UTF-8 null-terminated strings. + * + * Returns false if the reference could not be added (e.g. invalid handle or NULL strings). + */ +bool pactffi_add_interaction_reference(InteractionHandle interaction, const char *group, const char *name, const char *value); + /** * Get an iterator over all the messages of the Pact. The returned iterator needs to be * freed with `pactffi_pact_message_iter_delete`. diff --git a/internal/native/verifier.go b/internal/native/verifier.go index 3f03df269..0e45f6b4d 100644 --- a/internal/native/verifier.go +++ b/internal/native/verifier.go @@ -8,7 +8,6 @@ import "C" import ( "fmt" "log" - "strings" "unsafe" ) @@ -16,31 +15,6 @@ type Verifier struct { handle *C.VerifierHandle } -func (v *Verifier) Verify(args []string) error { - log.Println("[DEBUG] executing verifier FFI with args", args) - cargs := C.CString(strings.Join(args, "\n")) - defer free(cargs) - result := C.pactffi_verify(cargs) - - /// | Error | Description | - /// |-------|-------------| - /// | 1 | The verification process failed, see output for errors | - /// | 2 | A null pointer was received | - /// | 3 | The method panicked | - switch int(result) { - case 0: - return nil - case 1: - return ErrVerifierFailed - case 2: - return ErrInvalidVerifierConfig - case 3: - return ErrVerifierPanic - default: - return fmt.Errorf("an unknown error (%d) ocurred when verifying the provider (this indicates a defect in the framework)", int(result)) - } -} - // Version returns the current semver FFI interface version func (v *Verifier) Version() string { return Version() diff --git a/internal/native/verifier_test.go b/internal/native/verifier_test.go index 714d65551..5d6b3cb33 100644 --- a/internal/native/verifier_test.go +++ b/internal/native/verifier_test.go @@ -18,29 +18,6 @@ func TestVerifier_Version(t *testing.T) { fmt.Println("version: ", Version()) } -func TestVerifier_Verify(t *testing.T) { - t.Run("invalid args returns an error", func(t *testing.T) { - - v := Verifier{} - args := []string{ - "--file", - "/non/existent/path.json", - "--hostname", - "localhost", - "--port", - "55827", - "--state-change-url", - "http://localhost:55827/__setup/", - "--loglevel", - "info", - } - - res := v.Verify(args) - - assert.Error(t, res) - }) -} - func TestVerifier_NewForApplication(t *testing.T) { v := NewVerifier("pact-go", "test") diff --git a/message/v4/asynchronous_message.go b/message/v4/asynchronous_message.go index 76bfa13a2..c57cc8c37 100644 --- a/message/v4/asynchronous_message.go +++ b/message/v4/asynchronous_message.go @@ -47,6 +47,15 @@ func (m *AsynchronousMessageBuilder) GivenWithParameter(state models.ProviderSta return m } +// AddExternalReference records a reference to an external resource (such as a ticket or +// pull request) against the interaction. References appear under +// comments.references[group][name] in the Pact file. May be called multiple times. +func (m *AsynchronousMessageBuilder) AddExternalReference(group, name, value string) *AsynchronousMessageBuilder { + m.messageHandle.WithReference(group, name, value) + + return m +} + // ExpectsToReceive specifies the content it is expecting to be // given from the Provider. The function must be able to handle this // message for the interaction to succeed. diff --git a/message/v4/asynchronous_message_test.go b/message/v4/asynchronous_message_test.go index e01114825..57a89a81f 100644 --- a/message/v4/asynchronous_message_test.go +++ b/message/v4/asynchronous_message_test.go @@ -40,6 +40,25 @@ func TestAsyncTypeSystem(t *testing.T) { } +func TestAsyncAddExternalReference(t *testing.T) { + p, _ := NewAsynchronousPact(Config{ + Consumer: "asyncconsumer", + Provider: "asyncprovider", + PactDir: "/tmp/", + }) + + err := p.AddAsynchronousMessage(). + AddExternalReference("GitHub", "PR-456", "https://github.com/org/repo/pull/456"). + ExpectsToReceive("a message with an external reference"). + WithJSONContent(map[string]string{"event": "user.created"}). + ConsumedBy(func(mc AsynchronousMessage) error { + return nil + }). + Verify(t) + + assert.NoError(t, err) +} + // Sync - with plugin, but no transport // TODO: ExecuteTest has been disabled for now, because it's not very useful func TestAsyncTypeSystem_CsvPlugin_Matcher(t *testing.T) { diff --git a/message/v4/synchronous_message.go b/message/v4/synchronous_message.go index d5c350074..6088703ba 100644 --- a/message/v4/synchronous_message.go +++ b/message/v4/synchronous_message.go @@ -63,6 +63,15 @@ type UnconfiguredSynchronousMessageBuilder struct { pact *SynchronousPact } +// AddExternalReference records a reference to an external resource (such as a ticket or +// pull request) against the interaction. References appear under +// comments.references[group][name] in the Pact file. May be called multiple times. +func (m *UnconfiguredSynchronousMessageBuilder) AddExternalReference(group, name, value string) *UnconfiguredSynchronousMessageBuilder { + m.messageHandle.WithReference(group, name, value) + + return m +} + // UsingPlugin enables a plugin for use in the current test case func (m *UnconfiguredSynchronousMessageBuilder) UsingPlugin(config PluginConfig) *SynchronousMessageWithPlugin { err := m.pact.mockserver.UsingPlugin(config.Plugin, config.Version) diff --git a/message/v4/synchronous_message_test.go b/message/v4/synchronous_message_test.go index 3b1d4ac24..53765610e 100644 --- a/message/v4/synchronous_message_test.go +++ b/message/v4/synchronous_message_test.go @@ -44,6 +44,27 @@ func TestSyncTypeSystem_NoPlugin(t *testing.T) { assert.NoError(t, err) } +func TestSyncAddExternalReference(t *testing.T) { + p, _ := NewSynchronousPact(Config{ + Consumer: "consumer", + Provider: "provider", + }) + + err := p.AddSynchronousMessage("a sync message with an external reference"). + AddExternalReference("Jira", "TICKET-789", "https://jira.example.com/browse/TICKET-789"). + WithRequest(func(r *SynchronousMessageWithRequestBuilder) { + r.WithJSONContent(map[string]string{"request": "ping"}) + }). + WithResponse(func(r *SynchronousMessageWithResponseBuilder) { + r.WithJSONContent(map[string]string{"response": "pong"}) + }). + ExecuteTest(t, func(m SynchronousMessage) error { + return nil + }) + + assert.NoError(t, err) +} + // Sync - with plugin, but no transport func TestSyncTypeSystem_CsvPlugin_Matcher(t *testing.T) { p, _ := NewSynchronousPact(Config{