diff --git a/velocity-backend-jdbi/src/test/java/com/codeheadsystems/velocity/backend/jdbi/JdbiAggregationTest.java b/velocity-backend-jdbi/src/test/java/com/codeheadsystems/velocity/backend/jdbi/JdbiAggregationTest.java deleted file mode 100644 index 4d32ef6..0000000 --- a/velocity-backend-jdbi/src/test/java/com/codeheadsystems/velocity/backend/jdbi/JdbiAggregationTest.java +++ /dev/null @@ -1,416 +0,0 @@ -// SPDX-License-Identifier: BSD-3-Clause -package com.codeheadsystems.velocity.backend.jdbi; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.codeheadsystems.velocity.spi.model.Aggregation; -import com.codeheadsystems.velocity.spi.model.ApplyContext; -import com.codeheadsystems.velocity.spi.model.ApplyResult; -import com.codeheadsystems.velocity.spi.model.ApplyStatus; -import com.codeheadsystems.velocity.spi.model.BucketValue; -import com.codeheadsystems.velocity.spi.model.DistinctMember; -import com.codeheadsystems.velocity.spi.model.Exactness; -import com.codeheadsystems.velocity.spi.model.FailureCode; -import com.codeheadsystems.velocity.spi.model.Feature; -import com.codeheadsystems.velocity.spi.model.FeatureResult; -import com.codeheadsystems.velocity.spi.model.FeatureValue; -import com.codeheadsystems.velocity.spi.model.Intent.CountIntent; -import com.codeheadsystems.velocity.spi.model.Intent.DistinctIntent; -import com.codeheadsystems.velocity.spi.model.Intent.SumIntent; -import com.codeheadsystems.velocity.spi.model.Namespace; -import com.codeheadsystems.velocity.spi.model.QueryContext; -import com.codeheadsystems.velocity.spi.model.QueryTuple; -import com.codeheadsystems.velocity.spi.model.ReadYourWriteLevel; -import com.codeheadsystems.velocity.spi.model.SeedAggregate; -import com.codeheadsystems.velocity.spi.model.Subject; -import com.codeheadsystems.velocity.spi.model.Window; -import com.codeheadsystems.velocity.spi.model.WindowBounds; -import com.codeheadsystems.velocity.spi.model.WindowType; -import com.codeheadsystems.velocity.testkit.WindowMath; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.List; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -/** - * Backend-specific COUNT/SUM/DISTINCT/PURGE assertions against real Postgres, over TUMBLING windows - * — the tumbling counterpart of the sliding-based {@code *StoreScenarios} the tumbling-only backend - * cannot run. Same contract facets: exact windowed values, read-your-write, namespace/subject - * isolation, seed merge, and the distinguishable-failure path for an unsupported window. - */ -final class JdbiAggregationTest extends AbstractJdbiBackendTest { - - private static final Namespace NS_A = new Namespace("jdbi-ns-a"); - private static final Namespace NS_B = new Namespace("jdbi-ns-b"); - private static final Subject SUBJECT_A = new Subject("card", "subject-a"); - private static final Subject SUBJECT_B = new Subject("card", "subject-b"); - private static final Window TUMBLING_1M = new Window(Duration.ofMinutes(1), WindowType.TUMBLING); - private static final Window TUMBLING_1H = new Window(Duration.ofHours(1), WindowType.TUMBLING); - private static final Window SLIDING_1M = new Window(Duration.ofMinutes(1), WindowType.SLIDING); - private static final String DIMENSION = "ip"; - - private static ApplyContext apply(final Namespace ns) { - return new ApplyContext(ns, null); - } - - private static QueryContext query(final Namespace ns) { - return new QueryContext(ns, null); - } - - private static DistinctMember member(final String token) { - return new DistinctMember(token.getBytes(StandardCharsets.UTF_8)); - } - - private static void assertValue(final FeatureResult result, final long expected) { - assertThat(result).isInstanceOf(FeatureResult.Success.class); - final FeatureValue value = ((FeatureResult.Success) result).value(); - assertThat(value.value()).isEqualByComparingTo(BigDecimal.valueOf(expected)); - } - - private static FeatureValue successValue(final FeatureResult result) { - assertThat(result).isInstanceOf(FeatureResult.Success.class); - return ((FeatureResult.Success) result).value(); - } - - @Nested - final class Count { - - private final Feature feature = - new Feature("jdbi.count", Aggregation.count(), List.of(TUMBLING_1M)); - - private FeatureResult read(final Namespace ns, final Subject subject) { - return backend - .queryCount(query(ns), List.of(new QueryTuple(subject, Aggregation.count(), TUMBLING_1M))) - .get(0); - } - - @Test - void applyThenQueryReturnsExactCountFlaggedExactAtomic() { - backend.applyCount( - apply(NS_A), - List.of( - new CountIntent(feature, SUBJECT_A), - new CountIntent(feature, SUBJECT_A), - new CountIntent(feature, SUBJECT_A))); - - final FeatureResult result = read(NS_A, SUBJECT_A); - assertValue(result, 3); - assertThat(successValue(result).exactness()).isEqualTo(Exactness.EXACT); - assertThat(successValue(result).readYourWriteLevel()).isEqualTo(ReadYourWriteLevel.ATOMIC); - assertThat(successValue(result).asOf()).isEqualTo(clock.instant()); - } - - @Test - void applyResultReflectsWriteReadYourWrite() { - final ApplyResult first = - backend.applyCount(apply(NS_A), List.of(new CountIntent(feature, SUBJECT_A))); - assertThat(first.perFeature().get(0).status()).isEqualTo(ApplyStatus.APPLIED); - assertValue(first.perFeature().get(0).result(), 1); - - final ApplyResult second = - backend.applyCount(apply(NS_A), List.of(new CountIntent(feature, SUBJECT_A))); - assertValue(second.perFeature().get(0).result(), 2); - } - - @Test - void multiWindowFeatureEmitsOneAppliedPerWindow() { - final Feature multi = - new Feature("jdbi.count.multi", Aggregation.count(), List.of(TUMBLING_1M, TUMBLING_1H)); - final ApplyResult result = - backend.applyCount(apply(NS_A), List.of(new CountIntent(multi, SUBJECT_A))); - - assertThat(result.perFeature()).hasSize(2); - assertThat(result.perFeature()) - .allSatisfy(pf -> assertThat(pf.status()).isEqualTo(ApplyStatus.APPLIED)); - assertThat( - result.perFeature().stream().map(pf -> successValue(pf.result()).window()).toList()) - .containsExactlyInAnyOrder(TUMBLING_1M, TUMBLING_1H); - assertThat(result.perFeature()).allSatisfy(pf -> assertValue(pf.result(), 1)); - } - - @Test - void valuesIsolatedByNamespace() { - backend.applyCount( - apply(NS_A), - List.of(new CountIntent(feature, SUBJECT_A), new CountIntent(feature, SUBJECT_A))); - assertValue(read(NS_A, SUBJECT_A), 2); - assertValue(read(NS_B, SUBJECT_A), 0); - } - - @Test - void valuesIsolatedBySubject() { - backend.applyCount(apply(NS_A), List.of(new CountIntent(feature, SUBJECT_A))); - assertValue(read(NS_A, SUBJECT_A), 1); - assertValue(read(NS_A, SUBJECT_B), 0); - } - - @Test - void unsupportedWindowApplyIsDistinguishableFailureNotSilentZero() { - final Feature sliding = - new Feature("jdbi.count.sliding", Aggregation.count(), List.of(SLIDING_1M)); - final ApplyResult result = - backend.applyCount(apply(NS_A), List.of(new CountIntent(sliding, SUBJECT_A))); - assertThat(result.perFeature()).hasSize(1); - assertThat(result.perFeature().get(0).status()).isEqualTo(ApplyStatus.FAILED); - assertThat(result.perFeature().get(0).result()).isInstanceOf(FeatureResult.Failure.class); - assertThat(((FeatureResult.Failure) result.perFeature().get(0).result()).code()) - .isEqualTo(FailureCode.UNSUPPORTED_WINDOW); - } - - @Test - void mixedFeatureAppliesSupportedWindowAndFailsUnsupportedInSameCall() { - final Feature mixed = - new Feature("jdbi.count.mixed", Aggregation.count(), List.of(TUMBLING_1M, SLIDING_1M)); - final ApplyResult result = - backend.applyCount(apply(NS_A), List.of(new CountIntent(mixed, SUBJECT_A))); - assertThat(result.perFeature()).hasSize(2); - assertThat(result.perFeature().stream().map(pf -> pf.status()).toList()) - .containsExactlyInAnyOrder(ApplyStatus.APPLIED, ApplyStatus.FAILED); - // The supported window still recorded exactly one event. - assertValue(read(NS_A, SUBJECT_A), 1); - } - } - - @Nested - final class Sum { - - private final Feature feature = - new Feature("jdbi.sum", Aggregation.sum(), List.of(TUMBLING_1M)); - - private FeatureValue read() { - return successValue( - backend - .querySum( - query(NS_A), List.of(new QueryTuple(SUBJECT_A, Aggregation.sum(), TUMBLING_1M))) - .get(0)); - } - - @Test - void applyThenQueryReturnsExactSumCentsScaleZero() { - backend.applySum( - apply(NS_A), - List.of( - new SumIntent(feature, SUBJECT_A, BigDecimal.valueOf(150)), - new SumIntent(feature, SUBJECT_A, BigDecimal.valueOf(250)))); - final FeatureValue value = read(); - assertThat(value.value()).isEqualByComparingTo(BigDecimal.valueOf(400)); - assertThat(value.value().scale()).isZero(); - assertThat(value.asOf()).isEqualTo(clock.instant()); - } - - @Test - void applyResultReflectsRunningSum() { - final ApplyResult first = - backend.applySum( - apply(NS_A), List.of(new SumIntent(feature, SUBJECT_A, BigDecimal.valueOf(500)))); - assertThat(successValue(first.perFeature().get(0).result()).value()) - .isEqualByComparingTo(BigDecimal.valueOf(500)); - final ApplyResult second = - backend.applySum( - apply(NS_A), List.of(new SumIntent(feature, SUBJECT_A, BigDecimal.valueOf(125)))); - assertThat(successValue(second.perFeature().get(0).result()).value()) - .isEqualByComparingTo(BigDecimal.valueOf(625)); - } - - @Test - void bigDecimalCentsPreservedWithoutOverflow() { - final BigDecimal big = new BigDecimal("9000000000000000000"); - backend.applySum( - apply(NS_A), - List.of(new SumIntent(feature, SUBJECT_A, big), new SumIntent(feature, SUBJECT_A, big))); - assertThat(read().value()).isEqualByComparingTo(big.add(big)); - } - - @Test - void negativeValuesForRefunds() { - backend.applySum( - apply(NS_A), - List.of( - new SumIntent(feature, SUBJECT_A, BigDecimal.valueOf(500)), - new SumIntent(feature, SUBJECT_A, BigDecimal.valueOf(-200)))); - assertThat(read().value()).isEqualByComparingTo(BigDecimal.valueOf(300)); - } - - @Test - void emptyWindowIsKnownZero() { - assertThat(read().value()).isEqualByComparingTo(BigDecimal.ZERO); - } - } - - @Nested - final class Distinct { - - private final Feature feature = - new Feature("jdbi.distinct", Aggregation.distinct(DIMENSION), List.of(TUMBLING_1M)); - - private FeatureResult read(final Subject subject) { - return backend - .queryDistinct( - query(NS_A), - List.of(new QueryTuple(subject, Aggregation.distinct(DIMENSION), TUMBLING_1M))) - .get(0); - } - - @Test - void applyThenQueryReturnsCardinalityFlaggedExact() { - backend.applyDistinct( - apply(NS_A), - List.of( - new DistinctIntent(feature, SUBJECT_A, member("alice")), - new DistinctIntent(feature, SUBJECT_A, member("bob")), - new DistinctIntent(feature, SUBJECT_A, member("carol")))); - final FeatureResult result = read(SUBJECT_A); - assertValue(result, 3); - assertThat(successValue(result).exactness()).isEqualTo(Exactness.EXACT); - } - - @Test - void deDupesRepeatedMembers() { - backend.applyDistinct( - apply(NS_A), - List.of( - new DistinctIntent(feature, SUBJECT_A, member("alice")), - new DistinctIntent(feature, SUBJECT_A, member("alice")), - new DistinctIntent(feature, SUBJECT_A, member("bob")))); - assertValue(read(SUBJECT_A), 2); - } - - @Test - void applyResultReflectsCardinalityReadYourWrite() { - final ApplyResult first = - backend.applyDistinct( - apply(NS_A), List.of(new DistinctIntent(feature, SUBJECT_A, member("alice")))); - assertValue(first.perFeature().get(0).result(), 1); - final ApplyResult second = - backend.applyDistinct( - apply(NS_A), List.of(new DistinctIntent(feature, SUBJECT_A, member("bob")))); - assertValue(second.perFeature().get(0).result(), 2); - } - - @Test - void valuesIsolatedBySubject() { - backend.applyDistinct( - apply(NS_A), List.of(new DistinctIntent(feature, SUBJECT_A, member("alice")))); - assertValue(read(SUBJECT_B), 0); - } - } - - @Nested - final class Purge { - - private final Feature feature = - new Feature("jdbi.purge", Aggregation.count(), List.of(TUMBLING_1M)); - - private FeatureResult count(final Namespace ns, final Subject subject) { - return backend - .queryCount(query(ns), List.of(new QueryTuple(subject, Aggregation.count(), TUMBLING_1M))) - .get(0); - } - - @Test - void purgeSubjectClearsThatSubjectOnly() { - backend.applyCount(apply(NS_A), List.of(new CountIntent(feature, SUBJECT_A))); - backend.applyCount(apply(NS_A), List.of(new CountIntent(feature, SUBJECT_B))); - - backend.purge(NS_A, SUBJECT_A); - - assertValue(count(NS_A, SUBJECT_A), 0); - assertValue(count(NS_A, SUBJECT_B), 1); - } - - @Test - void purgeNamespaceClearsEntireNamespace() { - backend.applyCount(apply(NS_A), List.of(new CountIntent(feature, SUBJECT_A))); - backend.applyCount(apply(NS_A), List.of(new CountIntent(feature, SUBJECT_B))); - backend.applyCount(apply(NS_B), List.of(new CountIntent(feature, SUBJECT_A))); - - backend.purge(NS_A, null); - - assertValue(count(NS_A, SUBJECT_A), 0); - assertValue(count(NS_A, SUBJECT_B), 0); - assertValue(count(NS_B, SUBJECT_A), 1); - } - } - - @Nested - final class Seed { - - @Test - void seededSumBucketMergesWithRecorded() { - final Feature feature = new Feature("jdbi.seed.sum", Aggregation.sum(), List.of(TUMBLING_1M)); - final WindowBounds bucket = WindowMath.tumblingBucket(clock.instant(), 60_000); - backend.seed( - NS_A, - SUBJECT_A, - feature, - List.of(new BucketValue(bucket, new SeedAggregate.SumValue(BigDecimal.valueOf(1000))))); - backend.applySum( - apply(NS_A), List.of(new SumIntent(feature, SUBJECT_A, BigDecimal.valueOf(250)))); - - final FeatureValue value = - successValue( - backend - .querySum( - query(NS_A), - List.of(new QueryTuple(SUBJECT_A, Aggregation.sum(), TUMBLING_1M))) - .get(0)); - assertThat(value.value()).isEqualByComparingTo(BigDecimal.valueOf(1250)); - } - - @Test - void seededExactDistinctBucketMergesWithRecorded() { - final Feature feature = - new Feature("jdbi.seed.distinct", Aggregation.distinct(DIMENSION), List.of(TUMBLING_1M)); - final WindowBounds bucket = WindowMath.tumblingBucket(clock.instant(), 60_000); - backend.seed( - NS_A, - SUBJECT_A, - feature, - List.of( - new BucketValue( - bucket, - new SeedAggregate.ExactDistinct(List.of(member("alice"), member("bob")))))); - // Recording an overlapping member ("bob") plus a new one ("carol") de-dupes against the seed. - backend.applyDistinct( - apply(NS_A), - List.of( - new DistinctIntent(feature, SUBJECT_A, member("bob")), - new DistinctIntent(feature, SUBJECT_A, member("carol")))); - - assertValue( - backend - .queryDistinct( - query(NS_A), - List.of(new QueryTuple(SUBJECT_A, Aggregation.distinct(DIMENSION), TUMBLING_1M))) - .get(0), - 3); - } - - @Test - void seedRejectsUnsupportedAggregationWindowSpan() { - final Feature feature = - new Feature("jdbi.seed.bad", Aggregation.count(), List.of(TUMBLING_1M)); - final WindowBounds start = WindowMath.tumblingBucket(clock.instant(), 60_000); - final WindowBounds badBucket = new WindowBounds(start.start(), start.start().plusSeconds(90)); - assertThatThrownBy( - () -> - backend.seed( - NS_A, - SUBJECT_A, - feature, - List.of(new BucketValue(badBucket, new SeedAggregate.CountValue(1))))) - .isInstanceOf(IllegalArgumentException.class); - } - } - - @Test - void dataSourceConstructorProducesUsableBackend() { - final JdbiVelocityBackend viaDs = - new JdbiVelocityBackend(PostgresSupport.dataSource()).migrate(); - assertThat(viaDs.capabilities().seedSupported()).isTrue(); - assertThat(viaDs.clock()).isNotNull(); - } -} diff --git a/velocity-backend-jdbi/src/test/java/com/codeheadsystems/velocity/backend/jdbi/JdbiConcurrencyTest.java b/velocity-backend-jdbi/src/test/java/com/codeheadsystems/velocity/backend/jdbi/JdbiConcurrencyTest.java deleted file mode 100644 index 7c02881..0000000 --- a/velocity-backend-jdbi/src/test/java/com/codeheadsystems/velocity/backend/jdbi/JdbiConcurrencyTest.java +++ /dev/null @@ -1,91 +0,0 @@ -// SPDX-License-Identifier: BSD-3-Clause -package com.codeheadsystems.velocity.backend.jdbi; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.codeheadsystems.velocity.spi.model.Aggregation; -import com.codeheadsystems.velocity.spi.model.ApplyContext; -import com.codeheadsystems.velocity.spi.model.Feature; -import com.codeheadsystems.velocity.spi.model.FeatureResult; -import com.codeheadsystems.velocity.spi.model.Intent.CountIntent; -import com.codeheadsystems.velocity.spi.model.Namespace; -import com.codeheadsystems.velocity.spi.model.QueryContext; -import com.codeheadsystems.velocity.spi.model.QueryTuple; -import com.codeheadsystems.velocity.spi.model.Subject; -import com.codeheadsystems.velocity.spi.model.Window; -import com.codeheadsystems.velocity.spi.model.WindowType; -import java.math.BigDecimal; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import org.junit.jupiter.api.Test; - -/** - * Acceptance #1: the Postgres upsert is atomic under concurrent load. {@code N} threads each apply - * the same COUNT feature {@code M} times against real Postgres; the final bucket count must be - * exactly {@code N*M}, never lost to a read-modify-write race (NFR-6). This is what the {@code - * INSERT ... ON CONFLICT DO UPDATE SET count = count + 1} row-locking upsert buys. - */ -final class JdbiConcurrencyTest extends AbstractJdbiBackendTest { - - private static final Namespace NS = new Namespace("jdbi-concurrency"); - private static final Subject SUBJECT = new Subject("card", "hot-subject"); - private static final Window TUMBLING_1H = new Window(Duration.ofHours(1), WindowType.TUMBLING); - - @Test - void concurrentApplyToSameBucketIsExactlyAtomic() throws Exception { - // A 1-hour tumbling bucket so every apply lands in the SAME bucket for the whole test run. - final Feature feature = - new Feature("jdbi.concurrent.count", Aggregation.count(), List.of(TUMBLING_1H)); - final int threads = 16; - final int perThread = 50; - final int expected = threads * perThread; - - final CountDownLatch ready = new CountDownLatch(threads); - final CountDownLatch fire = new CountDownLatch(1); - try (ExecutorService pool = Executors.newFixedThreadPool(threads)) { - final List> futures = new ArrayList<>(); - for (int t = 0; t < threads; t++) { - futures.add( - pool.submit( - () -> { - ready.countDown(); - await(fire); - for (int i = 0; i < perThread; i++) { - backend.applyCount( - new ApplyContext(NS, null), List.of(new CountIntent(feature, SUBJECT))); - } - })); - } - assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); - fire.countDown(); - for (final Future future : futures) { - future.get(30, TimeUnit.SECONDS); - } - } - - final FeatureResult result = - backend - .queryCount( - new QueryContext(NS, null), - List.of(new QueryTuple(SUBJECT, Aggregation.count(), TUMBLING_1H))) - .get(0); - assertThat(result).isInstanceOf(FeatureResult.Success.class); - assertThat(((FeatureResult.Success) result).value().value()) - .isEqualByComparingTo(BigDecimal.valueOf(expected)); - } - - private static void await(final CountDownLatch latch) { - try { - latch.await(); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException(e); - } - } -} diff --git a/velocity-backend-jdbi/src/test/java/com/codeheadsystems/velocity/backend/jdbi/JdbiConformanceTckTest.java b/velocity-backend-jdbi/src/test/java/com/codeheadsystems/velocity/backend/jdbi/JdbiConformanceTckTest.java index 2c582d4..b2be696 100644 --- a/velocity-backend-jdbi/src/test/java/com/codeheadsystems/velocity/backend/jdbi/JdbiConformanceTckTest.java +++ b/velocity-backend-jdbi/src/test/java/com/codeheadsystems/velocity/backend/jdbi/JdbiConformanceTckTest.java @@ -1,24 +1,50 @@ // SPDX-License-Identifier: BSD-3-Clause package com.codeheadsystems.velocity.backend.jdbi; +import com.codeheadsystems.velocity.spi.model.Window; +import com.codeheadsystems.velocity.spi.model.WindowType; import com.codeheadsystems.velocity.testkit.tck.CapabilityConformanceScenarios; +import com.codeheadsystems.velocity.testkit.tck.CountStoreScenarios; +import com.codeheadsystems.velocity.testkit.tck.DistinctStoreScenarios; +import com.codeheadsystems.velocity.testkit.tck.PurgeScenarios; import com.codeheadsystems.velocity.testkit.tck.SeedSupportScenarios; +import com.codeheadsystems.velocity.testkit.tck.SumStoreScenarios; import com.codeheadsystems.velocity.testkit.tck.TumblingScenarios; +import java.time.Duration; import org.junit.jupiter.api.Test; /** - * Drives the {@code velocity-testkit} conformance TCK against real Postgres for the scenarios that - * apply to a tumbling-only backend (ADR 0004): {@link TumblingScenarios}, {@link - * SeedSupportScenarios} and {@link CapabilityConformanceScenarios}. This proves the JDBI backend - * honors the same frozen contract as the reference in-memory backend. + * Drives the full {@code velocity-testkit} conformance TCK against real Postgres for every scenario + * that applies to this tumbling-only backend (ADR 0004) — the same shared {@code *Scenarios} the + * reference in-memory backend runs, now parameterized by window so a tumbling backend supplies its + * own supported windows. This proves the JDBI backend honors the identical frozen contract. * *

