Ability Adapter Conformance Tests
Standard compliance suite for ability adapters. Any new provider adapter must pass this suite to guarantee pagination and error handling conform to Flowbot standards.
Source: pkg/ability/conformance/
Overview
Each capability (bookmark, archive, reader, kanban) defines a Service interface in pkg/ability/<capability>/interface.go. Provider adapters implement this interface, translating provider-specific types into ability domain types. The conformance suite verifies every adapter correctly implements:
- Pagination structure (
ListResult[T],PageInfo, opaque cursors) - Error wrapping (Flowbot sentinel errors via
types.WrapError/types.Errorf) - Input validation (empty IDs, nil queries, missing required fields)
- Context cancellation (canceled ctx maps to
types.ErrTimeout) - Not-implemented semantics (unsupported ops return
types.ErrNotImplemented)
Architecture
pkg/ability/conformance/
├── conformance.go # Shared types, context helpers, error assertion helpers
├── pagination.go # Pagination-specific assertions (cursor round-trip, PageInfo structure)
├── bookmark.go # RunBookmarkConformance — 33 subtests across 9 operations
├── archive.go # RunArchiveConformance — 7 subtests across 3 operations
├── reader.go # RunReaderConformance — 20 subtests across 7 operations
├── kanban.go # RunKanbanConformance — 27 subtests across 9 operations
├── conformance_test.go # Self-tests for the conformance framework
└── pagination_test.go # Self-tests for pagination helpers
Each adapter adds a conformance_test.go in its package:
pkg/ability/bookmark/karakeep/
├── adapter.go # Adapter implementation
├── adapter_test.go # Adapter-specific tests (type conversion, edge cases)
└── conformance_test.go # Calls conformance.RunBookmarkConformance
Design
Config-Based Factory Pattern
Each capability runner accepts a Config struct and a factory function. The factory translates Config fields into fake client behavior:
type BookmarkConfig struct {
ListItems []*Bookmark // Items returned by List
ListNextCursor string // Next cursor from provider
ListErr error // Error from List
// ... per-operation fields
}
type BookmarkServiceFactory func(t *testing.T, cfg BookmarkConfig) Service
The runner creates subtests, each with a different Config. The adapter’s conformance_test.go implements the factory, mapping Config fields to its fake client:
conformance.RunBookmarkConformance(t, func(t *testing.T, cfg conformance.BookmarkConfig) bm.Service {
c := &fakeClient{
listResp: toProviderResponse(cfg),
listErr: cfg.ListErr,
// ...
}
a := NewWithClient(c).(*Adapter)
a.cursorSecret = conformance.CursorSecret
return a
})
This decouples the conformance runner from provider-specific types — the runner only knows about ability domain types.
Per-Capability Runners
Each runner function defines all test cases for its capability’s operations. Test cases follow a consistent pattern:
| Dimension | Operations Tested | Verification |
|---|---|---|
| Success | All | Non-nil result, correct item fields |
| Pagination | List, Search | PageInfo present, HasMore logic, Items never nil |
| Timeout | All | errors.Is(err, types.ErrTimeout) |
| Invalid input | All | errors.Is(err, types.ErrInvalidArgument) |
| Provider error | Mutations | errors.Is(err, types.ErrProvider) |
| Not implemented | Per-backend | errors.Is(err, types.ErrNotImplemented) |
What the Suite Tests
Pagination Conformance (every list/search operation)
| Test | Assertion |
|---|---|
| Non-nil Items | result.Items is []*T{} (empty slice, not nil) |
| Non-nil Page | result.Page is always present |
| Limit pass-through | Non-zero limit preserved (or normalized by adapter) |
| HasMore = true | When provider returns a next cursor |
| HasMore = false | When provider returns no next cursor |
| NextCursor encoding | Cursor is HMAC-signed opaque string when HasMore is true |
| Cursor decoding | Incoming opaque cursor extracts provider cursor correctly |
Error Conformance (every operation)
| Sentinel Error | When Expected |
|---|---|
types.ErrTimeout |
Context is canceled before operation |
types.ErrProvider |
Provider client returns an error |
types.ErrInvalidArgument |
Empty ID, empty URL, nil tags, missing required fields |
types.ErrNotFound |
Entity does not exist (where applicable) |
types.ErrNotImplemented |
Operation not supported by this backend |
Input Validation (every operation)
- Empty/missing IDs return
ErrInvalidArgument - Empty URLs return
ErrInvalidArgument - Nil query structs use safe defaults (no panic)
- Empty tag slices return
ErrInvalidArgument
Context Cancellation (every operation)
- Canceled context before any provider call returns
ErrTimeout - Wrap uses
types.WrapError(types.ErrTimeout, "...", ctx.Err())
Adding a New Provider
Step 1: Implement the Service Interface
Create an adapter in pkg/ability/<capability>/<provider>/ implementing the capability’s Service interface.
Step 2: Create a Fake Client
In adapter_test.go, define a fakeClient struct implementing the adapter’s local client interface. Each method should have configurable response and error fields.
Step 3: Write Adapter-Specific Tests
In adapter_test.go, test adapter internals: type conversion functions, edge cases, boundary conditions.
Step 4: Wire Up the Conformance Suite
Create conformance_test.go:
package newprovider
import (
"testing"
"github.com/flowline-io/flowbot/pkg/ability/conformance"
bm "github.com/flowline-io/flowbot/pkg/ability/bookmark"
)
func TestConformance(t *testing.T) {
conformance.RunBookmarkConformance(t, func(t *testing.T, cfg conformance.BookmarkConfig) bm.Service {
c := &fakeClient{
listResp: toProviderListResponse(cfg),
listErr: cfg.ListErr,
// ... map every config field
}
a := NewWithClient(c).(*Adapter)
if cursorAdapter, ok := interface{}(a).(interface{ SetCursorSecret([]byte) }); ok {
cursorAdapter.SetCursorSecret(conformance.CursorSecret)
}
return a
})
}
Step 5: Run the Tests
# Run this adapter's conformance only
go test -run TestConformance ./pkg/ability/bookmark/newprovider/
# Run all ability tests
go test ./pkg/ability/...
# Run all tests
go tool task test
Cursor Secrets
Adapters using cursor-based pagination must expose a way to set the cursor secret for testing. The conformance suite provides conformance.CursorSecret and conformance.TestTime() for deterministic cursor encoding.
If the adapter has a SetCursorSecret([]byte) method:
a.SetCursorSecret(conformance.CursorSecret)
If the adapter exposes a now field for time injection (deterministic cursor expiry):
a.now = conformance.TestTime
Assertion Helpers
The conformance package exports reusable assertion helpers:
| Function | Purpose |
|---|---|
RequireListResult[T](t, result, limit, hasMore) |
Verifies ListResult structure |
RequireTimeoutError(t, err) |
Asserts errors.Is(err, types.ErrTimeout) |
RequireProviderError(t, err) |
Asserts errors.Is(err, types.ErrProvider) |
RequireInvalidArgError(t, err) |
Asserts errors.Is(err, types.ErrInvalidArgument) |
RequireNotFoundError(t, err) |
Asserts errors.Is(err, types.ErrNotFound) |
RequireNotImplementedError(t, err) |
Asserts errors.Is(err, types.ErrNotImplemented) |
AssertCursorRoundTrip(t, secret, payload) |
Verifies cursor encode → decode |
AssertPageInfoIsComplete(t, page, limit) |
Verifies all PageInfo fields |
CanceledContext() |
Returns an already-canceled context.Context |
Coverage Matrix
| Adapter | Capability | Conformance Tests | Adapter-Specific Tests | Total |
|---|---|---|---|---|
| karakeep | bookmark | 33 | 2 | 35 |
| archivebox | archive | 7 | 1 | 8 |
| miniflux | reader | 20 | 6 | 26 |
| kanboard | kanban | 27 | 7 | 34 |
| Total | 87 | 16 | 103 |
Plus 12 self-tests for the conformance framework itself.
Extending the Suite
To add conformance coverage for a new capability:
- Define
<capability>Configand<Capability>ServiceFactorytypes in a newpkg/ability/conformance/<capability>.go - Implement
Run<Capability>Conformancewith subtests covering all operations - Wire up existing adapters by adding
conformance_test.goin each adapter package - Add new subtests if the capability has unique semantics