Files
probo/contrib/claude/e2e.md
Bryan Frimin 7895dd32b0 Fix logging format string bugs and add missing contributor guides
Replace three instances of leftover %T format verbs in logger.ErrorCtx() calls with proper structured logging fields. Add alphabetically-sorted reference documentation for six new contrib/claude/ guides and reorder the existing list.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
2026-03-18 15:09:31 +01:00

6.6 KiB

End-to-End Testing

E2E tests live in e2e/console/ (package console_test) and run against a live bin/probod instance. The test infrastructure handles server lifecycle, authentication, and test data creation.

Running tests

SKIP_APPS=1 make build   # Build the binary (backend only)
make test-e2e            # Run all e2e tests

Client setup

Standalone user (new organization):

owner := testutil.NewClient(t, testutil.RoleOwner)

User in existing organization:

admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)

Each call creates a unique identity with a fresh email. Available roles: RoleOwner, RoleAdmin, RoleViewer.

Client methods

Method API Purpose
c.Execute(query, vars, &result) Console Execute and unmarshal into result
c.MustExecute(query, vars, &result) Console Execute, fail test on error
c.ExecuteShouldFail(query, vars) Console Expect error, fail if succeeds
c.Do(query, vars) Console Low-level, returns raw response
c.ExecuteConnect(query, vars, &result) Connect For auth operations
c.ExecuteWithFile(query, vars, path, file, &result) Console Single file upload
c.GetOrganizationID() Current org GID
c.GetUserID() Current user GID

Test data factories

Two patterns in e2e/internal/factory/:

Builder pattern (preferred):

vendorID := factory.NewVendor(owner).
	WithName("Stripe").
	WithCategory("CLOUD_PROVIDER").
	Create()

frameworkID := factory.NewFramework(owner).
	WithName("SOC 2").
	Create()

controlID := factory.NewControl(owner, frameworkID).
	WithName("Access Control").
	Create()

Simple factory functions:

vendorID := factory.CreateVendor(c, factory.Attrs{"name": "Acme"})
taskID := factory.CreateTask(c, &measureID, factory.Attrs{"name": "Task 1"})

Use factory.SafeName("prefix") for unique names and factory.SafeEmail() for unique emails.

Test structure

Every test and subtest must call t.Parallel(). One test file per entity in e2e/console/. Function naming: TestEntity_Operation.

func TestVendor_Create(t *testing.T) {
	t.Parallel()
	owner := testutil.NewClient(t, testutil.RoleOwner)

	t.Run("with required fields", func(t *testing.T) {
		t.Parallel()

		const query = `
			mutation CreateVendor($input: CreateVendorInput!) {
				createVendor(input: $input) {
					vendorEdge {
						node { id name }
					}
				}
			}
		`

		var result struct {
			CreateVendor struct {
				VendorEdge struct {
					Node struct {
						ID   string `json:"id"`
						Name string `json:"name"`
					} `json:"node"`
				} `json:"vendorEdge"`
			} `json:"createVendor"`
		}

		err := owner.Execute(query, map[string]any{
			"input": map[string]any{
				"organizationId": owner.GetOrganizationID().String(),
				"name":           factory.SafeName("Vendor"),
			},
		}, &result)

		require.NoError(t, err)
		assert.NotEmpty(t, result.CreateVendor.VendorEdge.Node.ID)
	})
}

Authorization (RBAC) testing

Test each role's access to each operation:

t.Run("viewer cannot create", func(t *testing.T) {
	t.Parallel()
	owner := testutil.NewClient(t, testutil.RoleOwner)
	viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)

	_, err := viewer.Do(createQuery, map[string]any{
		"input": map[string]any{
			"organizationId": viewer.GetOrganizationID().String(),
			"name":           "Test",
		},
	})
	testutil.RequireForbiddenError(t, err, "viewer cannot create")
})

Tenant isolation testing

t.Run("other org cannot access", func(t *testing.T) {
	t.Parallel()
	owner1 := testutil.NewClient(t, testutil.RoleOwner)
	owner2 := testutil.NewClient(t, testutil.RoleOwner)

	vendorID := factory.NewVendor(owner1).WithName("Vendor").Create()

	var result struct {
		Node *struct{ ID string } `json:"node"`
	}
	err := owner2.Execute(nodeQuery, map[string]any{"id": vendorID}, &result)
	testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "Vendor")
})

Assertion helpers

Pagination:

testutil.AssertFirstPage(t, edgeCount, pageInfo, expectedCount, expectMore)
testutil.AssertMiddlePage(t, edgeCount, pageInfo, expectedCount)
testutil.AssertLastPage(t, edgeCount, pageInfo, expectedCount, expectPrevious)

Timestamps:

testutil.AssertTimestampsOnCreate(t, createdAt, updatedAt, beforeCreate)
testutil.AssertTimestampsOnUpdate(t, createdAt, updatedAt, origCreatedAt, origUpdatedAt)

Ordering:

testutil.AssertOrderedAscending[T](t, values, "fieldName")
testutil.AssertOrderedDescending[T](t, values, "fieldName")
testutil.AssertTimesOrderedDescending(t, times, "createdAt")

Authorization:

testutil.RequireForbiddenError(t, err, "message")
testutil.RequireErrorCode(t, err, "CODE_NAME", "message")

Optional fields:

testutil.AssertOptionalStringEqual(t, expected, actual, "fieldName")

Validation testing

Use table-driven tests for validation scenarios:

tests := []struct {
	name              string
	input             map[string]any
	wantErrorContains string
}{
	{name: "missing name", input: map[string]any{}, wantErrorContains: "name"},
	{name: "HTML injection", input: map[string]any{"name": "<script>xss</script>"}, wantErrorContains: "HTML"},
	{name: "control char", input: map[string]any{"name": "Test\x00"}, wantErrorContains: "control"},
}

for _, tt := range tests {
	t.Run(tt.name, func(t *testing.T) {
		input := map[string]any{"organizationId": owner.GetOrganizationID().String()}
		maps.Copy(input, tt.input)

		_, err := owner.Do(query, map[string]any{"input": input})
		require.Error(t, err)
		assert.Contains(t, err.Error(), tt.wantErrorContains)
	})
}

File uploads

err := owner.ExecuteWithFile(
	uploadQuery,
	map[string]any{"input": map[string]any{"vendorId": vendorID, "file": nil}},
	"input.file",
	testutil.UploadFile{
		Filename:    "report.pdf",
		ContentType: "application/pdf",
		Content:     pdfBytes,
	},
	&result,
)

New entity e2e test checklist

  1. Filee2e/console/<entity>_test.go, package console_test
  2. CRUD — create (required fields, all fields), update, delete, get by ID, list
  3. Validation — required fields, empty strings, HTML injection, control chars, max length, invalid enums
  4. RBAC — owner/admin/viewer access for create, update, delete, read
  5. Tenant isolation — cross-org user cannot access resource
  6. TimestampscreatedAt == updatedAt on create, updatedAt advances on update
  7. Sub-resolvers — parent references, child collections
  8. Parallelt.Parallel() on every test and subtest