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>
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
- File —
e2e/console/<entity>_test.go, packageconsole_test - CRUD — create (required fields, all fields), update, delete, get by ID, list
- Validation — required fields, empty strings, HTML injection, control chars, max length, invalid enums
- RBAC — owner/admin/viewer access for create, update, delete, read
- Tenant isolation — cross-org user cannot access resource
- Timestamps —
createdAt == updatedAton create,updatedAtadvances on update - Sub-resolvers — parent references, child collections
- Parallel —
t.Parallel()on every test and subtest