{@code SlidingScenarios} is intentionally NOT run — this backend declares no sliding windows. - * The aggregation suites ({@code Count/Sum/Distinct/PurgeStoreScenarios}) hardcode a sliding window - * in their fixtures, so they cannot run against a tumbling-only backend; the equivalent tumbling - * assertions live in {@link JdbiAggregationTest}. + * The window-agnostic aggregation suites ({@code Count/Sum/Distinct/PurgeStoreScenarios}) run here + * with {@link WindowType#TUMBLING tumbling} windows, including the shared concurrency scenario + * against real Postgres (acceptance #1). */ final class JdbiConformanceTckTest extends AbstractJdbiBackendTest { + private static final Window TUMBLING_1M = new Window(Duration.ofMinutes(1), WindowType.TUMBLING); + private static final Window TUMBLING_1H = new Window(Duration.ofHours(1), WindowType.TUMBLING); + + private CountStoreScenarios count() { + return new CountStoreScenarios(backend, clock, TUMBLING_1M, TUMBLING_1H); + } + + private SumStoreScenarios sum() { + return new SumStoreScenarios(backend, clock, TUMBLING_1H); + } + + private DistinctStoreScenarios distinct() { + return new DistinctStoreScenarios(backend, clock, TUMBLING_1H); + } + + private PurgeScenarios purge() { + return new PurgeScenarios(backend, TUMBLING_1H); + } + private TumblingScenarios tumbling() { return new TumblingScenarios(backend, clock); } @@ -31,6 +57,100 @@ private CapabilityConformanceScenarios capability() { return new CapabilityConformanceScenarios(backend); } + // ---- CountStoreScenarios (tumbling) ---- + + @Test + void applyThenQueryReturnsExactCount() { + count().applyThenQueryReturnsExactCount(); + } + + @Test + void applyResultReflectsWriteReadYourWrite() { + count().applyResultReflectsWriteReadYourWrite(); + } + + @Test + void applyEmitsOneResultPerWindow() { + count().applyEmitsOneResultPerWindow(); + } + + @Test + void countValuesIsolatedByNamespace() { + count().valuesIsolatedByNamespace(); + } + + @Test + void countValuesIsolatedBySubject() { + count().valuesIsolatedBySubject(); + } + + /** The shared concurrency scenario (acceptance #1) against real Postgres. */ + @Test + void concurrentApplyIsAtomic() throws Exception { + count().concurrentApplyIsAtomic(); + } + + // ---- SumStoreScenarios (tumbling) ---- + + @Test + void applyThenQueryReturnsExactSumCents() { + sum().applyThenQueryReturnsExactSumCents(); + } + + @Test + void applyResultReflectsRunningSum() { + sum().applyResultReflectsRunningSum(); + } + + @Test + void bigDecimalCentsPreservedWithoutOverflow() { + sum().bigDecimalCentsPreservedWithoutOverflow(); + } + + @Test + void negativeValuesForRefunds() { + sum().negativeValuesForRefunds(); + } + + @Test + void sumValuesIsolatedByNamespace() { + sum().valuesIsolatedByNamespace(); + } + + // ---- DistinctStoreScenarios (tumbling) ---- + + @Test + void applyThenQueryReturnsCardinality() { + distinct().applyThenQueryReturnsCardinality(); + } + + @Test + void deDupesRepeatedMembers() { + distinct().deDupesRepeatedMembers(); + } + + @Test + void applyResultReflectsCardinality() { + distinct().applyResultReflectsCardinality(); + } + + @Test + void distinctValuesIsolatedBySubject() { + distinct().valuesIsolatedBySubject(); + } + + // ---- PurgeScenarios (tumbling) ---- + + @Test + void purgeSubjectClearsThatSubjectOnly() { + purge().purgeSubjectClearsThatSubjectOnly(); + } + + @Test + void purgeNamespaceClearsEntireNamespace() { + purge().purgeNamespaceClearsEntireNamespace(); + } + // ---- TumblingScenarios ---- @Test @@ -86,4 +206,14 @@ void unsupportedWindowQueryIsDistinguishableFailure() { void supportedEmptyWindowReturnsKnownZero() { capability().supportedEmptyWindowReturnsKnownZero(); } + + @Test + void unsupportedWindowApplyIsDistinguishableFailure() { + capability().unsupportedWindowApplyIsDistinguishableFailure(); + } + + @Test + void mixedApplyPartiallyFailsOnUnsupportedWindow() { + capability().mixedApplyPartiallyFailsOnUnsupportedWindow(); + } } diff --git a/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/CapabilityConformanceScenarios.java b/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/CapabilityConformanceScenarios.java index 76e23c1..4d8c1ff 100644 --- a/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/CapabilityConformanceScenarios.java +++ b/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/CapabilityConformanceScenarios.java @@ -18,9 +18,13 @@ import com.codeheadsystems.velocity.spi.VelocityBackend; import com.codeheadsystems.velocity.spi.model.Aggregation; import com.codeheadsystems.velocity.spi.model.AggregationType; +import com.codeheadsystems.velocity.spi.model.ApplyStatus; import com.codeheadsystems.velocity.spi.model.BackendCapabilities; import com.codeheadsystems.velocity.spi.model.FailureCode; +import com.codeheadsystems.velocity.spi.model.Feature; import com.codeheadsystems.velocity.spi.model.FeatureResult; +import com.codeheadsystems.velocity.spi.model.Intent.CountIntent; +import com.codeheadsystems.velocity.spi.model.PerFeature; import com.codeheadsystems.velocity.spi.model.QueryContext; import com.codeheadsystems.velocity.spi.model.Window; import com.codeheadsystems.velocity.spi.model.WindowType; @@ -99,6 +103,46 @@ public void supportedEmptyWindowReturnsKnownZero() { assertValue(queryOne(supported), 0); } + /** + * Applying to an unsupported window fast-rejects with a distinguishable {@code FAILED} {@link + * PerFeature} carrying {@link FailureCode#UNSUPPORTED_WINDOW} — never a silent {@code APPLIED} + * (the apply-side twin of {@link #unsupportedWindowQueryIsDistinguishableFailure}; ADR 0009 rule + * 1, FR-13). Exercised via the {@link CountStore} path — the guard is aggregation-independent. + */ + public void unsupportedWindowApplyIsDistinguishableFailure() { + final PerFeature pf = applyCount(UNSUPPORTED).get(0); + assertThat(pf.status()).isEqualTo(ApplyStatus.FAILED); + assertThat(pf.result().isSuccess()).isFalse(); + assertFailure(pf.result(), FailureCode.UNSUPPORTED_WINDOW); + } + + /** + * One apply spanning a supported and an unsupported window applies the supported one and fails + * only the unsupported one — a per-feature partial outcome (FR-34), not all-or-nothing. + */ + public void mixedApplyPartiallyFailsOnUnsupportedWindow() { + final Window supported = backend.capabilities().windows().get(0).window(); + final List results = applyCount(supported, UNSUPPORTED); + assertThat(results).hasSize(2); + final PerFeature applied = + results.stream().filter(pf -> pf.status() == ApplyStatus.APPLIED).findFirst().orElseThrow(); + final PerFeature failed = + results.stream().filter(pf -> pf.status() == ApplyStatus.FAILED).findFirst().orElseThrow(); + assertThat(applied.result().isSuccess()).isTrue(); + assertFailure(failed.result(), FailureCode.UNSUPPORTED_WINDOW); + } + + private List applyCount(final Window... windows) { + if (!(backend instanceof CountStore countStore)) { + throw new IllegalStateException( + "apply-side capability scenarios require a CountStore backend"); + } + final Feature feature = Tck.countFeature(windows); + return countStore + .applyCount(Tck.apply(NS_A), List.of(new CountIntent(feature, SUBJECT_A))) + .perFeature(); + } + private FeatureResult queryOne(final Window window) { final QueryContext ctx = Tck.query(NS_A); if (backend instanceof CountStore countStore) { diff --git a/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/CountStoreScenarios.java b/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/CountStoreScenarios.java index d8d4385..bdcd36e 100644 --- a/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/CountStoreScenarios.java +++ b/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/CountStoreScenarios.java @@ -3,11 +3,8 @@ import static com.codeheadsystems.velocity.testkit.tck.Tck.NS_A; import static com.codeheadsystems.velocity.testkit.tck.Tck.NS_B; -import static com.codeheadsystems.velocity.testkit.tck.Tck.SLIDING_1M; -import static com.codeheadsystems.velocity.testkit.tck.Tck.SLIDING_5S; import static com.codeheadsystems.velocity.testkit.tck.Tck.SUBJECT_A; import static com.codeheadsystems.velocity.testkit.tck.Tck.SUBJECT_B; -import static com.codeheadsystems.velocity.testkit.tck.Tck.TUMBLING_1H; import static com.codeheadsystems.velocity.testkit.tck.Tck.assertValue; import static com.codeheadsystems.velocity.testkit.tck.Tck.countFeature; import static com.codeheadsystems.velocity.testkit.tck.Tck.successValue; @@ -22,6 +19,7 @@ import com.codeheadsystems.velocity.spi.model.Intent.CountIntent; import com.codeheadsystems.velocity.spi.model.PerFeature; import com.codeheadsystems.velocity.spi.model.ReadYourWriteLevel; +import com.codeheadsystems.velocity.spi.model.Window; import com.codeheadsystems.velocity.testkit.MutableClock; import java.util.ArrayList; import java.util.List; @@ -40,24 +38,34 @@ public final class CountStoreScenarios { private final CountStore store; private final MutableClock clock; + private final Window window; + private final Window secondWindow; /** * @param store a fresh count-capable backend under test * @param clock the backend's controllable clock + * @param window a window the backend supports, used by the single-window scenarios + * @param secondWindow a second, distinct supported window used to exercise apply cardinality */ - public CountStoreScenarios(final CountStore store, final MutableClock clock) { + public CountStoreScenarios( + final CountStore store, + final MutableClock clock, + final Window window, + final Window secondWindow) { this.store = Objects.requireNonNull(store, "store"); this.clock = Objects.requireNonNull(clock, "clock"); + this.window = Objects.requireNonNull(window, "window"); + this.secondWindow = Objects.requireNonNull(secondWindow, "secondWindow"); } /** Apply N events then query the window returns exactly N, flagged EXACT/ATOMIC. */ public void applyThenQueryReturnsExactCount() { - final Feature feature = countFeature(SLIDING_1M); + final Feature feature = countFeature(window); store.applyCount(Tck.apply(NS_A), intents(feature, 3)); final var results = store.queryCount( - Tck.query(NS_A), List.of(Tck.tuple(SUBJECT_A, Aggregation.count(), SLIDING_1M))); + Tck.query(NS_A), List.of(Tck.tuple(SUBJECT_A, Aggregation.count(), window))); assertThat(results).hasSize(1); assertValue(results.get(0), 3); assertThat(successValue(results.get(0)).exactness()).isEqualTo(Exactness.EXACT); @@ -69,7 +77,7 @@ public void applyThenQueryReturnsExactCount() { /** The value returned by apply already reflects the caller's own write (ADR 0007, NFR-7). */ public void applyResultReflectsWriteReadYourWrite() { - final Feature feature = countFeature(SLIDING_1M); + final Feature feature = countFeature(window); final ApplyResult first = store.applyCount(Tck.apply(NS_A), intents(feature, 1)); assertThat(first.perFeature()).hasSize(1); @@ -82,51 +90,46 @@ public void applyResultReflectsWriteReadYourWrite() { /** A multi-window feature yields one {@link PerFeature} per window (apply cardinality). */ public void applyEmitsOneResultPerWindow() { - final Feature feature = countFeature(SLIDING_5S, TUMBLING_1H); + final Feature feature = countFeature(window, secondWindow); final ApplyResult result = store.applyCount(Tck.apply(NS_A), intents(feature, 1)); assertThat(result.perFeature()).hasSize(2); assertThat(result.perFeature()) .allSatisfy(pf -> assertThat(pf.status()).isEqualTo(ApplyStatus.APPLIED)); assertThat(result.perFeature().stream().map(pf -> successValue(pf.result()).window()).toList()) - .containsExactlyInAnyOrder(SLIDING_5S, TUMBLING_1H); + .containsExactlyInAnyOrder(window, secondWindow); // One underlying event, not one per window: each window sees exactly the single apply. assertThat(result.perFeature()).allSatisfy(pf -> assertValue(pf.result(), 1)); } /** Counts in one namespace are invisible in another; the other reads a known zero. */ public void valuesIsolatedByNamespace() { - store.applyCount(Tck.apply(NS_A), intents(countFeature(SLIDING_1M), 2)); + store.applyCount(Tck.apply(NS_A), intents(countFeature(window), 2)); assertValue( store - .queryCount( - Tck.query(NS_A), List.of(Tck.tuple(SUBJECT_A, Aggregation.count(), SLIDING_1M))) + .queryCount(Tck.query(NS_A), List.of(Tck.tuple(SUBJECT_A, Aggregation.count(), window))) .get(0), 2); assertValue( store - .queryCount( - Tck.query(NS_B), List.of(Tck.tuple(SUBJECT_A, Aggregation.count(), SLIDING_1M))) + .queryCount(Tck.query(NS_B), List.of(Tck.tuple(SUBJECT_A, Aggregation.count(), window))) .get(0), 0); } /** Counts for one subject are invisible for another subject in the same namespace. */ public void valuesIsolatedBySubject() { - store.applyCount( - Tck.apply(NS_A), List.of(new CountIntent(countFeature(SLIDING_1M), SUBJECT_A))); + store.applyCount(Tck.apply(NS_A), List.of(new CountIntent(countFeature(window), SUBJECT_A))); assertValue( store - .queryCount( - Tck.query(NS_A), List.of(Tck.tuple(SUBJECT_A, Aggregation.count(), SLIDING_1M))) + .queryCount(Tck.query(NS_A), List.of(Tck.tuple(SUBJECT_A, Aggregation.count(), window))) .get(0), 1); assertValue( store - .queryCount( - Tck.query(NS_A), List.of(Tck.tuple(SUBJECT_B, Aggregation.count(), SLIDING_1M))) + .queryCount(Tck.query(NS_A), List.of(Tck.tuple(SUBJECT_B, Aggregation.count(), window))) .get(0), 0); } @@ -136,7 +139,7 @@ public void valuesIsolatedBySubject() { * count is exactly N, never lost to a read-modify-write race (NFR-6). */ public void concurrentApplyIsAtomic() throws Exception { - final Feature feature = countFeature(SLIDING_1M); + final Feature feature = countFeature(window); final int threads = 16; final CountDownLatch ready = new CountDownLatch(threads); final CountDownLatch fire = new CountDownLatch(1); @@ -159,8 +162,7 @@ public void concurrentApplyIsAtomic() throws Exception { } assertValue( store - .queryCount( - Tck.query(NS_A), List.of(Tck.tuple(SUBJECT_A, Aggregation.count(), SLIDING_1M))) + .queryCount(Tck.query(NS_A), List.of(Tck.tuple(SUBJECT_A, Aggregation.count(), window))) .get(0), threads); } diff --git a/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/DistinctStoreScenarios.java b/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/DistinctStoreScenarios.java index da8fbb1..9ad0ba2 100644 --- a/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/DistinctStoreScenarios.java +++ b/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/DistinctStoreScenarios.java @@ -3,7 +3,6 @@ import static com.codeheadsystems.velocity.testkit.tck.Tck.DIMENSION; import static com.codeheadsystems.velocity.testkit.tck.Tck.NS_A; -import static com.codeheadsystems.velocity.testkit.tck.Tck.SLIDING_1M; import static com.codeheadsystems.velocity.testkit.tck.Tck.SUBJECT_A; import static com.codeheadsystems.velocity.testkit.tck.Tck.SUBJECT_B; import static com.codeheadsystems.velocity.testkit.tck.Tck.assertValue; @@ -18,6 +17,7 @@ import com.codeheadsystems.velocity.spi.model.Exactness; import com.codeheadsystems.velocity.spi.model.Feature; import com.codeheadsystems.velocity.spi.model.Intent.DistinctIntent; +import com.codeheadsystems.velocity.spi.model.Window; import com.codeheadsystems.velocity.testkit.MutableClock; import java.util.List; import java.util.Objects; @@ -31,19 +31,23 @@ public final class DistinctStoreScenarios { private final DistinctStore store; private final MutableClock clock; + private final Window window; /** * @param store a fresh distinct-capable backend under test * @param clock the backend's controllable clock + * @param window a window the backend supports, exercised by these scenarios */ - public DistinctStoreScenarios(final DistinctStore store, final MutableClock clock) { + public DistinctStoreScenarios( + final DistinctStore store, final MutableClock clock, final Window window) { this.store = Objects.requireNonNull(store, "store"); this.clock = Objects.requireNonNull(clock, "clock"); + this.window = Objects.requireNonNull(window, "window"); } /** Apply distinct members then query returns the exact cardinality of the distinct set. */ public void applyThenQueryReturnsCardinality() { - final Feature feature = distinctFeature(SLIDING_1M); + final Feature feature = distinctFeature(window); store.applyDistinct( Tck.apply(NS_A), List.of( @@ -55,7 +59,7 @@ public void applyThenQueryReturnsCardinality() { store .queryDistinct( Tck.query(NS_A), - List.of(Tck.tuple(SUBJECT_A, Aggregation.distinct(DIMENSION), SLIDING_1M))) + List.of(Tck.tuple(SUBJECT_A, Aggregation.distinct(DIMENSION), window))) .get(0); assertValue(result, 3); assertThat(successValue(result).exactness()).isEqualTo(Exactness.EXACT); @@ -64,7 +68,7 @@ public void applyThenQueryReturnsCardinality() { /** Re-applying the same member does not increase cardinality. */ public void deDupesRepeatedMembers() { - final Feature feature = distinctFeature(SLIDING_1M); + final Feature feature = distinctFeature(window); store.applyDistinct( Tck.apply(NS_A), List.of( @@ -76,14 +80,14 @@ public void deDupesRepeatedMembers() { store .queryDistinct( Tck.query(NS_A), - List.of(Tck.tuple(SUBJECT_A, Aggregation.distinct(DIMENSION), SLIDING_1M))) + List.of(Tck.tuple(SUBJECT_A, Aggregation.distinct(DIMENSION), window))) .get(0), 2); } /** The value returned by apply already reflects the post-apply cardinality (read-your-write). */ public void applyResultReflectsCardinality() { - final Feature feature = distinctFeature(SLIDING_1M); + final Feature feature = distinctFeature(window); final ApplyResult first = store.applyDistinct( Tck.apply(NS_A), List.of(new DistinctIntent(feature, SUBJECT_A, member("alice")))); @@ -97,7 +101,7 @@ public void applyResultReflectsCardinality() { /** Distinct members for one subject do not leak into another subject's cardinality. */ public void valuesIsolatedBySubject() { - final Feature feature = distinctFeature(SLIDING_1M); + final Feature feature = distinctFeature(window); store.applyDistinct( Tck.apply(NS_A), List.of(new DistinctIntent(feature, SUBJECT_A, member("alice")))); @@ -105,7 +109,7 @@ public void valuesIsolatedBySubject() { store .queryDistinct( Tck.query(NS_A), - List.of(Tck.tuple(SUBJECT_B, Aggregation.distinct(DIMENSION), SLIDING_1M))) + List.of(Tck.tuple(SUBJECT_B, Aggregation.distinct(DIMENSION), window))) .get(0), 0); } diff --git a/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/PurgeScenarios.java b/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/PurgeScenarios.java index 45ac585..40003bc 100644 --- a/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/PurgeScenarios.java +++ b/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/PurgeScenarios.java @@ -3,7 +3,6 @@ import static com.codeheadsystems.velocity.testkit.tck.Tck.NS_A; import static com.codeheadsystems.velocity.testkit.tck.Tck.NS_B; -import static com.codeheadsystems.velocity.testkit.tck.Tck.SLIDING_1M; import static com.codeheadsystems.velocity.testkit.tck.Tck.SUBJECT_A; import static com.codeheadsystems.velocity.testkit.tck.Tck.SUBJECT_B; import static com.codeheadsystems.velocity.testkit.tck.Tck.assertValue; @@ -16,6 +15,7 @@ import com.codeheadsystems.velocity.spi.model.Intent.CountIntent; import com.codeheadsystems.velocity.spi.model.Namespace; import com.codeheadsystems.velocity.spi.model.Subject; +import com.codeheadsystems.velocity.spi.model.Window; import java.util.List; import java.util.Objects; @@ -27,17 +27,20 @@ public final class PurgeScenarios { private final CountStore store; + private final Window window; /** * @param store a fresh count-capable backend under test + * @param window a window the backend supports, exercised by these scenarios */ - public PurgeScenarios(final CountStore store) { + public PurgeScenarios(final CountStore store, final Window window) { this.store = Objects.requireNonNull(store, "store"); + this.window = Objects.requireNonNull(window, "window"); } /** {@code purge(ns, subject)} clears that subject only; other subjects survive. */ public void purgeSubjectClearsThatSubjectOnly() { - final Feature feature = countFeature(SLIDING_1M); + final Feature feature = countFeature(window); store.applyCount(Tck.apply(NS_A), List.of(new CountIntent(feature, SUBJECT_A))); store.applyCount(Tck.apply(NS_A), List.of(new CountIntent(feature, SUBJECT_B))); @@ -49,7 +52,7 @@ public void purgeSubjectClearsThatSubjectOnly() { /** {@code purge(ns, null)} clears the whole namespace; another namespace is untouched. */ public void purgeNamespaceClearsEntireNamespace() { - final Feature feature = countFeature(SLIDING_1M); + final Feature feature = countFeature(window); store.applyCount(Tck.apply(NS_A), List.of(new CountIntent(feature, SUBJECT_A))); store.applyCount(Tck.apply(NS_A), List.of(new CountIntent(feature, SUBJECT_B))); store.applyCount(Tck.apply(NS_B), List.of(new CountIntent(feature, SUBJECT_A))); @@ -63,8 +66,7 @@ public void purgeNamespaceClearsEntireNamespace() { private FeatureResult count(final Namespace namespace, final Subject subject) { return store - .queryCount( - Tck.query(namespace), List.of(Tck.tuple(subject, Aggregation.count(), SLIDING_1M))) + .queryCount(Tck.query(namespace), List.of(Tck.tuple(subject, Aggregation.count(), window))) .get(0); } } diff --git a/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/SumStoreScenarios.java b/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/SumStoreScenarios.java index 075a717..a835521 100644 --- a/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/SumStoreScenarios.java +++ b/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/SumStoreScenarios.java @@ -3,7 +3,6 @@ import static com.codeheadsystems.velocity.testkit.tck.Tck.NS_A; import static com.codeheadsystems.velocity.testkit.tck.Tck.NS_B; -import static com.codeheadsystems.velocity.testkit.tck.Tck.SLIDING_1M; import static com.codeheadsystems.velocity.testkit.tck.Tck.SUBJECT_A; import static com.codeheadsystems.velocity.testkit.tck.Tck.cents; import static com.codeheadsystems.velocity.testkit.tck.Tck.successValue; @@ -15,6 +14,7 @@ import com.codeheadsystems.velocity.spi.model.ApplyResult; import com.codeheadsystems.velocity.spi.model.Feature; import com.codeheadsystems.velocity.spi.model.Intent.SumIntent; +import com.codeheadsystems.velocity.spi.model.Window; import com.codeheadsystems.velocity.testkit.MutableClock; import java.math.BigDecimal; import java.util.List; @@ -28,19 +28,22 @@ public final class SumStoreScenarios { private final SumStore store; private final MutableClock clock; + private final Window window; /** * @param store a fresh sum-capable backend under test * @param clock the backend's controllable clock + * @param window a window the backend supports, exercised by these scenarios */ - public SumStoreScenarios(final SumStore store, final MutableClock clock) { + public SumStoreScenarios(final SumStore store, final MutableClock clock, final Window window) { this.store = Objects.requireNonNull(store, "store"); this.clock = Objects.requireNonNull(clock, "clock"); + this.window = Objects.requireNonNull(window, "window"); } /** Apply cents values then query returns their exact sum with scale 0. */ public void applyThenQueryReturnsExactSumCents() { - final Feature feature = sumFeature(SLIDING_1M); + final Feature feature = sumFeature(window); store.applySum( Tck.apply(NS_A), List.of( @@ -51,8 +54,7 @@ public void applyThenQueryReturnsExactSumCents() { successValue( store .querySum( - Tck.query(NS_A), - List.of(Tck.tuple(SUBJECT_A, Aggregation.sum(), SLIDING_1M))) + Tck.query(NS_A), List.of(Tck.tuple(SUBJECT_A, Aggregation.sum(), window))) .get(0)) .value(); assertThat(value).isEqualByComparingTo(cents(400)); @@ -62,7 +64,7 @@ public void applyThenQueryReturnsExactSumCents() { store .querySum( Tck.query(NS_A), - List.of(Tck.tuple(SUBJECT_A, Aggregation.sum(), SLIDING_1M))) + List.of(Tck.tuple(SUBJECT_A, Aggregation.sum(), window))) .get(0)) .asOf()) .isEqualTo(clock.instant()); @@ -70,7 +72,7 @@ public void applyThenQueryReturnsExactSumCents() { /** The value returned by apply already reflects the running sum (read-your-write). */ public void applyResultReflectsRunningSum() { - final Feature feature = sumFeature(SLIDING_1M); + final Feature feature = sumFeature(window); final ApplyResult first = store.applySum(Tck.apply(NS_A), List.of(new SumIntent(feature, SUBJECT_A, cents(500)))); assertThat(successValue(first.perFeature().get(0).result()).value()) @@ -86,7 +88,7 @@ public void applyResultReflectsRunningSum() { * Large cents sums are preserved exactly — well past a {@code long}-cents / double's safe range. */ public void bigDecimalCentsPreservedWithoutOverflow() { - final Feature feature = sumFeature(SLIDING_1M); + final Feature feature = sumFeature(window); final BigDecimal big = new BigDecimal("9000000000000000000"); // > Long.MAX_VALUE / 2 store.applySum( Tck.apply(NS_A), @@ -96,8 +98,7 @@ public void bigDecimalCentsPreservedWithoutOverflow() { successValue( store .querySum( - Tck.query(NS_A), - List.of(Tck.tuple(SUBJECT_A, Aggregation.sum(), SLIDING_1M))) + Tck.query(NS_A), List.of(Tck.tuple(SUBJECT_A, Aggregation.sum(), window))) .get(0)) .value(); assertThat(value).isEqualByComparingTo(big.add(big)); @@ -105,7 +106,7 @@ public void bigDecimalCentsPreservedWithoutOverflow() { /** A negative value (refund) reduces the sum. */ public void negativeValuesForRefunds() { - final Feature feature = sumFeature(SLIDING_1M); + final Feature feature = sumFeature(window); store.applySum( Tck.apply(NS_A), List.of( @@ -117,7 +118,7 @@ public void negativeValuesForRefunds() { store .querySum( Tck.query(NS_A), - List.of(Tck.tuple(SUBJECT_A, Aggregation.sum(), SLIDING_1M))) + List.of(Tck.tuple(SUBJECT_A, Aggregation.sum(), window))) .get(0)) .value()) .isEqualByComparingTo(cents(300)); @@ -125,7 +126,7 @@ public void negativeValuesForRefunds() { /** Sums in one namespace are invisible in another; the other reads a known zero. */ public void valuesIsolatedByNamespace() { - final Feature feature = sumFeature(SLIDING_1M); + final Feature feature = sumFeature(window); store.applySum(Tck.apply(NS_A), List.of(new SumIntent(feature, SUBJECT_A, cents(999)))); assertThat( @@ -133,7 +134,7 @@ public void valuesIsolatedByNamespace() { store .querySum( Tck.query(NS_B), - List.of(Tck.tuple(SUBJECT_A, Aggregation.sum(), SLIDING_1M))) + List.of(Tck.tuple(SUBJECT_A, Aggregation.sum(), window))) .get(0)) .value()) .isEqualByComparingTo(BigDecimal.ZERO); diff --git a/velocity-testkit/src/test/java/com/codeheadsystems/velocity/testkit/InMemoryVelocityBackendTck.java b/velocity-testkit/src/test/java/com/codeheadsystems/velocity/testkit/InMemoryVelocityBackendTck.java index 5b433af..b7b7eab 100644 --- a/velocity-testkit/src/test/java/com/codeheadsystems/velocity/testkit/InMemoryVelocityBackendTck.java +++ b/velocity-testkit/src/test/java/com/codeheadsystems/velocity/testkit/InMemoryVelocityBackendTck.java @@ -1,6 +1,8 @@ // SPDX-License-Identifier: BSD-3-Clause package com.codeheadsystems.velocity.testkit; +import com.codeheadsystems.velocity.spi.model.Window; +import com.codeheadsystems.velocity.spi.model.WindowType; import com.codeheadsystems.velocity.testkit.tck.CapabilityConformanceScenarios; import com.codeheadsystems.velocity.testkit.tck.CountStoreScenarios; import com.codeheadsystems.velocity.testkit.tck.DistinctStoreScenarios; @@ -9,6 +11,7 @@ import com.codeheadsystems.velocity.testkit.tck.SlidingScenarios; import com.codeheadsystems.velocity.testkit.tck.SumStoreScenarios; import com.codeheadsystems.velocity.testkit.tck.TumblingScenarios; +import java.time.Duration; import java.time.Instant; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -24,6 +27,14 @@ class InMemoryVelocityBackendTck { /** A deterministic, mid-minute/mid-hour instant so time-independent scenarios are stable. */ private static final Instant FIXED = Instant.parse("2026-07-18T12:00:30Z"); + /** + * A primary window and a distinct second window, both InMemory-supported, for the shared + * scenarios. + */ + private static final Window SLIDING_1M = new Window(Duration.ofMinutes(1), WindowType.SLIDING); + + private static final Window TUMBLING_1H = new Window(Duration.ofHours(1), WindowType.TUMBLING); + private InMemoryVelocityBackend backend; private MutableClock clock; @@ -37,32 +48,35 @@ void freshBackend() { class Count { @Test void applyThenQueryReturnsExactCount() { - new CountStoreScenarios(backend, clock).applyThenQueryReturnsExactCount(); + new CountStoreScenarios(backend, clock, SLIDING_1M, TUMBLING_1H) + .applyThenQueryReturnsExactCount(); } @Test void applyResultReflectsWriteReadYourWrite() { - new CountStoreScenarios(backend, clock).applyResultReflectsWriteReadYourWrite(); + new CountStoreScenarios(backend, clock, SLIDING_1M, TUMBLING_1H) + .applyResultReflectsWriteReadYourWrite(); } @Test void applyEmitsOneResultPerWindow() { - new CountStoreScenarios(backend, clock).applyEmitsOneResultPerWindow(); + new CountStoreScenarios(backend, clock, SLIDING_1M, TUMBLING_1H) + .applyEmitsOneResultPerWindow(); } @Test void valuesIsolatedByNamespace() { - new CountStoreScenarios(backend, clock).valuesIsolatedByNamespace(); + new CountStoreScenarios(backend, clock, SLIDING_1M, TUMBLING_1H).valuesIsolatedByNamespace(); } @Test void valuesIsolatedBySubject() { - new CountStoreScenarios(backend, clock).valuesIsolatedBySubject(); + new CountStoreScenarios(backend, clock, SLIDING_1M, TUMBLING_1H).valuesIsolatedBySubject(); } @Test void concurrentApplyIsAtomic() throws Exception { - new CountStoreScenarios(backend, clock).concurrentApplyIsAtomic(); + new CountStoreScenarios(backend, clock, SLIDING_1M, TUMBLING_1H).concurrentApplyIsAtomic(); } } @@ -70,27 +84,27 @@ void concurrentApplyIsAtomic() throws Exception { class Sum { @Test void applyThenQueryReturnsExactSumCents() { - new SumStoreScenarios(backend, clock).applyThenQueryReturnsExactSumCents(); + new SumStoreScenarios(backend, clock, SLIDING_1M).applyThenQueryReturnsExactSumCents(); } @Test void applyResultReflectsRunningSum() { - new SumStoreScenarios(backend, clock).applyResultReflectsRunningSum(); + new SumStoreScenarios(backend, clock, SLIDING_1M).applyResultReflectsRunningSum(); } @Test void bigDecimalCentsPreservedWithoutOverflow() { - new SumStoreScenarios(backend, clock).bigDecimalCentsPreservedWithoutOverflow(); + new SumStoreScenarios(backend, clock, SLIDING_1M).bigDecimalCentsPreservedWithoutOverflow(); } @Test void negativeValuesForRefunds() { - new SumStoreScenarios(backend, clock).negativeValuesForRefunds(); + new SumStoreScenarios(backend, clock, SLIDING_1M).negativeValuesForRefunds(); } @Test void valuesIsolatedByNamespace() { - new SumStoreScenarios(backend, clock).valuesIsolatedByNamespace(); + new SumStoreScenarios(backend, clock, SLIDING_1M).valuesIsolatedByNamespace(); } } @@ -98,22 +112,22 @@ void valuesIsolatedByNamespace() { class Distinct { @Test void applyThenQueryReturnsCardinality() { - new DistinctStoreScenarios(backend, clock).applyThenQueryReturnsCardinality(); + new DistinctStoreScenarios(backend, clock, SLIDING_1M).applyThenQueryReturnsCardinality(); } @Test void deDupesRepeatedMembers() { - new DistinctStoreScenarios(backend, clock).deDupesRepeatedMembers(); + new DistinctStoreScenarios(backend, clock, SLIDING_1M).deDupesRepeatedMembers(); } @Test void applyResultReflectsCardinality() { - new DistinctStoreScenarios(backend, clock).applyResultReflectsCardinality(); + new DistinctStoreScenarios(backend, clock, SLIDING_1M).applyResultReflectsCardinality(); } @Test void valuesIsolatedBySubject() { - new DistinctStoreScenarios(backend, clock).valuesIsolatedBySubject(); + new DistinctStoreScenarios(backend, clock, SLIDING_1M).valuesIsolatedBySubject(); } } @@ -187,18 +201,28 @@ void unsupportedWindowQueryIsDistinguishableFailure() { void supportedEmptyWindowReturnsKnownZero() { new CapabilityConformanceScenarios(backend).supportedEmptyWindowReturnsKnownZero(); } + + @Test + void unsupportedWindowApplyIsDistinguishableFailure() { + new CapabilityConformanceScenarios(backend).unsupportedWindowApplyIsDistinguishableFailure(); + } + + @Test + void mixedApplyPartiallyFailsOnUnsupportedWindow() { + new CapabilityConformanceScenarios(backend).mixedApplyPartiallyFailsOnUnsupportedWindow(); + } } @Nested class Purge { @Test void purgeSubjectClearsThatSubjectOnly() { - new PurgeScenarios(backend).purgeSubjectClearsThatSubjectOnly(); + new PurgeScenarios(backend, SLIDING_1M).purgeSubjectClearsThatSubjectOnly(); } @Test void purgeNamespaceClearsEntireNamespace() { - new PurgeScenarios(backend).purgeNamespaceClearsEntireNamespace(); + new PurgeScenarios(backend, SLIDING_1M).purgeNamespaceClearsEntireNamespace(); } } }