diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d97362be..33b22c8fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -108,7 +108,7 @@ A request has one `DbContext`, and two queries on it must not overlap. A Blazor Work that outlives a request, such as a bulk operation or a check run, is a job in the `Jobs` table (ADR-0010). Enqueue it with `IJobQueue.EnqueueAsync(new NewJob(type, payload) { Subject = ..., RunAt = ..., MaxAttempts = ... })` inside the unit of work of the change that needs it, so the job commits with the change or not at all; outside a unit of work it throws, as the audit log does. The job records the current initiator, and its handler runs as them, so what it changes names whoever asked for it. A handler for work no person or agent does, such as the check run a submission starts, names a `SystemInitiator` in `RunAs`, and its jobs run as that engine whoever enqueued them, while the job still records who did (ADR-0012). Core lists each such handler with its engine in `EngineJobs`; the worker fails, without running it, a job whose handler names an engine it is not listed for, and a job an engine enqueued for a handler that names no engine, so a plugin's handler never runs as an engine. `IJobQueue.GetAsync` and `ListForSubjectAsync` read a job's state (`queued`, `running`, `succeeded`, `failed` or `dead`), attempts, progress and last error. -A handler implements `IJobHandler` (an extension point) with the type name its jobs store, which never changes once released. It reads the payload from `JobContext.Job`, resolves services from `JobContext.Services`, a scope of its own, and reports progress with `ReportProgressAsync`, which also keeps its lease; when that returns false another worker has the job, and the handler stops. A returned failure ends the job as `failed`. An exception retries it after `JobQueue__RetryDelay`, doubled for each attempt up to `JobQueue__MaxRetryDelay`, and after its last attempt the job is `dead`. Work in a handler therefore has to be safe to run again. +A handler implements `IJobHandler` (an extension point) with the type name its jobs store, which never changes once released. It reads the payload from `JobContext.Job`, resolves services from `JobContext.Services`, a scope of its own, and reports progress with `ReportProgressAsync`, which also keeps its lease; when that returns false another worker has the job, and the handler stops. A returned failure ends the job as `failed`. An exception retries it after `JobQueue__RetryDelay`, doubled for each attempt up to `JobQueue__MaxRetryDelay`, and after its last attempt the job is `dead`. Work in a handler therefore has to be safe to run again. Each handler has a class derived from `JobHandlerConformanceTests` that names its type. `JobQueue__WorkerCount` workers in the host claim due jobs every `JobQueue__PollingInterval`. A claim holds a job for `JobQueue__LeaseDuration`, which the worker renews at a third of it while the handler runs. A renewal that fails is tried again while the lease holds, and no wait or renewal lasts past the lease's end. The handler's cancellation token fires as soon as the lease is lost or its end passes with no renewal, because another worker may then claim the job; a handler should honour it. A job whose worker stopped is claimed again once the lease runs out, counting another attempt. A host that stops while a handler runs puts the job back in the queue with its attempt given back. Claims are conditional updates on the row, so with any number of workers, in one host or several, each job runs once at a time. @@ -132,13 +132,15 @@ An administrator adds a webhook on `/webhooks` with the URL of an endpoint and t - **Secret.** Each webhook gets a signing secret, 32 random bytes written as `whsec_` and base64, shown once in the response that adds it. It is stored encrypted with the Data Protection key ring, so losing `DataProtection__KeysDirectory` loses the secrets too. It never appears in a list, a log or the audit log (SEC-07). - **Audit.** Adding and disabling append `webhook.added` and `webhook.disabled`, which name the subscription. -- **Delivery.** Publishing a change set queues one `webhook.deliver` job per enabled subscription that wants the event, in the publish's unit of work (`IChangeSetEvents`), so publishing never waits for an endpoint. A revert's publish sends `change_set.reverted` alone. The job POSTs JSON in the Standard Webhooks shape: `type`, `id` (the same as `webhook-id`), `timestamp` and `data`, with the set's id, rationale, sources and a `link` to it in the admin, its items (kind, id, operation, version) and, for a revert, the set it reverted. Set `Webhooks__AdminUrl` to make the link absolute. A 2xx answer delivers it. Anything else, a timeout (`Webhooks__Timeout`, ten seconds by default) or a network error is retried with the job queue's growing delay, up to `Webhooks__MaxAttempts` attempts in all. A redirect is never followed. A host taken off the allow-list is refused at sending and not retried. Every attempt is in `WebhookDeliveries`, and `/webhooks` shows the latest beside each subscription, with a button that sends a `webhook.test` delivery. +- **Delivery.** Publishing a change set queues one `webhook.deliver` job per enabled subscription that wants the event, in the publish's unit of work (`IChangeSetEvents`), so publishing never waits for an endpoint. A revert's publish sends `change_set.reverted` alone. The job POSTs JSON in the Standard Webhooks shape: `type`, `id` (the same as `webhook-id`), `timestamp` and `data`, with the set's id, rationale, sources and a `link` to it in the admin, its items (kind, id, operation, version) and, for a revert, the set it reverted. Set `Webhooks__AdminUrl` to make the link absolute. A 2xx answer delivers it. Anything else, a timeout (`Webhooks__Timeout`, ten seconds by default) or a network error is retried with the job queue's growing delay, up to `Webhooks__MaxAttempts` attempts in all. A redirect is never followed. A host taken off the allow-list is refused at sending and not retried. Every attempt is in `WebhookDeliveries`, and `/webhooks` shows the latest beside each subscription, with a button that sends a `webhook.test` delivery. A publish listener implements `IChangeSetEvents` (an extension point) and passes `ChangeSetEventsConformanceTests` on both databases. - **Verifying.** Each request carries `webhook-id`, `webhook-timestamp` (Unix seconds) and `webhook-signature`: `v1,` and the base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{body}`, keyed with the bytes of the secret after `whsec_`. A Standard Webhooks library checks it directly. A receiver should reject a timestamp more than a few minutes old, and should treat a repeated `webhook-id` as a retry of the same event. ## Extension points Every pluggable part of the product is an interface in `FluentCMS.Core` marked `[ExtensionPoint]`. Implementations register through dependency injection, either explicitly with `AddFluentCmsExtension()` or by discovery with `AddFluentCmsExtensions(assembly)`, which picks up every concrete class implementing an extension-point interface in that assembly. Both paths produce the same `IExtensionCatalog` entry and the same `IEnumerable` resolution, so built-ins under `FluentCMS.Core.Builtin` and future plugins look identical to the rest of the product. Each interface is documented where it is defined, together with the conformance tests an implementation must pass. +Each extension point has a conformance class, an abstract test class that every implementation derives from, built in or not, marked `[ConformanceFor(typeof(...))]`. A test fails for an extension point without one, so a new extension point comes with its class. `tests/FluentCMS.Tests/Extensions/extension-points.txt` lists every extension point's members. A change to any of them fails a test until that file is updated in the same pull request: run the tests with `FLUENTCMS_UPDATE_EXTENSION_BASELINE=1` to rewrite it, and say in the pull request whether the change is additive or breaking. ADR-0013 sets the rules: before the plugin SDK ships in M3 any change is allowed, and from then on breaking changes wait for a major version. Names an extension point stores, such as a field type's or a job handler's, never change once released. + Field types (`IFieldType` in `FluentCMS.Core.Schema`) are the first. A field type has a name that definitions store, so it never changes once released, and it checks the settings a definition gives a field of its type, returning errors for missing, invalid and unknown settings instead of throwing. `FieldSettingsReader` does that bookkeeping for built-ins and plugins alike. A type whose values point at entries of another content type, or at one field of an entity type such as `product.price`, also implements `ReferenceTargets`; `IContentTypeDirectory` refuses a definition whose targets do not exist in the site, or whose field targets are not fields of an entity type, so a plugin type gets the same checks as the built-in `reference`. Such a type also lists the references each stored value makes (`References`), with a JSON Pointer to each one's place; entries write them to the reference index that where used (`IWhereUsed`, CNT-05) reads, and refuse a reference to an entry that is missing or of another type. Every type also describes its values for the schema document agents read (`DescribeValue`, SCH-06): a JSON Schema for one value, an example that satisfies it and one sentence about it. It checks each value an entry stores in a field of its type (`ValidateValue`), accepting what it describes and returning `value_invalid` errors for anything else; `IEntryDirectory` stores no version with a value its type refuses. To add one, implement the interface, then derive a test class from `FieldTypeConformanceTests` in `tests/FluentCMS.Tests/Schema` with settings the type must accept and settings it must reject, and values it must accept and reject; a type outside Core also overrides `AddFieldTypes` to register its assembly. Media storage (`IMediaStorage` in `FluentCMS.Core.Media`, MED-04) holds the bytes of media assets. A storage has a name that every asset stores, so it never changes once released; it saves a stream and hands back an opaque key with the length and SHA-256, opens a key for reading and deletes one, and treats a key it did not hand out as not found, never as a path. The local disk storage, `local`, ships in Infrastructure. To add one, implement the interface, register it with `AddFluentCmsExtension()` and derive a test class from `MediaStorageConformanceTests` in `tests/FluentCMS.IntegrationTests/Media`; `Media__Storage` then names it, and the host refuses to start with a name nothing registered. @@ -155,7 +157,7 @@ A submitted change set is checked as the check engine (`SystemInitiator.Checks`, The rule book (`IRuleDirectory` in `FluentCMS.Core.Rules`, RUL-01 to RUL-03) holds the team's rules as versioned content. A rule has a slug and a definition: its kind (voice and style, factual, structural, or permission and routing), its text, a severity (block or warn) and a scope of content types, fields and locales; nothing reads the locales in M1. Each version is one JSON document. Every write goes into a `ChangeSetTarget`, as content type writes do: creating and revising store a version, and retiring proposes taking the active version out, each recorded as an operation of the set with its audit event in one unit of work. A rule is a draft until a published set makes one of its versions active, and retired once a published set retires it; reverting that set makes it active again, and reverting the set that created it makes it a draft again. The change set diff shows a rule's kind, text, severity and scope before and after. Only a person writes rules; an agent or an engine is refused. -Search (`ISearchIndex` in `FluentCMS.Core.Search`, SRC-01, ADR-0010) indexes each entry's latest version in the same unit of work as the write, as the lowercase words of every string and number in its fields (`SearchText`), so a result appears the moment its write commits. PostgreSQL keeps them in a `tsvector` with a GIN index and SQLite in an FTS5 table, both outside the EF model and created by their migrations; an external engine implements the same interface, registered with `AddFluentCmsExtension(ServiceLifetime.Scoped)` when it needs the request's scope. `ISearch` combines the matches with the entry rows, so deleting, restoring and publishing change results without touching the index, follows a match to the entries that reference it, and returns only types the caller may read. It reads the matches a page at a time, `Search__PageSize` (1,000 by default), and drops what the scope, deletions and filters leave out, until enough are left, the index has no more, or it has read `Search__MaxMatches` (10,000). A result that stopped there says it is not complete. Each hit has the state the entry tools show, in review included. A string that is a GUID, such as a reference, is not indexed. Field filters (`SearchQuery.Fields`) compare one type's latest field values, an entity's by its fact's value, and `SearchQuery.References` keeps entries whose latest version references an id; both are checked against the type before anything is read and applied to each page's candidates. With one type and either filter the text can be left out, and the candidates are then that type's entries. +Search (`ISearchIndex` in `FluentCMS.Core.Search`, SRC-01, ADR-0010) indexes each entry's latest version in the same unit of work as the write, as the lowercase words of every string and number in its fields (`SearchText`), so a result appears the moment its write commits. PostgreSQL keeps them in a `tsvector` with a GIN index and SQLite in an FTS5 table, both outside the EF model and created by their migrations; an external engine implements the same interface, registered with `AddFluentCmsExtension(ServiceLifetime.Scoped)` when it needs the request's scope, and passes `SearchIndexConformanceTests` on both databases. `ISearch` combines the matches with the entry rows, so deleting, restoring and publishing change results without touching the index, follows a match to the entries that reference it, and returns only types the caller may read. It reads the matches a page at a time, `Search__PageSize` (1,000 by default), and drops what the scope, deletions and filters leave out, until enough are left, the index has no more, or it has read `Search__MaxMatches` (10,000). A result that stopped there says it is not complete. Each hit has the state the entry tools show, in review included. A string that is a GUID, such as a reference, is not indexed. Field filters (`SearchQuery.Fields`) compare one type's latest field values, an entity's by its fact's value, and `SearchQuery.References` keeps entries whose latest version references an id; both are checked against the type before anything is read and applied to each page's candidates. With one type and either filter the text can be left out, and the candidates are then that type's entries. ## Code style diff --git a/docs/adr/0013-extension-interface-versioning.md b/docs/adr/0013-extension-interface-versioning.md new file mode 100644 index 000000000..73090ade5 --- /dev/null +++ b/docs/adr/0013-extension-interface-versioning.md @@ -0,0 +1,51 @@ +# ADR-0013: Versioning the extension interfaces + +- Status: proposed +- Date: 2026-10-19 +- Related: EXT-01, EXT-02, EXT-03, EXT-N1, EXT-N2, ADR-0004 + +## Context + +Every pluggable part of the product is an `[ExtensionPoint]` interface in `FluentCMS.Core`, and the built-in features implement them the way a plugin will (EXT-01, EXT-02). In M1 and M2 only code in this repository implements them. The public plugin SDK comes in M3 (EXT-03), and from then on other people's assemblies compile against these interfaces. EXT-N2 asks that the interfaces be versioned, with breaking changes only at major versions. + +Some of what an implementation relies on is not in the interface's signature: what its conformance suite checks, and the names it stores in data, such as a field type's name in a content type definition or a job handler's type in every job. + +## Decision + +- **The contract of an extension point** is three things: + - its `[ExtensionPoint]` interface and the public types in its members; + - the behaviour its conformance suite checks (EXT-N1); + - the names it stores, which never change once released. +- **Additive changes** keep every implementation compiling and passing its suite: + - a new extension point; + - a new member with a default implementation; + - a new optional argument on a record or options type that a caller fills in; + - a new type that existing members don't return. +- **Breaking changes**: + - removing or renaming a member; + - changing a member's signature, or the nullability of its types; + - a new member without a default; + - a stricter conformance check; + - a new value an implementation must handle in a closed set it switches on, such as an enum, unless the contract says unknown values are ignored; + - renaming a stored name. +- **Before the SDK ships (M1 and M2)**, any change is allowed, because every implementation is in this repository and is changed in the same pull request. Each change still updates the baseline below and says in the pull request whether it is additive or breaking, so the M3 SDK starts from a known surface. +- **From the SDK release**: + - The interfaces follow the product's semantic version. A minor release takes only additive changes. A breaking change waits for the next major. + - A member to be removed is first marked `[Obsolete]` with its replacement, for at least one minor release, and goes at the next major. +- **A surface baseline** in `tests/FluentCMS.Tests/Extensions/extension-points.txt` lists every extension point and its members, with their types and nullability. + - A test compares the interfaces with it and names each member that differs. + - A pull request that changes an extension point updates the baseline, and its reviewer judges the change against the rules above. +- **Every extension point has a conformance class**: an abstract test class that every implementation derives from, built in or not, marked `[ConformanceFor(typeof(...))]`. A test fails for an extension point that none covers. + +## Consequences + +- A change to an extension point can't slip through review unseen. The baseline diff shows it, and the rule test names it. +- A new extension point lands with its conformance class, so a plugin author has the contract to test against from the start. +- The baseline covers the interfaces' own members. A change to a type that only appears in a signature, such as `JobContext`, shows up in that type's own review, not in the baseline. +- Stored names are part of the contract, so renaming a built-in field type, check type, tool or job type is breaking even when no signature changes. + +## Alternatives considered + +- **`Microsoft.CodeAnalysis.PublicApiAnalyzers`** with `PublicAPI.Shipped.txt`: it tracks the whole public surface of `FluentCMS.Core`, not just the extension points. It adds a package and is noisy for a library that isn't published yet. The baseline test tracks only what plugins implement. +- **A separate contracts assembly**: the cleanest way to version the interfaces apart from the rest of Core, but ADR-0004 keeps M1 to a few projects, and nothing is published until M3. M3 can still move the interfaces without breaking source compatibility. +- **No rule before M3**: simplest, but the SDK would start from whatever the interfaces drifted to, with no record of which changes were breaking. diff --git a/docs/adr/README.md b/docs/adr/README.md index 6a4b85048..d082c3b93 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,5 +16,6 @@ Technical decisions for FluentCMS v2. Product decisions live in `docs/requiremen | [0010](0010-in-database-search-and-job-queue.md) | In-database full-text search and a durable job queue | accepted | | [0011](0011-admin-component-set-and-styling.md) | Admin component set and styling | accepted | | [0012](0012-system-initiator-for-engine-driven-actions.md) | A system initiator for actions the product takes on its own | accepted | +| [0013](0013-extension-interface-versioning.md) | Versioning the extension interfaces | proposed | Deferred to their own ADR when the work starts: CI and release pipeline, container image layout. diff --git a/src/FluentCMS.Core/ChangeSets/IChangeSetEvents.cs b/src/FluentCMS.Core/ChangeSets/IChangeSetEvents.cs index 7909bb26a..aaf7d4d25 100644 --- a/src/FluentCMS.Core/ChangeSets/IChangeSetEvents.cs +++ b/src/FluentCMS.Core/ChangeSets/IChangeSetEvents.cs @@ -4,7 +4,8 @@ namespace FluentCMS.Core.ChangeSets; // Told when a change set is published, inside the publish's unit of work, so what a listener records commits or rolls back with the // publish (API-05). A revert's publish also names the set it reverted. Publishing never waits on a listener: one queues its work, such -// as a webhook delivery, rather than doing it, and fails only when the database does. +// as a webhook delivery, rather than doing it, and fails only when the database does. Every implementation must pass +// ChangeSetEventsConformanceTests in tests/FluentCMS.IntegrationTests/ChangeSets. [ExtensionPoint] public interface IChangeSetEvents { diff --git a/src/FluentCMS.Core/Jobs/IJobHandler.cs b/src/FluentCMS.Core/Jobs/IJobHandler.cs index 025fc0f26..6b60214d6 100644 --- a/src/FluentCMS.Core/Jobs/IJobHandler.cs +++ b/src/FluentCMS.Core/Jobs/IJobHandler.cs @@ -6,7 +6,8 @@ namespace FluentCMS.Core.Jobs; // Runs the jobs of one type (ADR-0010, EXT-01). The worker runs it in a scope of its own as the job's initiator. A returned // failure ends the job as failed; an exception is retried after a growing delay until the job's attempts run out, so a handler -// throws only for what another attempt can fix, and its work must be safe to repeat. +// throws only for what another attempt can fix, and its work must be safe to repeat. Every implementation must pass +// JobHandlerConformanceTests in tests/FluentCMS.Tests/Jobs. [ExtensionPoint] public interface IJobHandler { diff --git a/src/FluentCMS.Core/Search/ISearchIndex.cs b/src/FluentCMS.Core/Search/ISearchIndex.cs index 7fef1e8fd..d86999270 100644 --- a/src/FluentCMS.Core/Search/ISearchIndex.cs +++ b/src/FluentCMS.Core/Search/ISearchIndex.cs @@ -4,7 +4,8 @@ namespace FluentCMS.Core.Search; // The full-text index over entries and entities (SRC-01, ADR-0010): PostgreSQL full-text search or SQLite FTS5 in the application // database, or an external engine a plugin adds later. IndexAsync is called inside the unit of work of the write it reflects, so an -// entry is found the moment its write commits and never when the write rolled back. +// entry is found the moment its write commits and never when the write rolled back. Every implementation must pass +// SearchIndexConformanceTests in tests/FluentCMS.IntegrationTests/Search, on both databases. [ExtensionPoint] public interface ISearchIndex { diff --git a/tests/ConformanceForAttribute.cs b/tests/ConformanceForAttribute.cs new file mode 100644 index 000000000..9c76a357c --- /dev/null +++ b/tests/ConformanceForAttribute.cs @@ -0,0 +1,9 @@ +namespace FluentCMS.Testing; + +// Names the extension points a conformance test class checks, so every extension point is seen to have one (EXT-N1). The check reads +// it from the source, because the classes live in more than one test project; both projects compile this one file. +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class ConformanceForAttribute(params Type[] extensionPoints) : Attribute +{ + public IReadOnlyList ExtensionPoints { get; } = extensionPoints; +} diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetEventsConformanceTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetEventsConformanceTests.cs new file mode 100644 index 000000000..757f395fc --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetEventsConformanceTests.cs @@ -0,0 +1,135 @@ +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Persistence; +using FluentCMS.Core.Sites; +using FluentCMS.Core.Webhooks; +using FluentCMS.Infrastructure.Persistence; +using FluentCMS.Infrastructure.Webhooks; +using FluentCMS.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.ChangeSets; + +// The contract every publish listener meets, built in or not (EXT-N1): it takes a publish and a revert's publish without failing, and +// what it records is in the publish's unit of work, so a publish that rolls back leaves none of it. Derive one class per listener: pick +// it from the registered ones, register it in AddListener if it lives outside the product, and seed what it reacts to in SeedAsync. +[ConformanceFor(typeof(IChangeSetEvents))] +public abstract class ChangeSetEventsConformanceTests +{ + protected abstract IChangeSetEvents Listener(IServiceProvider services); + + protected virtual void AddListener(IServiceCollection services) + { + } + + protected virtual Task SeedAsync(FluentCmsDbContext context) + { + return Task.CompletedTask; + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task It_takes_a_publish_and_a_reverts_publish_without_failing(string provider) + { + await using var scenario = await Scenario.CreateAsync(provider, AddListener, SeedAsync); + var (published, reverted) = Sets(); + + await using var scope = scenario.Services.CreateAsyncScope(); + var listener = Listener(scope.ServiceProvider); + await using var work = await scope.ServiceProvider.GetRequiredService().BeginAsync(); + + await listener.PublishedAsync(published, null); + await listener.PublishedAsync(published, reverted); + await work.CommitAsync(); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task What_it_records_rolls_back_with_the_publish(string provider) + { + await using var scenario = await Scenario.CreateAsync(provider, AddListener, SeedAsync); + var (published, reverted) = Sets(); + var before = await scenario.CountRowsAsync(); + + await using (var scope = scenario.Services.CreateAsyncScope()) + { + var listener = Listener(scope.ServiceProvider); + await using var work = await scope.ServiceProvider.GetRequiredService().BeginAsync(); + await listener.PublishedAsync(published, null); + await listener.PublishedAsync(published, reverted); + } + + Assert.Equal(before, await scenario.CountRowsAsync()); + } + + private static (ChangeSet Published, ChangeSet Reverted) Sets() + { + var person = new UserInitiator(Guid.NewGuid(), "Amir"); + var published = ChangeSet.Open(Site.Default.Id, "Undo the sync.", [], person).Value; + var reverted = ChangeSet.Open(Site.Default.Id, "Sync prices.", ["exports/catalog.csv"], person).Value; + return (published, reverted); + } + + private sealed class Scenario(TemporaryDatabase database, ServiceProvider services) : IAsyncDisposable + { + public ServiceProvider Services { get; } = services; + + public static async Task CreateAsync(string provider, Action addListener, Func seed) + { + var database = await TemporaryDatabase.CreateAsync(provider); + var services = TestServices.Build( + database.Configuration, + collection => collection.AddSingleton(new TestInitiator(new UserInitiator(Guid.NewGuid(), "Amir"))), + addListener); + await TestServices.MigrateAsync(services); + + await using (var scope = services.CreateAsyncScope()) + { + await seed(scope.ServiceProvider.GetRequiredService()); + } + + return new Scenario(database, services); + } + + // The rows a listener in this product can write: jobs it queues and events it appends. + public async Task CountRowsAsync() + { + await using var scope = Services.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + return await context.Jobs.CountAsync() + await context.AuditEvents.CountAsync(); + } + + public async ValueTask DisposeAsync() + { + await Services.DisposeAsync(); + await database.DisposeAsync(); + } + } +} + +// Queues a delivery for each subscription that wants the event, so a subscription to both events is seeded. +public sealed class WebhookPublishListenerConformanceTests : ChangeSetEventsConformanceTests +{ + protected override IChangeSetEvents Listener(IServiceProvider services) + { + return services.GetServices().Single(listener => listener.GetType().Name == "WebhookPublishListener"); + } + + protected override async Task SeedAsync(FluentCmsDbContext context) + { + context.WebhookSubscriptions.Add(new WebhookSubscriptionRow + { + Id = Guid.CreateVersion7(), + Url = "https://hooks.example.com/fluentcms", + Events = [WebhookEvents.ChangeSetPublished, WebhookEvents.ChangeSetReverted], + ProtectedSecret = "not read by the listener", + AddedAt = DateTimeOffset.UtcNow, + AddedById = Guid.NewGuid(), + AddedByName = "Amir", + }); + await context.SaveChangesAsync(); + } +} diff --git a/tests/FluentCMS.IntegrationTests/FluentCMS.IntegrationTests.csproj b/tests/FluentCMS.IntegrationTests/FluentCMS.IntegrationTests.csproj index 118821b31..8cf1edb1a 100644 --- a/tests/FluentCMS.IntegrationTests/FluentCMS.IntegrationTests.csproj +++ b/tests/FluentCMS.IntegrationTests/FluentCMS.IntegrationTests.csproj @@ -15,6 +15,11 @@ + + + + + diff --git a/tests/FluentCMS.IntegrationTests/Media/MediaStorageConformanceTests.cs b/tests/FluentCMS.IntegrationTests/Media/MediaStorageConformanceTests.cs index c0e30aa13..2b3b8da7a 100644 --- a/tests/FluentCMS.IntegrationTests/Media/MediaStorageConformanceTests.cs +++ b/tests/FluentCMS.IntegrationTests/Media/MediaStorageConformanceTests.cs @@ -3,12 +3,14 @@ using FluentCMS.Core.Extensions; using FluentCMS.Core.Media; using FluentCMS.Core.Schema; +using FluentCMS.Testing; using Microsoft.Extensions.DependencyInjection; namespace FluentCMS.IntegrationTests.Media; // The contract every media storage meets, built in or not (MED-04, EXT-N1). Derive one class per storage: name it, and register it in // AddStorage the way the application would. +[ConformanceFor(typeof(IMediaStorage))] public abstract class MediaStorageConformanceTests : IDisposable { private ServiceProvider? _provider; diff --git a/tests/FluentCMS.IntegrationTests/Search/SearchIndexConformanceTests.cs b/tests/FluentCMS.IntegrationTests/Search/SearchIndexConformanceTests.cs new file mode 100644 index 000000000..7b78959fa --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Search/SearchIndexConformanceTests.cs @@ -0,0 +1,138 @@ +using FluentCMS.Core.Persistence; +using FluentCMS.Core.Search; +using FluentCMS.Infrastructure.Persistence; +using FluentCMS.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.Search; + +// The contract every search index meets, built in or not (EXT-N1, ADR-0010), on each database, since an index writes inside the +// write's unit of work. Derive one class per index and register it in AddIndex; the built-in ones come with their provider. +[ConformanceFor(typeof(ISearchIndex))] +public abstract class SearchIndexConformanceTests +{ + protected virtual void AddIndex(IServiceCollection services) + { + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_document_is_found_by_the_start_of_each_word_and_only_with_every_word(string provider) + { + await using var index = await IndexScenario.CreateAsync(provider, AddIndex); + var widget = await index.AddAsync("widget pro a 4411"); + var gadget = await index.AddAsync("gadget 4412"); + + Assert.Equal([widget], await index.MatchAsync("441", "wid")); + Assert.Equal(new[] { widget, gadget }.Order(), (await index.MatchAsync("441")).Order()); + Assert.Empty(await index.MatchAsync("widget", "gadget")); + Assert.Empty(await index.MatchAsync("idget")); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_document_indexed_again_is_found_by_its_new_words_only(string provider) + { + await using var index = await IndexScenario.CreateAsync(provider, AddIndex); + var entry = await index.AddAsync("first draft borealis"); + + await index.AddAsync("second draft australis", entry); + + Assert.Empty(await index.MatchAsync("borealis")); + Assert.Equal([entry], await index.MatchAsync("australis")); + Assert.Equal([entry], await index.MatchAsync("draft")); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_document_indexed_in_a_unit_of_work_that_rolls_back_is_not_found(string provider) + { + await using var index = await IndexScenario.CreateAsync(provider, AddIndex); + var kept = await index.AddAsync("kept aurora"); + + await index.AddAsync("rolled back aurora", commit: false); + await index.AddAsync("rolled back aurora again", kept, commit: false); + + Assert.Equal([kept], await index.MatchAsync("aurora")); + Assert.Empty(await index.MatchAsync("rolled")); + } + + // A caller pages through matches by skipping those it read, so pages read in turn are one read cut into pieces. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Pages_read_in_turn_are_one_read_best_first_cut_into_pieces(string provider) + { + await using var index = await IndexScenario.CreateAsync(provider, AddIndex); + var strongest = await index.AddAsync("zephyr zephyr zephyr zephyr"); + foreach (var words in Enumerable.Range(0, 24).Select(other => $"zephyr item {other}")) + { + await index.AddAsync(words); + } + + var all = await index.MatchesAsync(["zephyr"], 0, 100); + var pages = new List(); + for (var skip = 0; skip < all.Count; skip += 10) + { + pages.AddRange(await index.MatchesAsync(["zephyr"], skip, 10)); + } + + Assert.Equal(25, all.Count); + Assert.Equal(strongest, all[0].EntryId); + Assert.Equal(all.Select(match => match.EntryId), pages.Select(match => match.EntryId)); + Assert.True(all.Zip(all.Skip(1)).All(pair => pair.First.Rank >= pair.Second.Rank), "Matches are not best first."); + Assert.Empty(await index.MatchesAsync(["zephyr"], 25, 10)); + } + + private sealed class IndexScenario(TemporaryDatabase database, ServiceProvider services) : IAsyncDisposable + { + public static async Task CreateAsync(string provider, Action addIndex) + { + var database = await TemporaryDatabase.CreateAsync(provider); + var services = TestServices.Build( + database.Configuration, + collection => collection.AddSingleton(new TestInitiator(null)), + addIndex); + await TestServices.MigrateAsync(services); + return new IndexScenario(database, services); + } + + // Indexes the text for a new entry id, or again for the given one, in a unit of work that commits unless told not to. + public async Task AddAsync(string text, Guid? entry = null, bool commit = true) + { + var id = entry ?? Guid.CreateVersion7(); + await using var scope = services.CreateAsyncScope(); + await using var work = await scope.ServiceProvider.GetRequiredService().BeginAsync(); + await scope.ServiceProvider.GetRequiredService().IndexAsync(new SearchDocument(id, text)); + if (commit) + { + await work.CommitAsync(); + } + + return id; + } + + public async Task> MatchAsync(params string[] terms) + { + return [.. (await MatchesAsync(terms, 0, 100)).Select(match => match.EntryId)]; + } + + public async Task> MatchesAsync(string[] terms, int skip, int limit) + { + await using var scope = services.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService().MatchAsync(terms, skip, limit); + } + + public async ValueTask DisposeAsync() + { + await services.DisposeAsync(); + await database.DisposeAsync(); + } + } +} + +// The index each provider comes with: tsvector and GIN on PostgreSQL, FTS5 on SQLite. +public sealed class InDatabaseSearchIndexConformanceTests : SearchIndexConformanceTests; diff --git a/tests/FluentCMS.IntegrationTests/Webhooks/WebhookDeliveryJobConformanceTests.cs b/tests/FluentCMS.IntegrationTests/Webhooks/WebhookDeliveryJobConformanceTests.cs new file mode 100644 index 000000000..74ae40242 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Webhooks/WebhookDeliveryJobConformanceTests.cs @@ -0,0 +1,29 @@ +using FluentCMS.Core.Security; +using FluentCMS.Infrastructure; +using FluentCMS.Tests.Jobs; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.Webhooks; + +// The delivery job lives in Infrastructure, so it comes with it. The database is never opened: a payload the job cannot read fails +// before any read. +public sealed class WebhookDeliveryJobConformanceTests : JobHandlerConformanceTests +{ + protected override string JobType => "webhook.deliver"; + + protected override void AddHandlers(IServiceCollection services) + { + services.AddSingleton(); + services.AddFluentCmsInfrastructure(new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["Database:ConnectionString"] = "Data Source=unused.db" }) + .Build()); + } + + private sealed class NoSecrets : ISecretProtector + { + public string Protect(string purpose, string secret) => throw new NotSupportedException("The conformance checks protect no secrets."); + + public string Unprotect(string purpose, string protectedSecret) => throw new NotSupportedException("The conformance checks protect no secrets."); + } +} diff --git a/tests/FluentCMS.Tests/Checks/CheckTypeConformanceTests.cs b/tests/FluentCMS.Tests/Checks/CheckTypeConformanceTests.cs index 9922eaa01..c320d7cdd 100644 --- a/tests/FluentCMS.Tests/Checks/CheckTypeConformanceTests.cs +++ b/tests/FluentCMS.Tests/Checks/CheckTypeConformanceTests.cs @@ -5,6 +5,7 @@ using FluentCMS.Core.Extensions; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; +using FluentCMS.Testing; using Microsoft.Extensions.DependencyInjection; namespace FluentCMS.Tests.Checks; @@ -12,6 +13,7 @@ namespace FluentCMS.Tests.Checks; // The contract every check type meets, built in or not (EXT-N1). Derive one class per type: name the type, list settings it must // accept and settings it must reject, list content it must pass and content it must fail under given settings, and override // AddServices to register a type that lives outside Core or a service the type reads, such as a model. +[ConformanceFor(typeof(ICheckType))] public abstract class CheckTypeConformanceTests : IDisposable { private static readonly string[] ValuesOfEveryKind = diff --git a/tests/FluentCMS.Tests/Extensions/ExtensionPointRulesTests.cs b/tests/FluentCMS.Tests/Extensions/ExtensionPointRulesTests.cs new file mode 100644 index 000000000..734366d5b --- /dev/null +++ b/tests/FluentCMS.Tests/Extensions/ExtensionPointRulesTests.cs @@ -0,0 +1,179 @@ +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using FluentCMS.Core.Extensions; + +namespace FluentCMS.Tests.Extensions; + +// EXT-N1 and EXT-N2: every extension point has a conformance class, and its members match the committed baseline, so a change to any +// of them is seen in review and called additive or breaking there (ADR-0013). +public sealed partial class ExtensionPointRulesTests +{ + private const string UpdateVariable = "FLUENTCMS_UPDATE_EXTENSION_BASELINE"; + + private static readonly NullabilityInfoContext Nullability = new(); + + private static IReadOnlyList ExtensionPoints { get; } = [.. typeof(ExtensionPointAttribute).Assembly.GetTypes() + .Where(type => type.IsInterface && type.IsDefined(typeof(ExtensionPointAttribute), inherit: false)) + .OrderBy(type => type.FullName, StringComparer.Ordinal)]; + + private static string BaselinePath => Path.Combine(ProjectReferenceRulesTests.FindRepositoryRoot(), "tests", "FluentCMS.Tests", "Extensions", "extension-points.txt"); + + [Fact] + public void Every_extension_point_has_a_conformance_class() + { + var root = ProjectReferenceRulesTests.FindRepositoryRoot(); + var covered = Directory.GetFiles(Path.Combine(root, "tests"), "*.cs", SearchOption.AllDirectories) + .Where(file => !file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + .Where(file => !file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + .SelectMany(file => ConformanceFor().Matches(File.ReadAllText(file))) + .SelectMany(attribute => TypeOf().Matches(attribute.Value).Select(type => type.Groups[1].Value)) + .ToHashSet(StringComparer.Ordinal); + + Assert.NotEmpty(ExtensionPoints); + Assert.Empty(ExtensionPoints.Where(type => !covered.Contains(type.Name)).Select(type => $"{type.FullName} has no class marked [ConformanceFor(typeof({type.Name}))]")); + } + + [Fact] + public void Every_extension_point_matches_its_baseline() + { + var actual = Surface(ExtensionPoints); + if (Environment.GetEnvironmentVariable(UpdateVariable) == "1") + { + File.WriteAllText(BaselinePath, actual); + } + + var changes = Changes(File.ReadAllText(BaselinePath), actual); + + Assert.True( + changes.Count == 0, + "An extension point changed:\n" + string.Join('\n', changes) + "\n" + + "Update tests/FluentCMS.Tests/Extensions/extension-points.txt in the same pull request, and say there whether the change is " + + $"additive or breaking (ADR-0013). Running the tests with {UpdateVariable}=1 rewrites it."); + } + + // Several extension points have a member of the same name, so a member moved from one to another is a change too. + [Fact] + public void A_member_is_compared_within_the_extension_point_it_belongs_to() + { + const string Expected = "First\n Name: String { get; }\nSecond\n Name: String { get; }\nThird\n"; + const string Actual = "First\n Name: String { get; }\nSecond\nThird\n Name: String { get; }\n"; + + Assert.Equal(["- Second Name: String { get; }", "+ Third Name: String { get; }"], Changes(Expected, Actual)); + } + + [Fact] + public void An_event_is_a_member_of_the_surface() + { + Assert.Contains(" event Changed: EventHandler\n", Surface([typeof(IWithAnEvent)]), StringComparison.Ordinal); + } + + // Each member line is read with the extension point above it, and each change names both. + private static List Changes(string expected, string actual) + { + var before = Qualified(expected); + var after = Qualified(actual); + return [.. before.Except(after, StringComparer.Ordinal).Select(line => $"- {line}"), .. after.Except(before, StringComparer.Ordinal).Select(line => $"+ {line}")]; + } + + private static List Qualified(string surface) + { + var lines = new List(); + var owner = string.Empty; + foreach (var line in surface.ReplaceLineEndings("\n").Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + if (line.StartsWith(" ", StringComparison.Ordinal)) + { + lines.Add(owner + line); + } + else + { + owner = line; + lines.Add(line); + } + } + + return lines; + } + + // One line per extension point and one per member, in a stable order, with the nullability of each type. + private static string Surface(IEnumerable extensionPoints) + { + var text = new StringBuilder(); + foreach (var type in extensionPoints) + { + text.Append(type.FullName).Append('\n'); + + var members = type.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Select(Describe) + .OfType() + .Order(StringComparer.Ordinal); + foreach (var member in members) + { + text.Append(" ").Append(member).Append('\n'); + } + } + + return text.ToString(); + } + + private static string? Describe(MemberInfo member) + { + switch (member) + { + case PropertyInfo property: + var accessors = (property.GetMethod is not null ? "get; " : string.Empty) + (property.SetMethod is not null ? "set; " : string.Empty); + var defaulted = property.GetMethod is { IsAbstract: false } ? " (default)" : string.Empty; + return $"{property.Name}: {Name(Nullability.Create(property))} {{ {accessors}}}{defaulted}"; + case MethodInfo { IsSpecialName: false } method: + var parameters = string.Join(", ", method.GetParameters().Select(parameter => + $"{Name(Nullability.Create(parameter))} {parameter.Name}" + (parameter.HasDefaultValue ? $" = {parameter.DefaultValue ?? "default"}" : string.Empty))); + var generics = method.IsGenericMethodDefinition ? $"<{string.Join(", ", method.GetGenericArguments().Select(argument => argument.Name))}>" : string.Empty; + return $"{method.Name}{generics}({parameters}): {Name(Nullability.Create(method.ReturnParameter))}{(method.IsAbstract ? string.Empty : " (default)")}"; + case EventInfo @event: + return $"event {@event.Name}: {Name(Nullability.Create(@event))}{(@event.AddMethod is { IsAbstract: false } ? " (default)" : string.Empty)}"; + default: + return null; + } + } + + private static string Name(NullabilityInfo info) + { + var type = info.Type; + var nullable = info.ReadState == NullabilityState.Nullable ? "?" : string.Empty; + + if (Nullable.GetUnderlyingType(type) is { } underlying) + { + return Simple(underlying) + "?"; + } + + if (type.IsArray) + { + return Name(info.ElementType!) + "[]" + nullable; + } + + if (type.IsGenericType) + { + var name = type.Name[..type.Name.IndexOf('`', StringComparison.Ordinal)]; + return $"{name}<{string.Join(", ", info.GenericTypeArguments.Select(Name))}>{nullable}"; + } + + return Simple(type) + nullable; + } + + private static string Simple(Type type) + { + return type == typeof(void) ? "void" : type.Name; + } + + [GeneratedRegex(@"\[ConformanceFor\((?:[^\[\]]|\[[^\]]*\])*\)\]")] + private static partial Regex ConformanceFor(); + + [GeneratedRegex(@"typeof\((\w+)\)")] + private static partial Regex TypeOf(); + + private interface IWithAnEvent + { + event EventHandler Changed; + } +} diff --git a/tests/FluentCMS.Tests/Extensions/extension-points.txt b/tests/FluentCMS.Tests/Extensions/extension-points.txt new file mode 100644 index 000000000..8f63de369 --- /dev/null +++ b/tests/FluentCMS.Tests/Extensions/extension-points.txt @@ -0,0 +1,30 @@ +FluentCMS.Core.ChangeSets.IChangeSetEvents + PublishedAsync(ChangeSet published, ChangeSet? reverted, CancellationToken cancellationToken = default): Task +FluentCMS.Core.Checks.ICheckType + EvaluateAsync(CheckInput input, CancellationToken cancellationToken): Task + Kind: CheckKind { get; } + Name: String { get; } + ValidateSettings(JsonElement settings): IReadOnlyList +FluentCMS.Core.Jobs.IJobHandler + RunAs: SystemInitiator? { get; } (default) + RunAsync(JobContext context, CancellationToken cancellationToken): Task + Type: String { get; } +FluentCMS.Core.Media.IMediaStorage + DeleteAsync(String key, CancellationToken cancellationToken = default): Task + Name: String { get; } + OpenReadAsync(String key, CancellationToken cancellationToken = default): Task + SaveAsync(Stream content, CancellationToken cancellationToken = default): Task +FluentCMS.Core.Schema.IFieldType + DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes): FieldValueDescription + Name: String { get; } + ReferenceTargets(JsonElement settings, FieldTypeRegistry fieldTypes): IReadOnlyList (default) + References(JsonElement value, JsonElement settings, FieldTypeRegistry fieldTypes): IReadOnlyList (default) + ValidateSettings(JsonElement settings, FieldTypeRegistry fieldTypes): IReadOnlyList + ValidateValue(JsonElement value, JsonElement settings, FieldTypeRegistry fieldTypes): IReadOnlyList +FluentCMS.Core.Search.ISearchIndex + IndexAsync(SearchDocument document, CancellationToken cancellationToken = default): Task + MatchAsync(IReadOnlyList terms, Int32 skip, Int32 limit, CancellationToken cancellationToken = default): Task> +FluentCMS.Core.Tools.IResourceProvider + Resources: IReadOnlyList { get; } +FluentCMS.Core.Tools.IToolProvider + Tools: IReadOnlyList { get; } diff --git a/tests/FluentCMS.Tests/FluentCMS.Tests.csproj b/tests/FluentCMS.Tests/FluentCMS.Tests.csproj index 58be6ee11..87c16c120 100644 --- a/tests/FluentCMS.Tests/FluentCMS.Tests.csproj +++ b/tests/FluentCMS.Tests/FluentCMS.Tests.csproj @@ -15,6 +15,10 @@ + + + + diff --git a/tests/FluentCMS.Tests/Jobs/BuiltinJobConformanceTests.cs b/tests/FluentCMS.Tests/Jobs/BuiltinJobConformanceTests.cs new file mode 100644 index 000000000..624e0cd75 --- /dev/null +++ b/tests/FluentCMS.Tests/Jobs/BuiltinJobConformanceTests.cs @@ -0,0 +1,20 @@ +namespace FluentCMS.Tests.Jobs; + +// The job handlers in Core. The base class is compiled into the integration tests too, for handlers that live in Infrastructure. +public sealed class CheckRunJobConformanceTests : JobHandlerConformanceTests +{ + protected override string JobType => "checks.run"; +} + +public sealed class BulkEntryUpdatesConformanceTests : JobHandlerConformanceTests +{ + protected override string JobType => "bulk.entry_updates"; +} + +public sealed class RetentionPurgeJobConformanceTests : JobHandlerConformanceTests +{ + protected override string JobType => "retention.purge"; + + // It purges whatever is past its retention, so it reads nothing from its payload. + protected override bool ReadsPayload => false; +} diff --git a/tests/FluentCMS.Tests/Jobs/JobHandlerConformanceTests.cs b/tests/FluentCMS.Tests/Jobs/JobHandlerConformanceTests.cs new file mode 100644 index 000000000..85bb1c382 --- /dev/null +++ b/tests/FluentCMS.Tests/Jobs/JobHandlerConformanceTests.cs @@ -0,0 +1,106 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using FluentCMS.Core; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Jobs; +using FluentCMS.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Tests.Jobs; + +// The contract every job handler meets, built in or not (EXT-N1, ADR-0010). Derive one class per handler: name its type, and override +// AddHandlers to register one that lives outside Core. +[ConformanceFor(typeof(IJobHandler))] +public abstract partial class JobHandlerConformanceTests : IDisposable +{ + // The length the jobs table keeps for a type. + private const int MaxTypeLength = 128; + + private ServiceProvider? _provider; + + protected abstract string JobType { get; } + + // False for a handler that reads nothing from its payload, which no payload can then make it fail. + protected virtual bool ReadsPayload => true; + + private ServiceProvider Provider => _provider ??= BuildProvider(); + + private IJobHandler Handler => Provider.GetServices().Single(handler => handler.Type == JobType); + + protected virtual void AddHandlers(IServiceCollection services) + { + } + + // Every job stores its type, so it is a short lowercase name, the same on every read, and no other handler claims it. + [Fact] + public void Its_type_is_one_stable_name_no_other_handler_takes() + { + Assert.Single(Provider.GetServices(), handler => handler.Type == JobType); + Assert.Matches(TypeName(), Handler.Type); + Assert.InRange(Handler.Type.Length, 1, MaxTypeLength); + Assert.Equal(Handler.Type, Handler.Type); + } + + // A handler runs as whoever enqueued the job, or as the one engine Core lists it for (ADR-0012), the same on every read. The worker + // fails every job of a handler that names any other engine. + [Fact] + public void It_runs_as_the_enqueuer_or_as_the_engine_core_lists_it_for() + { + var runAs = Provider.GetRequiredService().RunAs(Handler, new UserInitiator(Guid.NewGuid(), "Amir")); + + Assert.True(runAs.IsSuccess, string.Join(" ", runAs.Errors.Select(error => error.Message))); + Assert.Equal(Handler.RunAs, runAs.Value); + Assert.Equal(Handler.RunAs, Handler.RunAs); + } + + // Another attempt cannot fix a payload the handler cannot read, so it is a failure with a code and a message, not an exception. + [Theory] + [InlineData("{}")] + [InlineData("[]")] + [InlineData("\"text\"")] + [InlineData("null")] + public async Task A_payload_it_cannot_read_is_a_failure_that_says_why(string payload) + { + if (!ReadsPayload) + { + return; + } + + var job = new Job + { + Id = Guid.CreateVersion7(), + Type = JobType, + Payload = JsonDocument.Parse(payload).RootElement.Clone(), + State = JobState.Running, + Attempts = 1, + MaxAttempts = NewJob.DefaultMaxAttempts, + CreatedAt = DateTimeOffset.UtcNow, + ScheduledAt = DateTimeOffset.UtcNow, + }; + await using var scope = Provider.CreateAsyncScope(); + + var result = await Handler.RunAsync(new JobContext(job, scope.ServiceProvider, (_, _) => Task.FromResult(true)), CancellationToken.None); + + var error = Assert.Single(result.Errors); + Assert.False(string.IsNullOrWhiteSpace(error.Code)); + Assert.False(string.IsNullOrWhiteSpace(error.Message)); + } + + public void Dispose() + { + _provider?.Dispose(); + GC.SuppressFinalize(this); + } + + private ServiceProvider BuildProvider() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddFluentCmsCore(); + AddHandlers(services); + return services.BuildServiceProvider(); + } + + [GeneratedRegex(@"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*\z")] + private static partial Regex TypeName(); +} diff --git a/tests/FluentCMS.Tests/Schema/FieldTypeConformanceTests.cs b/tests/FluentCMS.Tests/Schema/FieldTypeConformanceTests.cs index 70ea24159..af379f6c1 100644 --- a/tests/FluentCMS.Tests/Schema/FieldTypeConformanceTests.cs +++ b/tests/FluentCMS.Tests/Schema/FieldTypeConformanceTests.cs @@ -4,6 +4,7 @@ using FluentCMS.Core.Extensions; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; +using FluentCMS.Testing; using Microsoft.Extensions.DependencyInjection; namespace FluentCMS.Tests.Schema; @@ -11,6 +12,7 @@ namespace FluentCMS.Tests.Schema; // The contract every field type meets, built in or not (EXT-N1). Derive one class per type: name the type, list settings it must // accept and settings it must reject, list values it must accept and reject under given settings, and override AddFieldTypes to // register a type that lives outside Core. +[ConformanceFor(typeof(IFieldType))] public abstract class FieldTypeConformanceTests : IDisposable { private static readonly string[] ValuesOfEveryKind = diff --git a/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs b/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs index bd395c5ea..1ac552e75 100644 --- a/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs +++ b/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs @@ -2,6 +2,7 @@ using System.Text.Json.Nodes; using FluentCMS.Core.Builtin.Tools; using FluentCMS.Core.Tools; +using FluentCMS.Testing; using FluentCMS.Tests.Schema; namespace FluentCMS.Tests.Tools; @@ -9,6 +10,7 @@ namespace FluentCMS.Tests.Tools; // The contract every tool and resource provider meets, built in or not (EXT-N1, MCP-02): an agent can read what each tool does // and what each argument takes, and an argument the tool does not know comes back as an error before anything is read. // Derive one class per provider and return it from Provider. +[ConformanceFor(typeof(IToolProvider), typeof(IResourceProvider))] public abstract class ToolProviderConformanceTests { protected abstract object Provider { get; }