diff --git a/Makefile b/Makefile index c79767e..01fd430 100644 --- a/Makefile +++ b/Makefile @@ -27,10 +27,11 @@ help: @echo " make clean - Clean build artifacts" @echo " make schemathesis - Run Schemathesis API tests (requires API server running)" -# Run all tests (integration tests in tests/ directory) +# Run all tests (integration tests in tests/ directory). +# Loads .env if present so DATABASE_URL (e.g. port 5433) is used when Postgres runs on a non-default port. tests: @echo "Running integration tests..." - go test ./tests/... -v + @(set -a && [ -f .env ] && . ./.env && set +a; go test ./tests/... -v) # Run unit tests (fast, no database required) test-unit: diff --git a/internal/api/handlers/feedback_records_handler.go b/internal/api/handlers/feedback_records_handler.go index bdc6d80..22041bf 100644 --- a/internal/api/handlers/feedback_records_handler.go +++ b/internal/api/handlers/feedback_records_handler.go @@ -64,6 +64,12 @@ func (h *FeedbackRecordsHandler) Create(w http.ResponseWriter, r *http.Request) return } + if errors.Is(err, huberrors.ErrConflict) { + response.RespondConflict(w, err.Error()) + + return + } + response.RespondInternalServerError(w, "An unexpected error occurred") return diff --git a/internal/api/response/response.go b/internal/api/response/response.go index c026a04..4430882 100644 --- a/internal/api/response/response.go +++ b/internal/api/response/response.go @@ -56,6 +56,11 @@ func RespondNotFound(w http.ResponseWriter, detail string) { RespondError(w, http.StatusNotFound, "Not Found", detail) } +// RespondConflict writes a 409 Conflict error response. +func RespondConflict(w http.ResponseWriter, detail string) { + RespondError(w, http.StatusConflict, "Conflict", detail) +} + // RespondInternalServerError writes a 500 Internal Server Error response. func RespondInternalServerError(w http.ResponseWriter, detail string) { RespondError(w, http.StatusInternalServerError, "Internal Server Error", detail) diff --git a/internal/huberrors/errors.go b/internal/huberrors/errors.go index b40b7f5..bac2e08 100644 --- a/internal/huberrors/errors.go +++ b/internal/huberrors/errors.go @@ -106,3 +106,32 @@ func (e *LimitExceededError) Is(target error) bool { return ok } + +// ErrConflict is the sentinel for conflict errors (e.g. duplicate tenant_id + submission_id + field_id). +var ErrConflict = &ConflictError{} + +// ConflictError is a sentinel error for resource conflicts. +type ConflictError struct { + Message string +} + +// NewConflictError creates a ConflictError with a custom message. +func NewConflictError(message string) *ConflictError { + return &ConflictError{Message: message} +} + +// Error implements the error interface. +func (e *ConflictError) Error() string { + if e.Message != "" { + return e.Message + } + + return "conflict" +} + +// Is implements the error interface for error comparison. +func (e *ConflictError) Is(target error) bool { + _, ok := target.(*ConflictError) + + return ok +} diff --git a/internal/models/feedback_records.go b/internal/models/feedback_records.go index 3dcf114..16169e7 100644 --- a/internal/models/feedback_records.go +++ b/internal/models/feedback_records.go @@ -102,6 +102,7 @@ type FeedbackRecord struct { Language *string `json:"language,omitempty"` UserIdentifier *string `json:"user_identifier,omitempty"` TenantID *string `json:"tenant_id,omitempty"` + SubmissionID *string `json:"submission_id,omitempty"` } // CreateFeedbackRecordRequest represents the request to create a feedback record. @@ -123,6 +124,7 @@ type CreateFeedbackRecordRequest struct { Language *string `json:"language,omitempty" validate:"omitempty,no_null_bytes,max=10"` UserIdentifier *string `json:"user_identifier,omitempty"` TenantID *string `json:"tenant_id,omitempty" validate:"omitempty,no_null_bytes,max=255"` + SubmissionID string `json:"submission_id" validate:"required,no_null_bytes,min=1,max=255"` } // UpdateFeedbackRecordRequest represents the request to update a feedback record @@ -174,6 +176,7 @@ func (r *UpdateFeedbackRecordRequest) ChangedFields() []string { // ListFeedbackRecordsFilters represents filters for listing feedback records. type ListFeedbackRecordsFilters struct { TenantID *string `form:"tenant_id" validate:"omitempty,no_null_bytes"` + SubmissionID *string `form:"submission_id" validate:"omitempty,no_null_bytes"` SourceType *string `form:"source_type" validate:"omitempty,no_null_bytes"` SourceID *string `form:"source_id" validate:"omitempty,no_null_bytes"` FieldID *string `form:"field_id" validate:"omitempty,no_null_bytes"` diff --git a/internal/repository/feedback_records_repository.go b/internal/repository/feedback_records_repository.go index 4c54b30..e09db10 100644 --- a/internal/repository/feedback_records_repository.go +++ b/internal/repository/feedback_records_repository.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "github.com/formbricks/hub/internal/huberrors" @@ -38,14 +39,14 @@ func (r *FeedbackRecordsRepository) Create(ctx context.Context, req *models.Crea collected_at, source_type, source_id, source_name, field_id, field_label, field_type, field_group_id, field_group_label, value_text, value_number, value_boolean, value_date, - metadata, language, user_identifier, tenant_id + metadata, language, user_identifier, tenant_id, submission_id ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) RETURNING id, collected_at, created_at, updated_at, source_type, source_id, source_name, field_id, field_label, field_type, field_group_id, field_group_label, value_text, value_number, value_boolean, value_date, - metadata, language, user_identifier, tenant_id + metadata, language, user_identifier, tenant_id, submission_id ` var record models.FeedbackRecord @@ -54,15 +55,20 @@ func (r *FeedbackRecordsRepository) Create(ctx context.Context, req *models.Crea collectedAt, req.SourceType, req.SourceID, req.SourceName, req.FieldID, req.FieldLabel, req.FieldType, req.FieldGroupID, req.FieldGroupLabel, req.ValueText, req.ValueNumber, req.ValueBoolean, req.ValueDate, - req.Metadata, req.Language, req.UserIdentifier, req.TenantID, + req.Metadata, req.Language, req.UserIdentifier, req.TenantID, req.SubmissionID, ).Scan( &record.ID, &record.CollectedAt, &record.CreatedAt, &record.UpdatedAt, &record.SourceType, &record.SourceID, &record.SourceName, &record.FieldID, &record.FieldLabel, &record.FieldType, &record.FieldGroupID, &record.FieldGroupLabel, &record.ValueText, &record.ValueNumber, &record.ValueBoolean, &record.ValueDate, - &record.Metadata, &record.Language, &record.UserIdentifier, &record.TenantID, + &record.Metadata, &record.Language, &record.UserIdentifier, &record.TenantID, &record.SubmissionID, ) if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return nil, huberrors.NewConflictError("a feedback record with this tenant_id, submission_id, and field_id already exists") + } + return nil, fmt.Errorf("failed to create feedback record: %w", err) } @@ -76,7 +82,7 @@ func (r *FeedbackRecordsRepository) GetByID(ctx context.Context, id uuid.UUID) ( source_type, source_id, source_name, field_id, field_label, field_type, field_group_id, field_group_label, value_text, value_number, value_boolean, value_date, - metadata, language, user_identifier, tenant_id + metadata, language, user_identifier, tenant_id, submission_id FROM feedback_records WHERE id = $1 ` @@ -88,7 +94,7 @@ func (r *FeedbackRecordsRepository) GetByID(ctx context.Context, id uuid.UUID) ( &record.SourceType, &record.SourceID, &record.SourceName, &record.FieldID, &record.FieldLabel, &record.FieldType, &record.FieldGroupID, &record.FieldGroupLabel, &record.ValueText, &record.ValueNumber, &record.ValueBoolean, &record.ValueDate, - &record.Metadata, &record.Language, &record.UserIdentifier, &record.TenantID, + &record.Metadata, &record.Language, &record.UserIdentifier, &record.TenantID, &record.SubmissionID, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -114,6 +120,12 @@ func buildFilterConditions(filters *models.ListFeedbackRecordsFilters) (whereCla argCount++ } + if filters.SubmissionID != nil { + conditions = append(conditions, fmt.Sprintf("submission_id = $%d", argCount)) + args = append(args, *filters.SubmissionID) + argCount++ + } + if filters.SourceType != nil { conditions = append(conditions, fmt.Sprintf("source_type = $%d", argCount)) args = append(args, *filters.SourceType) @@ -175,7 +187,7 @@ func (r *FeedbackRecordsRepository) List(ctx context.Context, filters *models.Li source_type, source_id, source_name, field_id, field_label, field_type, field_group_id, field_group_label, value_text, value_number, value_boolean, value_date, - metadata, language, user_identifier, tenant_id + metadata, language, user_identifier, tenant_id, submission_id FROM feedback_records ` @@ -214,7 +226,7 @@ func (r *FeedbackRecordsRepository) List(ctx context.Context, filters *models.Li &record.SourceType, &record.SourceID, &record.SourceName, &record.FieldID, &record.FieldLabel, &record.FieldType, &record.FieldGroupID, &record.FieldGroupLabel, &record.ValueText, &record.ValueNumber, &record.ValueBoolean, &record.ValueDate, - &record.Metadata, &record.Language, &record.UserIdentifier, &record.TenantID, + &record.Metadata, &record.Language, &record.UserIdentifier, &record.TenantID, &record.SubmissionID, ) if err != nil { return nil, fmt.Errorf("failed to scan feedback record: %w", err) @@ -316,7 +328,7 @@ func buildUpdateQuery( source_type, source_id, source_name, field_id, field_label, field_type, field_group_id, field_group_label, value_text, value_number, value_boolean, value_date, - metadata, language, user_identifier, tenant_id + metadata, language, user_identifier, tenant_id, submission_id `, strings.Join(updates, ", "), argCount) return query, args, true @@ -339,7 +351,7 @@ func (r *FeedbackRecordsRepository) Update( &record.SourceType, &record.SourceID, &record.SourceName, &record.FieldID, &record.FieldLabel, &record.FieldType, &record.FieldGroupID, &record.FieldGroupLabel, &record.ValueText, &record.ValueNumber, &record.ValueBoolean, &record.ValueDate, - &record.Metadata, &record.Language, &record.UserIdentifier, &record.TenantID, + &record.Metadata, &record.Language, &record.UserIdentifier, &record.TenantID, &record.SubmissionID, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/migrations/003_add_feedback_records_submission_id.sql b/migrations/003_add_feedback_records_submission_id.sql new file mode 100644 index 0000000..96ed9f4 --- /dev/null +++ b/migrations/003_add_feedback_records_submission_id.sql @@ -0,0 +1,22 @@ +-- +goose up +-- Add submission_id to feedback_records for grouping records belonging to one logical submission. +-- Enables idempotent multi-field ingestion and simpler grouped reads (e.g. GET ?submission_id=...). +-- Mandatory (NOT NULL): every record must belong to a submission; use e.g. field_id if single-field. + +ALTER TABLE feedback_records + ADD COLUMN submission_id VARCHAR(255) NOT NULL; + +-- Index for filtering by submission_id (and tenant-scoped list by submission) +CREATE INDEX idx_feedback_records_submission_id ON feedback_records(submission_id); +CREATE INDEX idx_feedback_records_tenant_submission_id ON feedback_records(tenant_id, submission_id); + +-- One value per field_id per submission per tenant (supports idempotent webhook retries). +-- NULLS NOT DISTINCT (PG 15+): treat NULL tenant_id as equal so duplicate (NULL, submission_id, field_id) conflicts. +CREATE UNIQUE INDEX idx_feedback_records_tenant_submission_field + ON feedback_records(tenant_id, submission_id, field_id) NULLS NOT DISTINCT; + +-- +goose down +DROP INDEX IF EXISTS idx_feedback_records_tenant_submission_field; +DROP INDEX IF EXISTS idx_feedback_records_tenant_submission_id; +DROP INDEX IF EXISTS idx_feedback_records_submission_id; +ALTER TABLE feedback_records DROP COLUMN IF EXISTS submission_id; diff --git a/openapi.yaml b/openapi.yaml index bd678e9..91d887d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -52,6 +52,13 @@ paths: type: string description: Filter by tenant ID (for multi-tenant deployments). NULL bytes not allowed. pattern: '^[^\x00]*$' + - name: submission_id + in: query + description: Filter by submission ID to group records belonging to one logical submission. NULL bytes not allowed. + schema: + type: string + description: Filter by submission ID (e.g. UUID from webhook idempotency). NULL bytes not allowed. + pattern: '^[^\x00]*$' - name: source_type in: query description: Filter by source type. NULL bytes not allowed. @@ -323,6 +330,12 @@ paths: parameters: id: '$response.body#/id' description: Delete the created feedback record by ID + "409": + description: Conflict – a feedback record with the same tenant_id, submission_id, and field_id already exists (idempotent create). + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ErrorModel' default: description: Error content: @@ -828,6 +841,17 @@ components: minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' + submission_id: + type: string + description: | + Identifier for the logical submission this record belongs to (tenant-scoped). Required. + Enables grouping multi-field submissions and idempotent ingestion. + Unique per (tenant_id, submission_id, field_id). If a record has no logical submission, use e.g. field_id. + examples: + - "550e8400-e29b-41d4-a716-446655440000" + minLength: 1 + maxLength: 255 + pattern: '^[^\x00]*$' tenant_id: type: string description: Tenant/organization identifier for multi-tenancy. NULL bytes not allowed. @@ -867,6 +891,7 @@ components: - source_type - field_id - field_type + - submission_id ErrorDetail: type: object additionalProperties: false @@ -975,6 +1000,10 @@ components: source_type: type: string description: Type of feedback source + submission_id: + type: string + description: Identifier for the logical submission this record belongs to (required). + pattern: '^[^\x00]*$' tenant_id: type: string description: Tenant/organization identifier. NULL bytes not allowed. @@ -1009,6 +1038,7 @@ components: - source_type - field_id - field_type + - submission_id ListFeedbackRecordsOutputBody: type: object additionalProperties: false diff --git a/tests/integration_test.go b/tests/integration_test.go index a8af633..1305683 100644 --- a/tests/integration_test.go +++ b/tests/integration_test.go @@ -8,8 +8,11 @@ import ( "io" "net/http" "net/http/httptest" + "os" "testing" + "github.com/google/uuid" + "github.com/joho/godotenv" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -24,17 +27,27 @@ import ( ) // defaultTestDatabaseURL is the default Postgres URL used by compose (postgres/postgres/test_db). -// Setting it here before config.Load() ensures tests do not use a different DATABASE_URL from .env, -// which would cause "password authentication failed" when .env points at another database. +// Used when DATABASE_URL is not set (e.g. CI uses job env; local can rely on .env). const defaultTestDatabaseURL = "postgres://postgres:postgres@localhost:5432/test_db?sslmode=disable" // setupTestServer creates a test HTTP server with all routes configured. func setupTestServer(t *testing.T) (server *httptest.Server, cleanup func()) { ctx := context.Background() - // Set test env before loading config so config.Load() uses test values and is not affected by .env. + // Load .env so DATABASE_URL (e.g. port 5433) is used when set; otherwise use default (5432). + // Try current dir and parent (e.g. when test cwd is tests/). + _ = godotenv.Load() + if os.Getenv("DATABASE_URL") == "" { + _ = godotenv.Load("../.env") + } + + databaseURL := os.Getenv("DATABASE_URL") + if databaseURL == "" { + databaseURL = defaultTestDatabaseURL + } + t.Setenv("API_KEY", testAPIKey) - t.Setenv("DATABASE_URL", defaultTestDatabaseURL) + t.Setenv("DATABASE_URL", databaseURL) // Load configuration cfg, err := config.Load() @@ -134,10 +147,11 @@ func TestCreateFeedbackRecord(t *testing.T) { // Test without authentication t.Run("Unauthorized without API key", func(t *testing.T) { reqBody := map[string]any{ - "source_type": "formbricks", - "field_id": "feedback", - "field_type": "text", - "value_text": "Great product!", + "source_type": "formbricks", + "submission_id": "feedback", + "field_id": "feedback", + "field_type": "text", + "value_text": "Great product!", } body, err := json.Marshal(reqBody) require.NoError(t, err) @@ -154,10 +168,11 @@ func TestCreateFeedbackRecord(t *testing.T) { // Test with invalid API key t.Run("Unauthorized with invalid API key", func(t *testing.T) { reqBody := map[string]any{ - "source_type": "formbricks", - "field_id": "feedback", - "field_type": "text", - "value_text": "Great product!", + "source_type": "formbricks", + "submission_id": "feedback", + "field_id": "feedback", + "field_type": "text", + "value_text": "Great product!", } body, err := json.Marshal(reqBody) require.NoError(t, err) @@ -177,10 +192,11 @@ func TestCreateFeedbackRecord(t *testing.T) { // Test with empty API key in header t.Run("Unauthorized with empty API key", func(t *testing.T) { reqBody := map[string]any{ - "source_type": "formbricks", - "field_id": "feedback", - "field_type": "text", - "value_text": "Great product!", + "source_type": "formbricks", + "submission_id": "feedback", + "field_id": "feedback", + "field_type": "text", + "value_text": "Great product!", } body, err := json.Marshal(reqBody) require.NoError(t, err) @@ -200,10 +216,11 @@ func TestCreateFeedbackRecord(t *testing.T) { // Test with malformed Authorization header t.Run("Unauthorized with malformed Authorization header", func(t *testing.T) { reqBody := map[string]any{ - "source_type": "formbricks", - "field_id": "feedback", - "field_type": "text", - "value_text": "Great product!", + "source_type": "formbricks", + "submission_id": "feedback", + "field_id": "feedback", + "field_type": "text", + "value_text": "Great product!", } body, err := json.Marshal(reqBody) require.NoError(t, err) @@ -220,13 +237,14 @@ func TestCreateFeedbackRecord(t *testing.T) { require.NoError(t, resp.Body.Close()) }) - // Test with valid authentication + // Test with valid authentication (use unique submission_id to avoid 409 from leftover data) t.Run("Success with valid API key", func(t *testing.T) { reqBody := map[string]any{ - "source_type": "formbricks", - "field_id": "feedback", - "field_type": "text", - "value_text": "Great product!", + "source_type": "formbricks", + "submission_id": uuid.New().String(), + "field_id": "feedback", + "field_type": "text", + "value_text": "Great product!", } body, err := json.Marshal(reqBody) require.NoError(t, err) @@ -294,12 +312,13 @@ func TestListFeedbackRecords(t *testing.T) { require.NoError(t, resp.Body.Close()) }) - // Create a test feedback record first + // Create a test feedback record first (unique submission_id per run) reqBody := map[string]any{ - "source_type": "formbricks", - "field_id": "nps_score", - "field_type": "number", - "value_number": 9, + "source_type": "formbricks", + "submission_id": uuid.New().String(), + "field_id": "nps_score", + "field_type": "number", + "value_number": 9, } body, err := json.Marshal(reqBody) require.NoError(t, err) @@ -354,6 +373,118 @@ func TestListFeedbackRecords(t *testing.T) { }) } +func TestFeedbackRecordsSubmissionID(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + client := &http.Client{} + subID := uuid.New().String() // unique per run to avoid 409 from leftover data + tenantID := "tenant-submission-test" + + // Create two records with same submission_id (multi-field submission) + createPayload := func(fieldID string, value any) map[string]any { + p := map[string]any{ + "source_type": "formbricks", + "submission_id": subID, + "tenant_id": tenantID, + "field_id": fieldID, + "field_type": "text", + } + if v, ok := value.(string); ok { + p["value_text"] = v + } + + return p + } + + for _, item := range []struct { + fieldID string + value string + }{ + {"reason", "cancelled"}, + {"comment", "Too expensive"}, + } { + body, err := json.Marshal(createPayload(item.fieldID, item.value)) + require.NoError(t, err) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, server.URL+"/v1/feedback-records", bytes.NewBuffer(body)) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+testAPIKey) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + require.NoError(t, resp.Body.Close()) + } + + // List by submission_id + t.Run("List by submission_id", func(t *testing.T) { + listURL := server.URL + "/v1/feedback-records?submission_id=" + subID + "&tenant_id=" + tenantID + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, listURL, http.NoBody) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+testAPIKey) + + resp, err := client.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result models.ListFeedbackRecordsResponse + + err = decodeData(resp, &result) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + assert.GreaterOrEqual(t, len(result.Data), 2) + + for _, rec := range result.Data { + require.NotNil(t, rec.SubmissionID) + assert.Equal(t, subID, *rec.SubmissionID) + require.NotNil(t, rec.TenantID) + assert.Equal(t, tenantID, *rec.TenantID) + } + }) +} + +func TestFeedbackRecordsSubmissionIDUniqueConstraint(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + client := &http.Client{} + subID := uuid.New().String() // unique per run so first create succeeds + tenantID := "tenant-unique" + fieldID := "reason" + + reqBody := map[string]any{ + "source_type": "formbricks", + "submission_id": subID, + "tenant_id": tenantID, + "field_id": fieldID, + "field_type": "text", + "value_text": "first", + } + body, err := json.Marshal(reqBody) + require.NoError(t, err) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, server.URL+"/v1/feedback-records", bytes.NewBuffer(body)) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+testAPIKey) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + require.NoError(t, resp.Body.Close()) + + // Duplicate (same tenant_id, submission_id, field_id) must return 409 + req2, err := http.NewRequestWithContext(context.Background(), http.MethodPost, server.URL+"/v1/feedback-records", bytes.NewBuffer(body)) + require.NoError(t, err) + req2.Header.Set("Authorization", "Bearer "+testAPIKey) + req2.Header.Set("Content-Type", "application/json") + resp2, err := client.Do(req2) + require.NoError(t, err) + + defer func() { _ = resp2.Body.Close() }() + + assert.Equal(t, http.StatusConflict, resp2.StatusCode) +} + func TestGetFeedbackRecord(t *testing.T) { server, cleanup := setupTestServer(t) defer cleanup() @@ -373,12 +504,13 @@ func TestGetFeedbackRecord(t *testing.T) { require.NoError(t, resp.Body.Close()) }) - // Create a test feedback record + // Create a test feedback record (unique submission_id per run) reqBody := map[string]any{ - "source_type": "formbricks", - "field_id": "rating", - "field_type": "number", - "value_number": 5, + "source_type": "formbricks", + "submission_id": uuid.New().String(), + "field_id": "rating", + "field_type": "number", + "value_number": 5, } body, err := json.Marshal(reqBody) require.NoError(t, err) @@ -456,12 +588,13 @@ func TestUpdateFeedbackRecord(t *testing.T) { require.NoError(t, resp.Body.Close()) }) - // Create a test feedback record + // Create a test feedback record (unique submission_id per run) reqBody := map[string]any{ - "source_type": "formbricks", - "field_id": "comment", - "field_type": "text", - "value_text": "Initial comment", + "source_type": "formbricks", + "submission_id": uuid.New().String(), + "field_id": "comment", + "field_type": "text", + "value_text": "Initial comment", } body, err := json.Marshal(reqBody) require.NoError(t, err) @@ -528,12 +661,13 @@ func TestDeleteFeedbackRecord(t *testing.T) { require.NoError(t, resp.Body.Close()) }) - // Create a test feedback record + // Create a test feedback record (unique submission_id per run) reqBody := map[string]any{ - "source_type": "formbricks", - "field_id": "temp", - "field_type": "text", - "value_text": "To be deleted", + "source_type": "formbricks", + "submission_id": uuid.New().String(), + "field_id": "temp", + "field_type": "text", + "value_text": "To be deleted", } body, err := json.Marshal(reqBody) require.NoError(t, err) @@ -584,11 +718,13 @@ func TestBulkDeleteFeedbackRecords(t *testing.T) { client := &http.Client{} userID := "bulk-delete-test-user-123" + subID := uuid.New().String() // unique per run to avoid 409 from leftover data // Create several feedback records with the same user_identifier createPayload := func(fieldID string, valueNum float64) map[string]any { return map[string]any{ "source_type": "formbricks", + "submission_id": subID, "user_identifier": userID, "field_id": fieldID, "field_type": "number", @@ -683,6 +819,7 @@ func TestBulkDeleteFeedbackRecords(t *testing.T) { } { body, err := json.Marshal(map[string]any{ "source_type": "formbricks", + "submission_id": userIDTenant + "-" + item.fieldID, "user_identifier": userIDTenant, "tenant_id": item.tenantID, "field_id": item.fieldID, @@ -735,8 +872,15 @@ func TestBulkDeleteFeedbackRecords(t *testing.T) { func TestFeedbackRecordsRepository_BulkDelete(t *testing.T) { ctx := context.Background() + _ = godotenv.Load() + + databaseURL := os.Getenv("DATABASE_URL") + if databaseURL == "" { + databaseURL = defaultTestDatabaseURL + } + t.Setenv("API_KEY", testAPIKey) - t.Setenv("DATABASE_URL", defaultTestDatabaseURL) + t.Setenv("DATABASE_URL", databaseURL) cfg, err := config.Load() require.NoError(t, err) @@ -752,6 +896,7 @@ func TestFeedbackRecordsRepository_BulkDelete(t *testing.T) { // Create two records with same user_identifier req1 := &models.CreateFeedbackRecordRequest{ SourceType: sourceType, + SubmissionID: userID, FieldID: "f1", FieldType: models.FieldTypeNumber, ValueNumber: ptrFloat64(1), @@ -763,6 +908,7 @@ func TestFeedbackRecordsRepository_BulkDelete(t *testing.T) { req2 := &models.CreateFeedbackRecordRequest{ SourceType: sourceType, + SubmissionID: userID, FieldID: "f2", FieldType: models.FieldTypeNumber, ValueNumber: ptrFloat64(2),