diff --git a/.changeset/spanner-test-stability.md b/.changeset/spanner-test-stability.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/spanner-test-stability.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59a0275eb..028d8a7fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,7 +90,19 @@ jobs: cache: true # Spanner tests use a Docker testcontainer emulator, not the embedded # postgres binary, so no Maven binary cache is needed here. - - run: go test -v -tags spanner_integration -timeout=10m ./... + - name: Spanner integration tests + run: | + mkdir -p test-output/go + set -o pipefail + go test -json -v -tags spanner_integration -timeout=10m ./... | tee test-output/go/spanner-integration.json + - name: Upload Spanner test log on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: spanner-integration-test-log + path: test-output/go/spanner-integration.json + if-no-files-found: ignore + retention-days: 7 goreleaser-snapshot: runs-on: depot-ubuntu-24.04-8 diff --git a/internal/api/integration_test/flow_definition_test.go b/internal/api/integration_test/flow_definition_test.go index 8aee15d9c..aa4534f17 100644 --- a/internal/api/integration_test/flow_definition_test.go +++ b/internal/api/integration_test/flow_definition_test.go @@ -15,8 +15,6 @@ import ( ) func TestCreateFlowDefinitionUnauthenticated(t *testing.T) { - t.Parallel() - userSchema := "https://some-tenant.com/schemas/unknown-user-schema.yaml" userSchemaURI, err := url.Parse(userSchema) require.NoError(t, err) @@ -49,12 +47,11 @@ func TestCreateFlowDefinitionUnauthenticated(t *testing.T) { } func TestCreateFlowDefinition(t *testing.T) { - t.Parallel() project, err := harness.EnsureProjectService(t).Create(t.Context(), nil) require.NoError(t, err) - harness.CreateUserSchema(t, project.ID, harness.TestData.Schemas.CreateSchemaRequestUserSchema) - u := "https://raw.githubusercontent.com/zitadel/nextgen/refs/heads/main/api/openapi/endpoints/schemas/examples/user-schema-example.yaml" + u := flowDefinitionSchemaURL(t) + harness.EnsureUserSchemaWithID(t, project.ID, harness.TestData.Schemas.CreateSchemaRequestUserSchema, u) userSchemaURI, err := url.Parse(u) require.NoError(t, err) @@ -290,7 +287,6 @@ func TestCreateFlowDefinition(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - t.Parallel() client := harness.EnsureAPIClient(t, project.ID) resp, err := client.CreateFlowDefinition(t.Context(), tt.req) assert.NoError(t, err) @@ -365,8 +361,12 @@ func validSteps() []api.FlowDefinitionStep { } } +func flowDefinitionSchemaURL(t *testing.T) string { + t.Helper() + return "https://test.example.schemas.com/integration/" + url.PathEscape(t.Name()) + ".json" +} + func TestGetFlowDefinitionUnauthenticated(t *testing.T) { - t.Parallel() client := harness.EnsureAnonymousAPIClient(t) getResp, err := client.GetFlowDefinition(t.Context(), api.GetFlowDefinitionParams{ ID: "flowDef_1234", @@ -385,11 +385,10 @@ func TestGetFlowDefinitionUnauthenticated(t *testing.T) { } func TestGetFlowDefinition(t *testing.T) { - t.Parallel() project, err := harness.EnsureProjectService(t).Create(t.Context(), nil) require.NoError(t, err) - harness.CreateUserSchema(t, project.ID, harness.TestData.Schemas.CreateSchemaRequestUserSchema) - u := "https://raw.githubusercontent.com/zitadel/nextgen/refs/heads/main/api/openapi/endpoints/schemas/examples/user-schema-example.yaml" + u := flowDefinitionSchemaURL(t) + harness.EnsureUserSchemaWithID(t, project.ID, harness.TestData.Schemas.CreateSchemaRequestUserSchema, u) userSchemaURI, err := url.Parse(u) require.NoError(t, err) @@ -442,7 +441,6 @@ func TestGetFlowDefinition(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - t.Parallel() client := harness.EnsureAPIClient(t, project.ID) resp, err := client.GetFlowDefinition(t.Context(), tt.req) assert.NoError(t, err) @@ -452,8 +450,6 @@ func TestGetFlowDefinition(t *testing.T) { } func TestListFlowDefinitionsUnauthenticated(t *testing.T) { - t.Parallel() - client := harness.EnsureAnonymousAPIClient(t) getResp, err := client.ListFlowDefinitions(t.Context(), api.ListFlowDefinitionsParams{ ProjectID: "proj_1234", @@ -471,19 +467,18 @@ func TestListFlowDefinitionsUnauthenticated(t *testing.T) { } func TestListFlowDefinitions(t *testing.T) { - t.Parallel() project1, err := harness.EnsureProjectService(t).Create(t.Context(), nil) require.NoError(t, err) - harness.CreateUserSchema(t, project1.ID, harness.TestData.Schemas.CreateSchemaRequestUserSchema) project2, err := harness.EnsureProjectService(t).Create(t.Context(), nil) require.NoError(t, err) - harness.CreateUserSchema(t, project2.ID, harness.TestData.Schemas.CreateSchemaRequestUserSchema) project3, err := harness.EnsureProjectService(t).Create(t.Context(), nil) require.NoError(t, err) - u := "https://raw.githubusercontent.com/zitadel/nextgen/refs/heads/main/api/openapi/endpoints/schemas/examples/user-schema-example.yaml" + u := flowDefinitionSchemaURL(t) + harness.EnsureUserSchemaWithID(t, project1.ID, harness.TestData.Schemas.CreateSchemaRequestUserSchema, u) + harness.EnsureUserSchemaWithID(t, project2.ID, harness.TestData.Schemas.CreateSchemaRequestUserSchema, u) userSchemaURI, err := url.Parse(u) require.NoError(t, err) @@ -666,7 +661,6 @@ func TestListFlowDefinitions(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - t.Parallel() client := harness.EnsureAPIClient(t, project1.ID) resp, err := client.ListFlowDefinitions(t.Context(), tt.req) assert.NoError(t, err) @@ -699,7 +693,6 @@ func TestListFlowDefinitions(t *testing.T) { } func TestDeleteFlowDefinitionUnauthenticated(t *testing.T) { - t.Parallel() client := harness.EnsureAnonymousAPIClient(t) resp, err := client.DeleteFlowDefinition(t.Context(), api.DeleteFlowDefinitionParams{ ID: "flowDef_1234", @@ -717,11 +710,10 @@ func TestDeleteFlowDefinitionUnauthenticated(t *testing.T) { } func TestDeleteFlowDefinition(t *testing.T) { - t.Parallel() project, err := harness.EnsureProjectService(t).Create(t.Context(), nil) require.NoError(t, err) - harness.CreateUserSchema(t, project.ID, harness.TestData.Schemas.CreateSchemaRequestUserSchema) - u := "https://raw.githubusercontent.com/zitadel/nextgen/refs/heads/main/api/openapi/endpoints/schemas/examples/user-schema-example.yaml" + u := flowDefinitionSchemaURL(t) + harness.EnsureUserSchemaWithID(t, project.ID, harness.TestData.Schemas.CreateSchemaRequestUserSchema, u) userSchemaURI, err := url.Parse(u) require.NoError(t, err) @@ -776,7 +768,6 @@ func TestDeleteFlowDefinition(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - t.Parallel() client := harness.EnsureAPIClient(t, project.ID) resp, err := client.DeleteFlowDefinition(t.Context(), tt.req) assert.NoError(t, err) diff --git a/internal/api/integration_test/helpers/auth_attempt.go b/internal/api/integration_test/helpers/auth_attempt.go index 11d28214c..9dbe8de4e 100644 --- a/internal/api/integration_test/helpers/auth_attempt.go +++ b/internal/api/integration_test/helpers/auth_attempt.go @@ -10,27 +10,47 @@ import ( func (h *Harness) EnsureAuthAttemptService(t *testing.T) service.AuthAttemptService { t.Helper() + h.mu.Lock() + svc := h.AuthAttemptService + h.mu.Unlock() + if svc != nil { + return svc + } + svc = service.NewAuthAttemptService( + h.EnsureDBPool(t), + h.EnsureAuthAttemptRepo(t), + h.EnsureSessionRepo(t), + h.EnsureProjectRepo(t), + h.EnsureUserRepo(t), + h.EnsureUserPasswordRepo(t), + h.EnsureUserPasskeyRepo(t), + h.EnsureHashVerifier(t), + ) + h.mu.Lock() if h.AuthAttemptService == nil { - h.AuthAttemptService = service.NewAuthAttemptService( - h.EnsureDBPool(t), - h.EnsureAuthAttemptRepo(t), - h.EnsureSessionRepo(t), - h.EnsureProjectRepo(t), - h.EnsureUserRepo(t), - h.EnsureUserPasswordRepo(t), - h.EnsureUserPasskeyRepo(t), - h.EnsureHashVerifier(t), - ) + h.AuthAttemptService = svc } - return h.AuthAttemptService + svc = h.AuthAttemptService + h.mu.Unlock() + return svc } func (h *Harness) EnsureAuthAttemptRepo(t *testing.T) domain.AuthAttemptRepository { t.Helper() + h.mu.Lock() + repo := h.AuthAttemptRepo + h.mu.Unlock() + if repo != nil { + return repo + } + repo = repository.NewAuthAttemptRepository( + h.EnsureDBPool(t), + ) + h.mu.Lock() if h.AuthAttemptRepo == nil { - h.AuthAttemptRepo = repository.NewAuthAttemptRepository( - h.EnsureDBPool(t), - ) + h.AuthAttemptRepo = repo } - return h.AuthAttemptRepo + repo = h.AuthAttemptRepo + h.mu.Unlock() + return repo } diff --git a/internal/api/integration_test/helpers/client.go b/internal/api/integration_test/helpers/client.go index bde5afa64..8005ad882 100644 --- a/internal/api/integration_test/helpers/client.go +++ b/internal/api/integration_test/helpers/client.go @@ -11,56 +11,101 @@ import ( func (h *Harness) EnsureAPIClient(t *testing.T, projectID string) *api.Client { t.Helper() - serv := h.EnsureTestServer(t) - if h.apiClients == nil { - h.apiClients = make(map[string]*api.Client) - } - if client, ok := h.apiClients[projectID]; ok { - return client + h.mu.Lock() + if h.apiClients != nil { + if client, ok := h.apiClients[projectID]; ok { + h.mu.Unlock() + return client + } } + h.mu.Unlock() + + serv := h.EnsureTestServer(t) client, err := api.NewClient( serv.URL, h.EnsureFakeSecuritySource(t, projectID), ) require.NoError(t, err) + h.mu.Lock() + if h.apiClients == nil { + h.apiClients = make(map[string]*api.Client) + } + if existing, ok := h.apiClients[projectID]; ok { + h.mu.Unlock() + return existing + } h.apiClients[projectID] = client + h.mu.Unlock() return client } func (h *Harness) EnsureAnonymousAPIClient(t *testing.T) *api.Client { t.Helper() + h.mu.Lock() + client := h.anonymousClient + h.mu.Unlock() + if client != nil { + return client + } + serv := h.EnsureTestServer(t) + client, err := api.NewClient( + serv.URL, + h.EnsureAnonymousSecuritySource(t), + ) + require.NoError(t, err) + h.mu.Lock() if h.anonymousClient == nil { - client, err := api.NewClient( - serv.URL, - h.EnsureAnonymousSecuritySource(t), - ) - require.NoError(t, err) h.anonymousClient = client } - return h.anonymousClient + client = h.anonymousClient + h.mu.Unlock() + return client } func (h *Harness) EnsureFakeSecuritySource(t *testing.T, projectID string) *FakeSecuritySource { t.Helper() + h.mu.Lock() + if h.fakeSecuritySources != nil { + if source, ok := h.fakeSecuritySources[projectID]; ok { + h.mu.Unlock() + return source + } + } + h.mu.Unlock() + + source := &FakeSecuritySource{ + projectID: projectID, + } + h.mu.Lock() if h.fakeSecuritySources == nil { h.fakeSecuritySources = make(map[string]*FakeSecuritySource) } if source, ok := h.fakeSecuritySources[projectID]; ok { + h.mu.Unlock() return source } - h.fakeSecuritySources[projectID] = &FakeSecuritySource{ - projectID: projectID, - } - return h.fakeSecuritySources[projectID] + h.fakeSecuritySources[projectID] = source + h.mu.Unlock() + return source } func (h *Harness) EnsureAnonymousSecuritySource(t *testing.T) *FakeSecuritySource { t.Helper() + h.mu.Lock() + source := h.anonymousSecuritySource + h.mu.Unlock() + if source != nil { + return source + } + source = &FakeSecuritySource{} + h.mu.Lock() if h.anonymousSecuritySource == nil { - h.anonymousSecuritySource = &FakeSecuritySource{} + h.anonymousSecuritySource = source } - return h.anonymousSecuritySource + source = h.anonymousSecuritySource + h.mu.Unlock() + return source } type FakeSecuritySource struct { diff --git a/internal/api/integration_test/helpers/crypto.go b/internal/api/integration_test/helpers/crypto.go index 584bd0347..c2a0ba7a0 100644 --- a/internal/api/integration_test/helpers/crypto.go +++ b/internal/api/integration_test/helpers/crypto.go @@ -10,26 +10,35 @@ import ( func (h *Harness) EnsureHasher(t *testing.T) crypto.Hasher { t.Helper() - if h.Hasher == nil { - h.Hasher = createNewHasher(t) - } - return h.Hasher + return h.ensurePasswapHasher(t) } func (h *Harness) EnsureHashVerifier(t *testing.T) crypto.HashVerifier { t.Helper() - if h.Hasher == nil { - h.Hasher = createNewHasher(t) - } - return h.Hasher + return h.ensurePasswapHasher(t) } func (h *Harness) EnsureHashValidator(t *testing.T) crypto.HashValidator { t.Helper() + return h.ensurePasswapHasher(t) +} + +func (h *Harness) ensurePasswapHasher(t *testing.T) *crypto.PasswapHasher { + t.Helper() + h.mu.Lock() + hasher := h.Hasher + h.mu.Unlock() + if hasher != nil { + return hasher + } + hasher = createNewHasher(t) + h.mu.Lock() if h.Hasher == nil { - h.Hasher = createNewHasher(t) + h.Hasher = hasher } - return h.Hasher + hasher = h.Hasher + h.mu.Unlock() + return hasher } func createNewHasher(t *testing.T) *crypto.PasswapHasher { @@ -55,11 +64,21 @@ func createNewHasher(t *testing.T) *crypto.PasswapHasher { func (h *Harness) EnsureCrypter(t *testing.T) crypto.Crypter { t.Helper() + h.mu.Lock() + crypter := h.Crypter + h.mu.Unlock() + if crypter != nil { + return crypter + } + key := [32]byte([]byte("MasterkeyNeedsToHave32Characters")) + crypter = op.NewAES256GCMCrypto(key, "") + h.mu.Lock() if h.Crypter == nil { - key := [32]byte([]byte("MasterkeyNeedsToHave32Characters")) - h.Crypter = op.NewAES256GCMCrypto(key, "") + h.Crypter = crypter } - return h.Crypter + crypter = h.Crypter + h.mu.Unlock() + return crypter } func (h *Harness) EnsureEncrypter(t *testing.T) crypto.Encrypter { diff --git a/internal/api/integration_test/helpers/flow.go b/internal/api/integration_test/helpers/flow.go index 45e2b67f6..0cd9858c4 100644 --- a/internal/api/integration_test/helpers/flow.go +++ b/internal/api/integration_test/helpers/flow.go @@ -22,40 +22,70 @@ func (h *Harness) EnsureCreateUserHandler(t *testing.T) *domain.FlowCreateUserHa func (h *Harness) EnsureFlowService(t *testing.T) service.FlowService { t.Helper() + h.mu.Lock() + svc := h.FlowService + h.mu.Unlock() + if svc != nil { + return svc + } + svc = service.NewFlowService( + h.EnsureDBPool(t), + h.EnsureFlowDefinitionRepo(t), + h.EnsureFlowStateMachine(t), + idgen.NewULID(), + ) + h.mu.Lock() if h.FlowService == nil { - h.FlowService = service.NewFlowService( - h.EnsureDBPool(t), - h.EnsureFlowDefinitionRepo(t), - h.EnsureFlowStateMachine(t), - idgen.NewULID(), - ) + h.FlowService = svc } - return h.FlowService + svc = h.FlowService + h.mu.Unlock() + return svc } func (h *Harness) EnsureFlowStateMachine(t *testing.T) *domain.FlowStateMachineRuntime { t.Helper() + h.mu.Lock() + runtime := h.FlowStateMachine + h.mu.Unlock() + if runtime != nil { + return runtime + } + fields := domain.NewSchemaFieldResolver(h.EnsureSchemaResolver(t)) + authAdapter := service.NewFlowAuthAttemptAdapter(h.EnsureAuthAttemptService(t)) + passkeyRegSvc := service.NewPasskeyRegistrationService( + h.EnsureDBPool(t), + repository.NewPasskeyRegistrationRepository(), + h.EnsureUserPasskeyRepo(t), + idgen.NewULID(), + ) + passkeyRegAdapter := service.NewFlowPasskeyRegistrationAdapter(passkeyRegSvc) + runtime = domain.NewFlowStateMachine(fields, h.EnsureCreateUserHandler(t), authAdapter, passkeyRegAdapter, time.Now) + h.mu.Lock() if h.FlowStateMachine == nil { - fields := domain.NewSchemaFieldResolver(h.EnsureSchemaResolver(t)) - authAdapter := service.NewFlowAuthAttemptAdapter(h.EnsureAuthAttemptService(t)) - passkeyRegSvc := service.NewPasskeyRegistrationService( - h.EnsureDBPool(t), - repository.NewPasskeyRegistrationRepository(), - h.EnsureUserPasskeyRepo(t), - idgen.NewULID(), - ) - passkeyRegAdapter := service.NewFlowPasskeyRegistrationAdapter(passkeyRegSvc) - h.FlowStateMachine = domain.NewFlowStateMachine(fields, h.EnsureCreateUserHandler(t), authAdapter, passkeyRegAdapter, time.Now) + h.FlowStateMachine = runtime } - return h.FlowStateMachine + runtime = h.FlowStateMachine + h.mu.Unlock() + return runtime } func (h *Harness) EnsureFlowDefinitionRepo(t *testing.T) domain.FlowDefinitionRepository { t.Helper() + h.mu.Lock() + repo := h.FlowDefinitionRepo + h.mu.Unlock() + if repo != nil { + return repo + } + repo = repository.NewFlowDefinitionRepository( + h.EnsureDBPool(t), + ) + h.mu.Lock() if h.FlowDefinitionRepo == nil { - h.FlowDefinitionRepo = repository.NewFlowDefinitionRepository( - h.EnsureDBPool(t), - ) + h.FlowDefinitionRepo = repo } - return h.FlowDefinitionRepo + repo = h.FlowDefinitionRepo + h.mu.Unlock() + return repo } diff --git a/internal/api/integration_test/helpers/flow_definition.go b/internal/api/integration_test/helpers/flow_definition.go index 381879453..c69aee980 100644 --- a/internal/api/integration_test/helpers/flow_definition.go +++ b/internal/api/integration_test/helpers/flow_definition.go @@ -8,14 +8,24 @@ import ( func (h *Harness) EnsureFlowDefinitionService(t *testing.T) service.FlowDefinitionService { t.Helper() + h.mu.Lock() + svc := h.FlowDefinitionService + h.mu.Unlock() + if svc != nil { + return svc + } + svc = service.NewFlowDefinitionService( + h.EnsureDBPool(t), + h.EnsureSchemaService(t), + h.EnsureSchemaValidator(t), + nil, + h.EnsureFlowDefinitionRepo(t), + ) + h.mu.Lock() if h.FlowDefinitionService == nil { - h.FlowDefinitionService = service.NewFlowDefinitionService( - h.EnsureDBPool(t), - h.EnsureSchemaService(t), - h.EnsureSchemaValidator(t), - nil, - h.EnsureFlowDefinitionRepo(t), - ) + h.FlowDefinitionService = svc } - return h.FlowDefinitionService + svc = h.FlowDefinitionService + h.mu.Unlock() + return svc } diff --git a/internal/api/integration_test/helpers/harness.go b/internal/api/integration_test/helpers/harness.go index 113cee580..43c5cdb07 100644 --- a/internal/api/integration_test/helpers/harness.go +++ b/internal/api/integration_test/helpers/harness.go @@ -3,6 +3,7 @@ package helpers import ( "net/http" "net/http/httptest" + "sync" generated "github.com/zitadel/nextgen/api/generated" "github.com/zitadel/nextgen/internal/api" @@ -15,6 +16,9 @@ import ( ) type Harness struct { + mu sync.Mutex + schemaMu sync.Mutex + DBPool database.Pool HttpClient *http.Client TestServer *httptest.Server diff --git a/internal/api/integration_test/helpers/http_client.go b/internal/api/integration_test/helpers/http_client.go index 605840abd..66b87e548 100644 --- a/internal/api/integration_test/helpers/http_client.go +++ b/internal/api/integration_test/helpers/http_client.go @@ -7,10 +7,21 @@ import ( ) func (h *Harness) EnsureHttpClient(t *testing.T) *http.Client { + t.Helper() + h.mu.Lock() + client := h.HttpClient + h.mu.Unlock() + if client != nil { + return client + } + client = &http.Client{ + Timeout: 5 * time.Minute, + } + h.mu.Lock() if h.HttpClient == nil { - h.HttpClient = &http.Client{ - Timeout: 5 * time.Minute, - } + h.HttpClient = client } - return h.HttpClient + client = h.HttpClient + h.mu.Unlock() + return client } diff --git a/internal/api/integration_test/helpers/pg_database.go b/internal/api/integration_test/helpers/pg_database.go index 6a806dfb6..0d5758f8f 100644 --- a/internal/api/integration_test/helpers/pg_database.go +++ b/internal/api/integration_test/helpers/pg_database.go @@ -14,10 +14,19 @@ var Connector database.Connector func (h *Harness) EnsureDBPool(t *testing.T) database.Pool { t.Helper() + h.mu.Lock() + pool := h.DBPool + h.mu.Unlock() + if pool != nil { + return pool + } + pool, err := Connector.Connect(t.Context()) + require.NoError(t, err) + h.mu.Lock() if h.DBPool == nil { - var err error - h.DBPool, err = Connector.Connect(t.Context()) - require.NoError(t, err) + h.DBPool = pool } - return h.DBPool + pool = h.DBPool + h.mu.Unlock() + return pool } diff --git a/internal/api/integration_test/helpers/project.go b/internal/api/integration_test/helpers/project.go index 7bcfd50b6..0a7fbb6aa 100644 --- a/internal/api/integration_test/helpers/project.go +++ b/internal/api/integration_test/helpers/project.go @@ -10,27 +10,46 @@ import ( func (h *Harness) EnsureProjectRepo(t *testing.T) domain.ProjectRepository { t.Helper() + h.mu.Lock() + repo := h.ProjectRepo + h.mu.Unlock() + if repo != nil { + return repo + } + repo = repository.NewProjectRepository( + h.EnsureDBPool(t), + ) + h.mu.Lock() if h.ProjectRepo == nil { - h.ProjectRepo = repository.NewProjectRepository( - h.EnsureDBPool(t), - ) + h.ProjectRepo = repo } - - return h.ProjectRepo + repo = h.ProjectRepo + h.mu.Unlock() + return repo } func (h *Harness) EnsureProjectService(t *testing.T) service.ProjectService { t.Helper() + h.mu.Lock() + svc := h.ProjectService + h.mu.Unlock() + if svc != nil { + return svc + } + svc = service.NewProjectService( + h.EnsureDBPool(t), + h.EnsureProjectRepo(t), + h.EnsureSchemaRepo(t), + h.EnsureFlowDefinitionRepo(t), + h.EnsureSecretGenerator(t), + BuiltinSchemaBaseURL, + h.EnsureSchemaValidator(t), + ) + h.mu.Lock() if h.ProjectService == nil { - h.ProjectService = service.NewProjectService( - h.EnsureDBPool(t), - h.EnsureProjectRepo(t), - h.EnsureSchemaRepo(t), - h.EnsureFlowDefinitionRepo(t), - h.EnsureSecretGenerator(t), - BuiltinSchemaBaseURL, - h.EnsureSchemaValidator(t), - ) + h.ProjectService = svc } - return h.ProjectService + svc = h.ProjectService + h.mu.Unlock() + return svc } diff --git a/internal/api/integration_test/helpers/schema.go b/internal/api/integration_test/helpers/schema.go index b30098e07..3f161ff52 100644 --- a/internal/api/integration_test/helpers/schema.go +++ b/internal/api/integration_test/helpers/schema.go @@ -1,6 +1,8 @@ package helpers import ( + "encoding/json" + "net/http" "net/url" "testing" @@ -17,58 +19,115 @@ const BuiltinSchemaBaseURL = "https://test.example.schemas.com/schemas" func (h *Harness) EnsureSchemaService(t *testing.T) *service.SchemaService { t.Helper() + h.mu.Lock() + svc := h.SchemaService + h.mu.Unlock() + if svc != nil { + return svc + } + svc = service.NewSchemaService( + h.EnsureDBPool(t), + h.EnsureSchemaRepo(t), + h.EnsureSchemaResolver(t), + h.EnsureSchemaValidator(t), + ) + h.mu.Lock() if h.SchemaService == nil { - h.SchemaService = service.NewSchemaService( - h.EnsureDBPool(t), - h.EnsureSchemaRepo(t), - h.EnsureSchemaResolver(t), - h.EnsureSchemaValidator(t), - ) + h.SchemaService = svc } - return h.SchemaService + svc = h.SchemaService + h.mu.Unlock() + return svc } func (h *Harness) EnsureSchemaRepo(t *testing.T) domain.JSONSchemaRepository { t.Helper() + h.mu.Lock() + repo := h.SchemaRepo + h.mu.Unlock() + if repo != nil { + return repo + } + repo = repository.NewJSONSchemaRepository( + h.EnsureDBPool(t), + ) + h.mu.Lock() if h.SchemaRepo == nil { - h.SchemaRepo = repository.NewJSONSchemaRepository( - h.EnsureDBPool(t), - ) + h.SchemaRepo = repo } - return h.SchemaRepo + repo = h.SchemaRepo + h.mu.Unlock() + return repo } func (h *Harness) EnsureSchemaResolver(t *testing.T) *domain.JSONSchemaResolver { t.Helper() + h.mu.Lock() + resolver := h.SchemaResolver + h.mu.Unlock() + if resolver != nil { + return resolver + } + + cache, err := lru.New2Q[string, *jsonschema.Schema](100) + require.NoError(t, err) + resolver = domain.NewJSONSchemaResolver( + h.EnsureSchemaRepo(t), + cache, + 0, + 0, + h.EnsureHttpClient(t), + mustParseURL(t, BuiltinSchemaBaseURL), + ) + h.mu.Lock() if h.SchemaResolver == nil { - cache, err := lru.New2Q[string, *jsonschema.Schema](100) - require.NoError(t, err) - - h.SchemaResolver = domain.NewJSONSchemaResolver( - h.EnsureSchemaRepo(t), - cache, - 0, - 0, - h.EnsureHttpClient(t), - mustParseURL(t, BuiltinSchemaBaseURL), - ) + h.SchemaResolver = resolver } - return h.SchemaResolver + resolver = h.SchemaResolver + h.mu.Unlock() + return resolver } func (h *Harness) EnsureSchemaValidator(t *testing.T) *domain.SchemaValidator { t.Helper() + h.mu.Lock() + validator := h.SchemaValidator + h.mu.Unlock() + if validator != nil { + return validator + } + validator, err := domain.NewSchemaValidator(BuiltinSchemaBaseURL) + require.NoError(t, err) + h.mu.Lock() if h.SchemaValidator == nil { - schemaValidator, err := domain.NewSchemaValidator(BuiltinSchemaBaseURL) - require.NoError(t, err) - - h.SchemaValidator = schemaValidator + h.SchemaValidator = validator } - return h.SchemaValidator + validator = h.SchemaValidator + h.mu.Unlock() + return validator } func (h *Harness) CreateUserSchema(t *testing.T, projectID string, schema string) string { t.Helper() + return h.createUserSchema(t, projectID, schema, false) +} + +func (h *Harness) EnsureUserSchema(t *testing.T, projectID string, schema string) string { + t.Helper() + return h.createUserSchema(t, projectID, schema, true) +} + +func (h *Harness) EnsureUserSchemaWithID(t *testing.T, projectID string, schema string, schemaID string) string { + t.Helper() + return h.EnsureUserSchema(t, projectID, userSchemaWithID(t, schema, schemaID)) +} + +func (h *Harness) createUserSchema(t *testing.T, projectID string, schema string, allowExisting bool) string { + t.Helper() + h.schemaMu.Lock() + defer h.schemaMu.Unlock() + + schemaID := userSchemaID(t, schema) client := h.EnsureAPIClient(t, projectID) apiSchema := api.UserSchema{} @@ -84,11 +143,54 @@ func (h *Harness) CreateUserSchema(t *testing.T, projectID string, schema string } resp, err := client.CreateSchema(t.Context(), req, params) - require.NoError(t, err) - require.IsType(t, &api.CreateSchemaResponse{}, resp) + require.NoErrorf(t, err, "create schema response: %s", MustMarshal(t, resp)) + if created, ok := resp.(*api.CreateSchemaResponse); ok { + return created.ID + } + if allowExisting && isCreateSchemaConflict(resp) { + getResp, err := client.GetSchemaById(t.Context(), api.GetSchemaByIdParams{ + ID: schemaID, + ProjectID: api.ProjectID(projectID), + }) + require.NoErrorf(t, err, "get existing schema response: %s", MustMarshal(t, getResp)) + require.IsTypef(t, &api.GetSchemaByIdOK{}, getResp, "existing schema response: %s", MustMarshal(t, getResp)) + return schemaID + } + require.IsTypef(t, &api.CreateSchemaResponse{}, resp, "create schema response: %s", MustMarshal(t, resp)) return resp.(*api.CreateSchemaResponse).ID } +func isCreateSchemaConflict(resp api.CreateSchemaRes) bool { + switch r := resp.(type) { + case *api.CreateSchemaConflict: + return true + case *api.ErrorDetailsStatusCode: + return r.StatusCode == http.StatusConflict + default: + return false + } +} + +func userSchemaID(t *testing.T, schema string) string { + t.Helper() + var body struct { + ID string `json:"$id"` + } + require.NoError(t, json.Unmarshal([]byte(schema), &body)) + require.NotEmpty(t, body.ID) + return body.ID +} + +func userSchemaWithID(t *testing.T, schema string, schemaID string) string { + t.Helper() + var body map[string]any + require.NoError(t, json.Unmarshal([]byte(schema), &body)) + body["$id"] = schemaID + bs, err := json.Marshal(body) + require.NoError(t, err) + return string(bs) +} + func mustParseURL(t *testing.T, s string) *url.URL { u, err := url.Parse(s) require.NoError(t, err) diff --git a/internal/api/integration_test/helpers/secrets.go b/internal/api/integration_test/helpers/secrets.go index f42f2c8d4..0000d4c3d 100644 --- a/internal/api/integration_test/helpers/secrets.go +++ b/internal/api/integration_test/helpers/secrets.go @@ -8,8 +8,18 @@ import ( func (h *Harness) EnsureSecretGenerator(t *testing.T) secrets.Generator { t.Helper() + h.mu.Lock() + generator := h.SecretGenerator + h.mu.Unlock() + if generator != nil { + return generator + } + generator = secrets.NewRandomSecretGenerator() + h.mu.Lock() if h.SecretGenerator == nil { - h.SecretGenerator = secrets.NewRandomSecretGenerator() + h.SecretGenerator = generator } - return h.SecretGenerator + generator = h.SecretGenerator + h.mu.Unlock() + return generator } diff --git a/internal/api/integration_test/helpers/server.go b/internal/api/integration_test/helpers/server.go index 7a156d98f..ff582fff4 100644 --- a/internal/api/integration_test/helpers/server.go +++ b/internal/api/integration_test/helpers/server.go @@ -11,50 +11,89 @@ import ( func (h *Harness) EnsureTestServer(t *testing.T) *httptest.Server { t.Helper() + h.mu.Lock() + server := h.TestServer + h.mu.Unlock() + if server != nil { + return server + } + server = httptest.NewServer( + h.EnsureGeneratedServer(t), + ) + h.mu.Lock() if h.TestServer == nil { - h.TestServer = httptest.NewServer( - h.EnsureGeneratedServer(t), - ) + h.TestServer = server } - return h.TestServer + server = h.TestServer + h.mu.Unlock() + return server } func (h *Harness) EnsureGeneratedServer(t *testing.T) *generated.Server { t.Helper() + h.mu.Lock() + server := h.GeneratedServer + h.mu.Unlock() + if server != nil { + return server + } + server, err := generated.NewServer( + h.EnsureHandler(t), + h.EnsureSecurityHandler(t), + generated.WithErrorHandler(api.OgenErrorHandler), + ) + require.NoError(t, err) + h.mu.Lock() if h.GeneratedServer == nil { - var err error - h.GeneratedServer, err = generated.NewServer( - h.EnsureHandler(t), - h.EnsureSecurityHandler(t), - generated.WithErrorHandler(api.OgenErrorHandler), - ) - require.NoError(t, err) - } - return h.GeneratedServer + h.GeneratedServer = server + } + server = h.GeneratedServer + h.mu.Unlock() + return server } func (h *Harness) EnsureHandler(t *testing.T) *api.Handler { t.Helper() + h.mu.Lock() + handler := h.Handler + h.mu.Unlock() + if handler != nil { + return handler + } + handler = api.NewHandler( + h.EnsureCrypter(t), + h.EnsureFlowService(t), + h.EnsureAuthAttemptService(t), + h.EnsureSessionService(t), + h.EnsureProjectService(t), + h.EnsureUserService(t), + h.EnsureSchemaService(t), + h.EnsureFlowDefinitionService(t), + h.EnsureTeamService(t), + ) + h.mu.Lock() if h.Handler == nil { - h.Handler = api.NewHandler( - h.EnsureCrypter(t), - h.EnsureFlowService(t), - h.EnsureAuthAttemptService(t), - h.EnsureSessionService(t), - h.EnsureProjectService(t), - h.EnsureUserService(t), - h.EnsureSchemaService(t), - h.EnsureFlowDefinitionService(t), - h.EnsureTeamService(t), - ) - } - return h.Handler + h.Handler = handler + } + handler = h.Handler + h.mu.Unlock() + return handler } func (h *Harness) EnsureSecurityHandler(t *testing.T) *api.SecurityHandler { t.Helper() + h.mu.Lock() + handler := h.SecurityHandler + h.mu.Unlock() + if handler != nil { + return handler + } + handler = api.NewSecurityHandler() + h.mu.Lock() if h.SecurityHandler == nil { - h.SecurityHandler = api.NewSecurityHandler() + h.SecurityHandler = handler } - return h.SecurityHandler + handler = h.SecurityHandler + h.mu.Unlock() + return handler } diff --git a/internal/api/integration_test/helpers/session.go b/internal/api/integration_test/helpers/session.go index d87a3d415..9bb818aee 100644 --- a/internal/api/integration_test/helpers/session.go +++ b/internal/api/integration_test/helpers/session.go @@ -11,22 +11,42 @@ import ( func (h *Harness) EnsureSessionService(t *testing.T) service.SessionService { t.Helper() + h.mu.Lock() + svc := h.SessionService + h.mu.Unlock() + if svc != nil { + return svc + } + svc = service.NewSessionService( + h.EnsureDBPool(t), + h.EnsureSessionRepo(t), + service.SessionConfig{DefaultTTL: time.Hour, MaxTTL: 24 * time.Hour}, + ) + h.mu.Lock() if h.SessionService == nil { - h.SessionService = service.NewSessionService( - h.EnsureDBPool(t), - h.EnsureSessionRepo(t), - service.SessionConfig{DefaultTTL: time.Hour, MaxTTL: 24 * time.Hour}, - ) + h.SessionService = svc } - return h.SessionService + svc = h.SessionService + h.mu.Unlock() + return svc } func (h *Harness) EnsureSessionRepo(t *testing.T) domain.SessionRepository { t.Helper() + h.mu.Lock() + repo := h.SessionRepo + h.mu.Unlock() + if repo != nil { + return repo + } + repo = repository.NewSessionRepository( + h.EnsureDBPool(t), + ) + h.mu.Lock() if h.SessionRepo == nil { - h.SessionRepo = repository.NewSessionRepository( - h.EnsureDBPool(t), - ) + h.SessionRepo = repo } - return h.SessionRepo + repo = h.SessionRepo + h.mu.Unlock() + return repo } diff --git a/internal/api/integration_test/helpers/spanner_database.go b/internal/api/integration_test/helpers/spanner_database.go index e6f77ff21..efc08fd49 100644 --- a/internal/api/integration_test/helpers/spanner_database.go +++ b/internal/api/integration_test/helpers/spanner_database.go @@ -14,10 +14,19 @@ var Connector database.Connector func (h *Harness) EnsureDBPool(t *testing.T) database.Pool { t.Helper() + h.mu.Lock() + pool := h.DBPool + h.mu.Unlock() + if pool != nil { + return pool + } + pool, err := Connector.Connect(t.Context()) + require.NoError(t, err) + h.mu.Lock() if h.DBPool == nil { - var err error - h.DBPool, err = Connector.Connect(t.Context()) - require.NoError(t, err) + h.DBPool = pool } - return h.DBPool + pool = h.DBPool + h.mu.Unlock() + return pool } diff --git a/internal/api/integration_test/helpers/team.go b/internal/api/integration_test/helpers/team.go index fa793d0ca..23ff7a029 100644 --- a/internal/api/integration_test/helpers/team.go +++ b/internal/api/integration_test/helpers/team.go @@ -10,22 +10,41 @@ import ( func (h *Harness) EnsureTeamRepo(t *testing.T) domain.TeamRepository { t.Helper() + h.mu.Lock() + repo := h.TeamRepo + h.mu.Unlock() + if repo != nil { + return repo + } + repo = repository.NewTeamRepository( + h.EnsureDBPool(t), + ) + h.mu.Lock() if h.TeamRepo == nil { - h.TeamRepo = repository.NewTeamRepository( - h.EnsureDBPool(t), - ) + h.TeamRepo = repo } - - return h.TeamRepo + repo = h.TeamRepo + h.mu.Unlock() + return repo } func (h *Harness) EnsureTeamService(t *testing.T) *service.TeamService { t.Helper() + h.mu.Lock() + svc := h.TeamService + h.mu.Unlock() + if svc != nil { + return svc + } + svc = service.NewTeamService( + h.EnsureDBPool(t), + h.EnsureTeamRepo(t), + ) + h.mu.Lock() if h.TeamService == nil { - h.TeamService = service.NewTeamService( - h.EnsureDBPool(t), - h.EnsureTeamRepo(t), - ) + h.TeamService = svc } - return h.TeamService + svc = h.TeamService + h.mu.Unlock() + return svc } diff --git a/internal/api/integration_test/helpers/user.go b/internal/api/integration_test/helpers/user.go index 1201c59ee..c65de7b2b 100644 --- a/internal/api/integration_test/helpers/user.go +++ b/internal/api/integration_test/helpers/user.go @@ -12,26 +12,45 @@ import ( func (h *Harness) EnsureUserService(t *testing.T) *service.UserService { t.Helper() + h.mu.Lock() + svc := h.UserService + h.mu.Unlock() + if svc != nil { + return svc + } + svc = service.NewUserService( + h.EnsureDBPool(t), + h.EnsureUserRepo(t), + h.EnsureUserPasswordRepo(t), + h.EnsureSchemaRepo(t), + h.EnsureDecrypter(t), + h.EnsureHasher(t), + ) + h.mu.Lock() if h.UserService == nil { - h.UserService = service.NewUserService( - h.EnsureDBPool(t), - h.EnsureUserRepo(t), - h.EnsureUserPasswordRepo(t), - h.EnsureSchemaRepo(t), - h.EnsureDecrypter(t), - h.EnsureHasher(t), - ) + h.UserService = svc } - return h.UserService + svc = h.UserService + h.mu.Unlock() + return svc } func (h *Harness) EnsureUserRepo(t *testing.T) domain.UserRepository { t.Helper() + h.mu.Lock() + repo := h.UserRepo + h.mu.Unlock() + if repo != nil { + return repo + } + repo = repository.NewUserRepository() + h.mu.Lock() if h.UserRepo == nil { - h.UserRepo = repository.NewUserRepository() + h.UserRepo = repo } - - return h.UserRepo + repo = h.UserRepo + h.mu.Unlock() + return repo } func CreateSessionUsingPassword(t *testing.T, diff --git a/internal/api/integration_test/helpers/user_passkey.go b/internal/api/integration_test/helpers/user_passkey.go index bab4f4f05..bd8363de1 100644 --- a/internal/api/integration_test/helpers/user_passkey.go +++ b/internal/api/integration_test/helpers/user_passkey.go @@ -9,9 +9,18 @@ import ( func (h *Harness) EnsureUserPasskeyRepo(t *testing.T) domain.UserPasskeyRepository { t.Helper() + h.mu.Lock() + repo := h.UserPasskeyRepo + h.mu.Unlock() + if repo != nil { + return repo + } + repo = repository.NewUserPasskeyRepository() + h.mu.Lock() if h.UserPasskeyRepo == nil { - h.UserPasskeyRepo = repository.NewUserPasskeyRepository() + h.UserPasskeyRepo = repo } - - return h.UserPasskeyRepo + repo = h.UserPasskeyRepo + h.mu.Unlock() + return repo } diff --git a/internal/api/integration_test/helpers/user_password.go b/internal/api/integration_test/helpers/user_password.go index 0ef078f9d..8958e6412 100644 --- a/internal/api/integration_test/helpers/user_password.go +++ b/internal/api/integration_test/helpers/user_password.go @@ -9,9 +9,18 @@ import ( func (h *Harness) EnsureUserPasswordRepo(t *testing.T) domain.UserPasswordRepository { t.Helper() + h.mu.Lock() + repo := h.UserPasswordRepo + h.mu.Unlock() + if repo != nil { + return repo + } + repo = repository.NewUserPasswordRepository() + h.mu.Lock() if h.UserPasswordRepo == nil { - h.UserPasswordRepo = repository.NewUserPasswordRepository() + h.UserPasswordRepo = repo } - - return h.UserPasswordRepo + repo = h.UserPasswordRepo + h.mu.Unlock() + return repo } diff --git a/internal/storage/database/repository/json_schema_test.go b/internal/storage/database/repository/json_schema_test.go index 2446b0774..855218146 100644 --- a/internal/storage/database/repository/json_schema_test.go +++ b/internal/storage/database/repository/json_schema_test.go @@ -12,9 +12,6 @@ import ( ) func TestJSONSchemaRepository_CRUD(t *testing.T) { - if isSpannerDB { - t.Skip("JSONSchemaRepository uses []byte for payload; Spanner requires spanner.NullJSON — tracked separately") - } tx, rollback := transactionForRollback(t) repo := repository.NewJSONSchemaRepository(tx) defer rollback()