Skip to content

Conversation

@nguyenduy
Copy link
Contributor

@nguyenduy nguyenduy commented Dec 9, 2025

Summary by Sourcery

Migrate the monorepo test stack to Vitest 4 and stabilize browser/Storybook testing and coverage collection across services and UI packages.

Enhancements:

  • Refine Storybook Vitest config to use Vitest 4 APIs, Playwright browser provider, improved file watching, and stricter test/coverage exclusion rules for dist and coverage artifacts.
  • Introduce a shared @cellix/test-utils package with a makeNewableMock helper and adopt it across persistence and domain tests to simplify constructor-style mocking.
  • Adjust various test suites (SendGrid, OTEL builder, Mongo unit of work, repositories, event buses, GraphQL helpers, Twilio messaging, and integration tests) for Vitest 4 compatibility, better typing, and more robust mocking patterns.
  • Update GraphQL resolver helpers to simplify typings for field population helpers and align with current usage patterns.
  • Update UI component barrel exports to reference TypeScript/TSX sources to support Vitest-specific export conditions in testing.

Build:

  • Add a vitest-related catalog in pnpm-workspace.yaml and update package.json dependencies in multiple packages to use Vitest 4 (vitest, @vitest/coverage-v8, @vitest/browser-playwright) via the catalog.
  • Adjust Storybook/Vitest config TypeScript settings to include DOM libs for browser tests and align various packages' TypeScript versions with the monorepo baseline.
  • Update scripts and exports in @sthrift/ui-components and other packages to expose Vitest-specific entry points and remove older @vitest/browser references.

CI:

  • Split coverage collection scripts and pipeline steps into separate node and UI/Storybook runs (including affected-only variants) to reduce flakiness and resource contention in CI, and log ulimit for diagnostics.

Tests:

  • Refine Storybook-driven UI stories and GraphQL mocks (e.g., profile page, app container) to align with updated GraphQL schema and pagination structures.
  • Tighten test typings for event bus handlers and domain event handlers to improve type safety while using Vitest 4 mocks.
  • Ensure Mongo unit-of-work integration tests and other suites reset mocks between runs for isolation under the new test runner.

Chores:

  • Convert getAccountPlanReadRepository to a named function export for clearer typing and usage.
  • Small cleanup of Biome/ESLint suppressions and helper utilities in tests to match new mocking approach.

@nguyenduy nguyenduy requested a review from a team December 9, 2025 22:37
@nguyenduy nguyenduy linked an issue Dec 9, 2025 that may be closed by this pull request
@nguyenduy nguyenduy marked this pull request as draft December 9, 2025 22:37
@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Dec 9, 2025

Reviewer's Guide

Migrates the monorepo’s testing stack to Vitest 4, introduces a shared constructor-mocking helper, tightens Storybook/browser Vitest configuration for CI stability, aligns GraphQL stories and repository tests with new schemas and mocking patterns, and splits coverage into separate Node vs UI pipelines wired through package.json and Azure Pipelines.

Sequence diagram for MongoUnitOfWork transaction and integration events with Vitest 4 mocks

sequenceDiagram
  participant Test as TestScenario
  participant UnitOfWork as MongoUnitOfWork
  participant Mongoose as mongoose.connection
  participant RepoClass as TestRepoClass
  participant Repo as RepoMock
  participant IntegrationBus as IntegrationEventBus

  Test->>Mongoose: transaction(callback)
  activate Mongoose
  Mongoose->>UnitOfWork: callback(session)
  activate UnitOfWork
  UnitOfWork->>RepoClass: new RepoClass(Passport, model, typeConverter, bus, session)
  activate RepoClass
  RepoClass-->>UnitOfWork: Repo instance
  deactivate RepoClass
  UnitOfWork->>Repo: get(aggId)
  activate Repo
  Repo-->>UnitOfWork: AggregateRootMock
  UnitOfWork->>Repo: domainOperation(repo)
  Repo-->>UnitOfWork: domain changes and addIntegrationEvent
  UnitOfWork->>Repo: save(aggregate)
  Repo-->>UnitOfWork: saved
  UnitOfWork->>Repo: getIntegrationEvents()
  Repo-->>UnitOfWork: [event1, event2]
  loop for each event
    UnitOfWork->>IntegrationBus: dispatch(EventClass, payload)
    activate IntegrationBus
    IntegrationBus-->>UnitOfWork: resolved
    deactivate IntegrationBus
  end
  UnitOfWork-->>Mongoose: transaction completed
  deactivate UnitOfWork
  Mongoose-->>Test: resolved
  deactivate Mongoose

  rect rgb(255,220,220)
    Test->>Mongoose: transaction(callback)
    Mongoose->>UnitOfWork: callback(session)
    UnitOfWork->>RepoClass: new RepoClass(...)
    UnitOfWork->>Repo: domainOperation(repo)
    UnitOfWork->>Repo: getIntegrationEvents()
    Repo-->>UnitOfWork: [event1, event2]
    UnitOfWork->>IntegrationBus: dispatch(event1)
    IntegrationBus-->>UnitOfWork: rejected Error
    UnitOfWork-->>Test: propagate Error
  end
Loading

Class diagram for shared Vitest constructor mocking and related tests

classDiagram
  class TestUtils {
    +makeNewableMock~TArgs, TResult~(impl TArgs... TResult) MockFunction
  }

  class MockFunction {
    <<function>>
  }

  class OtelBuilderTests {
    +MockExporter
    +traceExporterMock
    +metricExporterMock
    +logExporterMock
  }

  class TwilioTests {
    +mockConversationInstance
    +mockClient
    +TwilioConstructor
  }

  class AccountPlanRepositoryTests {
    +makeAccountPlanDoc()
    +setupAccountPlanRepo()
  }

  class ReservationRequestRepositoryTests {
    +makeReservationRequestDoc()
    +setupReservationRequestRepo()
  }

  class ConversationRepositoryTests {
    +makeConversationDoc()
    +setupConversationRepo()
  }

  class AdminUserRepositoryTests {
    +makeAdminUserDoc()
    +setupAdminUserRepo()
  }

  class PersonalUserRepositoryTests {
    +makePersonalUserDoc()
    +setupPersonalUserRepo()
  }

  class ServiceMessagingTwilio {
    +constructor(accountSid string, authToken string)
  }

  class OtelBuilder {
    +buildExporters(enable bool) Exporters
  }

  class Exporters {
    +traceExporter any
    +metricExporter any
    +logExporter any
  }

  TestUtils ..> MockFunction : returns
  OtelBuilderTests ..> TestUtils : uses makeNewableMock
  TwilioTests ..> TestUtils : uses makeNewableMock
  AccountPlanRepositoryTests ..> TestUtils : uses makeNewableMock
  ReservationRequestRepositoryTests ..> TestUtils : uses makeNewableMock
  ConversationRepositoryTests ..> TestUtils : uses makeNewableMock
  AdminUserRepositoryTests ..> TestUtils : uses makeNewableMock
  PersonalUserRepositoryTests ..> TestUtils : uses makeNewableMock

  OtelBuilderTests ..> OtelBuilder : builds exporters
  TwilioTests ..> ServiceMessagingTwilio : constructs service
  OtelBuilder ..> Exporters : returns
  TwilioTests ..> Exporters : verifies exporters via mocks
Loading

Class diagram for Storybook Vitest configuration with Vitest 4 browser Playwright

classDiagram
  class StorybookVitestConfigOptions {
    +storybookDirRelativeToPackage string
    +setupFiles string[]
    +browsers BrowserInstanceConfig[]
    +additionalCoverageExclude string[]
  }

  class BrowserInstanceConfig {
    +browser string
  }

  class VitestConfigModule {
    +createStorybookVitestConfig(pkgDirname string, opts StorybookVitestConfigOptions) UserConfig
  }

  class UserConfig {
    +resolve any
    +server any
    +test VitestTestConfig
  }

  class VitestTestConfig {
    +exclude string[]
    +globals bool
    +retry number
    +testTimeout number
    +fileParallelism bool
    +projects any[]
    +coverage CoverageConfig
    +watch bool
    +isolate bool
  }

  class CoverageConfig {
    +exclude string[]
  }

  class BaseConfigModule {
    +baseConfig UserConfig
  }

  class StorybookAddonVitest {
    +storybookTest(config any) any
  }

  class VitestBrowserPlaywright {
    +playwright() any
  }

  VitestConfigModule ..> StorybookVitestConfigOptions : uses
  VitestConfigModule ..> UserConfig : returns
  VitestConfigModule ..> VitestTestConfig : configures
  VitestTestConfig ..> CoverageConfig : contains
  VitestConfigModule ..> BaseConfigModule : mergeConfig
  VitestConfigModule ..> StorybookAddonVitest : uses storybookTest
  VitestConfigModule ..> VitestBrowserPlaywright : uses playwright
  StorybookVitestConfigOptions "1" *-- "*" BrowserInstanceConfig
Loading

Flow diagram for split Node vs UI coverage in CI with Vitest 4

flowchart TD
  A_Developer[Developer]
Loading

File-Level Changes

Change Details Files
Upgrade Vitest and related tooling to 4.x and centralize versions via the workspace catalog.
  • Bump vitest and @vitest/coverage-v8 to catalog-managed 4.x versions across root, apps, and packages.
  • Add @vitest/browser-playwright as a dependency where browser tests are used and wire it into config.
  • Update @amiceli/vitest-cucumber to 6.x to stay compatible with Vitest 4.
package.json
apps/docs/package.json
apps/ui-sharethrift/package.json
packages/cellix/ui-core/package.json
packages/cellix/vitest-config/package.json
packages/sthrift/ui-components/package.json
packages/sthrift/messaging-service-twilio/package.json
packages/sthrift/mock-messaging-server/package.json
pnpm-workspace.yaml
Refactor Storybook/Vitest browser config to use @vitest/browser-playwright, control file watching, and improve CI behavior.
  • Change createStorybookVitestConfig to return a Vite UserConfig, merging baseConfig with a new storybookConfig.
  • Switch browser provider from string 'playwright' to playwright() from @vitest/browser-playwright.
  • Add resolve.conditions, stricter test.exclude patterns, server.watch.ignored, and watch toggling based on CI.
  • Preserve coverage exclusions while making them configurable via additionalCoverageExclude.
packages/cellix/vitest-config/src/configs/storybook.config.ts
packages/cellix/vitest-config/tsconfig.json
Introduce a shared test-utils package to provide a reusable makeNewableMock helper for constructor-compatible mocks.
  • Create @cellix/test-utils package with a makeNewableMock helper that returns functions usable with new for Vitest 4 ctor mocking.
  • Wire the new package into TypeScript build and linting with its own tsconfig and scripts.
  • Add @cellix/test-utils as a dependency where repositories and services need constructor mocks.
packages/cellix/test-utils/package.json
packages/cellix/test-utils/src/index.ts
packages/cellix/test-utils/tsconfig.json
packages/cellix/test-utils/readme.md
packages/sthrift/service-otel/package.json
packages/sthrift/persistence/package.json
Adapt various mocks and tests (SendGrid, OTEL, Twilio, Mongo unit of work, repositories, event buses, handle-event) to Vitest 4’s constructor and typing requirements.
  • Update OTEL exporter tests to define MockExporter inside vi.mock, add a constructor-safe factory, and adjust instanceof and arg assertions.
  • Refactor Twilio tests to use makeNewableMock-style constructors for the client and conversations API, and use a safe console.warn spy implementation.
  • Change AccountPlan, reservation-request, conversation, admin-user, and personal-user repository tests to use makeNewableMock for model constructors and add helpers (e.g., getMongoDataSource) for accessing internals.
  • Rework MongoUnitOfWork tests: remove overridden getIntegrationEvents overrides on test classes, introduce helper factories for domain operations with/without events, and centralize integration event dispatch assertions via a dispatchMock.
  • Tighten event-bus and handle-event tests by giving handlers explicit function types, ensuring async handlers return Promises, and adding necessary casts when registering.
  • Add vi.restoreAllMocks in MongoUnitOfWork integration tests to avoid cross-test interference.
packages/sthrift/service-sendgrid/src/sendgrid.test.ts
packages/sthrift/service-otel/src/otel-builder.test.ts
packages/sthrift/messaging-service-twilio/src/index.test.ts
packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-unit-of-work.test.ts
packages/cellix/mongoose-seedwork/tests/integration/mongo-unit-of-work.integration.test.ts
packages/sthrift/persistence/src/datasources/readonly/account-plan/account-plan/account-plan.read-repository.test.ts
packages/sthrift/persistence/src/datasources/domain/account-plan/account-plan/account-plan.repository.test.ts
packages/sthrift/persistence/src/datasources/domain/reservation-request/reservation-request/reservation-request.repository.test.ts
packages/sthrift/persistence/src/datasources/domain/conversation/conversation/conversation.repository.test.ts
packages/sthrift/persistence/src/datasources/domain/user/admin-user/admin-user.repository.test.ts
packages/sthrift/persistence/src/datasources/domain/user/personal-user/personal-user.repository.test.ts
packages/cellix/event-bus-seedwork-node/src/node-event-bus.test.ts
packages/cellix/event-bus-seedwork-node/src/in-proc-event-bus.test.ts
packages/cellix/domain-seedwork/src/domain-seedwork/handle-event.test.ts
Align GraphQL-facing UI stories and helpers with updated schema shapes and listing queries.
  • Update ShareThrift profile stories to use currentUser and myListingsAll{items,total,page,pageSize} instead of deprecated fields, adding variables to the listings query.
  • Add a reusable userIsAdminMockRequest helper for mocking UseUserIsAdminDocument results in profile stories.
  • Adjust App.container stories to mock currentUserAndCreateIfNotExists for authenticated-not-completed-onboarding scenario.
  • Remove unnecessary biome ignores in resolver helpers and keep runtime behavior intact.
apps/ui-sharethrift/src/components/layouts/home/account/profile/stories/ProfilePage.stories.tsx
apps/ui-sharethrift/src/App.container.stories.tsx
packages/sthrift/graphql/src/schema/resolver-helper.ts
Expose source entrypoints and TypeScript paths for UI components to better support Vitest/browser testing.
  • Change ui-components barrel exports to reference .tsx source files rather than compiled .js outputs for tests.
  • Add a vitest export condition in @sthrift/ui-components so tests can import from src/index.ts while builds still consume dist.
  • Keep type exports pointing at dist/src/index.d.ts to maintain type consumers’ behavior.
packages/sthrift/ui-components/src/index.ts
packages/sthrift/ui-components/package.json
Split coverage runs into Node vs UI pipelines and wire them into root scripts and Azure Pipelines to reduce open-file pressure and flakiness.
  • Replace the single test:coverage script with separate test:coverage:node and test:coverage:ui commands, plus affected-only variants, and use them from test:coverage and merge scripts.
  • Update the monorepo build stage YAML to print ulimit, run Node and UI coverage separately for affected PR builds, and then merge LCOV reports.
  • Ensure coverage scripts use Turbo filters to exclude/include UI packages appropriately.
package.json
build-pipeline/core/monorepo-build-stage.yml
Minor cleanups and API tweaks in persistence and GraphQL helpers to support testing.
  • Convert getAccountPlanReadRepository from a const arrow to a named function returning AccountPlanReadRepository for better typing and testability.
  • Tighten models/passport helper construction in read-repository tests to avoid relying on unused parts of ModelsContext.
  • Remove redundant biome-ignore comments in resolver helpers while preserving any casts where necessary.
packages/sthrift/persistence/src/datasources/readonly/account-plan/account-plan/account-plan.read-repository.ts
packages/sthrift/persistence/src/datasources/readonly/account-plan/account-plan/account-plan.read-repository.test.ts
packages/sthrift/graphql/src/schema/resolver-helper.ts

Assessment against linked issues

Issue Objective Addressed Explanation
#245 Upgrade Vitest and related tooling to 4.x via the pnpm workspace catalog and ensure all workspace packages use the catalog-managed Vitest version without overrides.
#245 Update test configuration, mocks, and test code (including Storybook/browser-based tests) to be compatible with Vitest 4 so that backend, frontend, and Storybook tests pass.
#245 Preserve SerenityJS testing behavior and coverage after the Vitest 4 upgrade.

Possibly linked issues

  • #: PR implements the Vitest 4.x upgrade, workspace catalog usage, test fixes, and CI/storybook adjustments requested by the issue.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • The MongoUnitOfWork tests contain several repeated patterns for building custom domain operations that create aggregates and add integration events; consider extracting small helper functions (e.g. makeOpWithEvents(...)) to reduce duplication and make each scenario easier to read and maintain.
  • There are a number of as unknown as ... and as MockInstance casts around mocked event buses and handlers (e.g. in the MongoUnitOfWork and event bus tests); introducing small typed factory helpers for these mocks would tighten type-safety and avoid the need for broad casts in the test bodies.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The MongoUnitOfWork tests contain several repeated patterns for building custom domain operations that create aggregates and add integration events; consider extracting small helper functions (e.g. `makeOpWithEvents(...)`) to reduce duplication and make each scenario easier to read and maintain.
- There are a number of `as unknown as ...` and `as MockInstance` casts around mocked event buses and handlers (e.g. in the MongoUnitOfWork and event bus tests); introducing small typed factory helpers for these mocks would tighten type-safety and avoid the need for broad casts in the test bodies.

## Individual Comments

### Comment 1
<location> `packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-unit-of-work.test.ts:146` </location>
<code_context>
     });
   });

   Scenario('Domain operation with no events, completes successfully', ({ Given, When, Then }) => {
     Given('a domain operation that emits no domain or integration events', () => {
-      repoInstance.getIntegrationEvents = vi.fn(() => []);
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting shared helpers for domain operations, repo/model setup, and a typed dispatch mock to remove duplication and clarify the tests’ intent while keeping existing behavior.

You can keep all the new behaviors while substantially reducing complexity and duplication by extracting a few small helpers and tightening the typing of your mocks.

### 1. Factor out domain operation factories

All `domainOperation`, `customDomainOp`, `customDomainOp2`, and `customDomainOp3` share the same structure: load aggregate → mutate → (optionally) add events → save.

You can encapsulate that in a couple of helpers:

```ts
type RepoOp = (repo: RepoMock) => Promise<void>;
type RepoOpMock = RepoOp & MockInstance;

function makeDomainOpWithoutEvents(): RepoOpMock {
  const op: RepoOp = async (repo) => {
    const aggregate = await repo.get('agg-1');
    aggregate.foo = 'new-foo';
    await repo.save(aggregate);
  };
  return vi.fn(op) as RepoOpMock;
}

function makeDomainOpWithEvents(events: TestEvent[]): RepoOpMock {
  const op: RepoOp = async (repo) => {
    const aggregate = await repo.get('agg-1');
    aggregate.foo = 'new-foo';
    for (const e of events) {
      aggregate.addIntegrationEvent(TestEvent, e.payload);
    }
    await repo.save(aggregate);
  };
  return vi.fn(op) as RepoOpMock;
}
```

Then in scenarios:

```ts
let domainOperation: RepoOpMock;

BeforeEachScenario(() => {
  // ...
  domainOperation = makeDomainOpWithoutEvents();
});

Scenario('Domain operation emits integration events, all dispatch succeed', ({ Given, When, Then }) => {
  let event1: TestEvent;
  let event2: TestEvent;
  Given('integration events are emitted during the domain operation', () => {
    event1 = new TestEvent('id'); event1.payload = { foo: 'bar1' };
    event2 = new TestEvent('id'); event2.payload = { foo: 'bar2' };
    domainOperation = makeDomainOpWithEvents([event1, event2]);
  });
  // ...
});
```

This removes `customDomainOp`, `customDomainOp2`, `customDomainOp3` and the repeated inline async functions while preserving behavior.

---

### 2. Strongly-type the dispatch mock once

Instead of repeatedly casting `integrationEventBus.dispatch` to `MockInstance`, define a mock type alias and use it consistently:

```ts
type DispatchMock = MockInstance<
  [new (...args: any[]) => DomainSeedwork.DomainEvent<any>, any],
  Promise<void>
>;

let dispatchMock: DispatchMock;

BeforeEachScenario(() => {
  dispatchMock = vi.fn() as DispatchMock;
  integrationEventBus = {
    dispatch: dispatchMock,
    register: vi.fn(),
  } as unknown as DomainSeedwork.EventBus;

  // ...
});
```

Then in scenarios you can avoid the `unknown` cast gymnastics:

```ts
When('the transaction completes successfully', async () => {
  domainOperation.mockClear();
  dispatchMock.mockClear();
  await unitOfWork.withTransaction(Passport, domainOperation);
});

Then('all integration events are dispatched after the transaction commits', () => {
  expect(dispatchMock).toHaveBeenCalledTimes(2);
  expect(dispatchMock).toHaveBeenNthCalledWith(1, event1.constructor, event1.payload);
  expect(dispatchMock).toHaveBeenNthCalledWith(2, event2.constructor, event2.payload);
});
```

This keeps the same assertions but centralizes the casting and improves readability.

---

### 3. Extract repo & model setup helpers instead of reconfiguring per scenario

You’re repeating:

- `repoInstance.getIntegrationEvents = vi.fn(() => []) as ...;`
- `repoInstance.get = vi.fn().mockResolvedValue(new AggregateRootMock(...));`
- `repoInstance.save = vi.fn().mockResolvedValue(undefined);`
- Re-setup of `mockModel.findById` after `vi.clearAllMocks()`.

Create small helpers:

```ts
function setupMockModelReturningAggregate() {
  (mockModel.findById as MockInstance).mockReturnValue({
    exec: vi.fn().mockResolvedValue({
      _id: 'agg-1',
      foo: 'old-foo',
    }),
  });
}

function setupRepoNoEvents() {
  repoInstance.getIntegrationEvents = vi.fn(() => []) as typeof repoInstance.getIntegrationEvents;
  repoInstance.get = vi.fn().mockResolvedValue(
    new AggregateRootMock(
      {
        id: 'agg-1',
        foo: 'old-foo',
        createdAt: new Date(),
        updatedAt: new Date(),
        schemaVersion: '1',
      } as PropType,
      {},
    ),
  );
  repoInstance.save = vi.fn().mockResolvedValue(undefined);
}
```

Use them in `BeforeEachScenario` and scenarios:

```ts
BeforeEachScenario(() => {
  // ... create mockModel, repoInstance, unitOfWork, etc.
  vi.clearAllMocks();

  vi.spyOn(mongoose.connection, 'transaction').mockImplementation(async cb =>
    cb({} as ClientSession),
  );

  setupMockModelReturningAggregate();
});

Scenario('Domain operation with no events, completes successfully', ({ Given, When, Then }) => {
  Given('a domain operation that emits no domain or integration events', () => {
    setupRepoNoEvents();
  });
  // ...
});
```

This removes duplicated plumbing and keeps each scenario’s `Given` focused on behavior.

---

### 4. Simplify repo wiring assertions

You currently assert individual internal properties of the repo instance. You can keep the safety of checking that `withTransaction` passes a correctly-constructed repo without hard-coding internal structure:

```ts
Then('the transaction is committed and no events are dispatched', () => {
  expect(domainOperation).toHaveBeenCalled();
  const repoArg = domainOperation.mock.calls[0]?.[0];

  expect(repoArg).toBeInstanceOf(TestRepoClass);
  expect((repoArg as TestRepoClass).model).toBe(mockModel);
  expect((repoArg as TestRepoClass).typeConverter).toBe(typeConverter);
  expect((repoArg as TestRepoClass).bus).toBe(eventBus);
  expect((repoArg as TestRepoClass).session).toStrictEqual(session);
  expect(dispatchMock).not.toHaveBeenCalled();
});
```

Or, if you want to reduce coupling further:

```ts
expect(domainOperation).toHaveBeenCalledWith(expect.any(TestRepoClass));
```

The key is you don’t need to inspect every internal field of `TestRepoClass` in every scenario; one focused scenario can check wiring, and others can just assert `expect.any(TestRepoClass)` plus event behavior.
</issue_to_address>

### Comment 2
<location> `packages/sthrift/service-otel/src/otel-builder.test.ts:22` </location>
<code_context>
-  const metricExporterMock = vi.fn((args) => Object.assign(new MetricExporter(), { __args: args }));
-  const logExporterMock = vi.fn((args) => Object.assign(new LogExporter(), { __args: args }));
-  // Expose mocks and classes for test access
+  // Class-based mocks that record constructor args
+  type ExporterArgs = Record<string, unknown>;
+  class TraceExporter {
</code_context>

<issue_to_address>
**issue (complexity):** Consider reverting to `vi.fn`-based constructor mocks with inner classes so you can keep `instanceof` and arg assertions while avoiding custom `mock.calls` state and helpers.

The new class/static `mock.calls` setup duplicates Vitest’s call tracking and makes the mock module harder to reason about. You can keep `instanceof` semantics and constructor arg assertions while simplifying back to `vi.fn`-based constructor mocks.

A possible simplification inside the `vi.mock` factory:

```ts
vi.mock('@azure/monitor-opentelemetry-exporter', () => {
  class TraceExporter {
    constructor(public __args: Record<string, unknown>) {}
  }
  class MetricExporter {
    constructor(public __args: Record<string, unknown>) {}
  }
  class LogExporter {
    constructor(public __args: Record<string, unknown>) {}
  }

  const AzureMonitorTraceExporter = vi
    .fn<[], InstanceType<typeof TraceExporter>>()
    .mockImplementation((args: Record<string, unknown>) => new TraceExporter(args));

  const AzureMonitorMetricExporter = vi
    .fn<[], InstanceType<typeof MetricExporter>>()
    .mockImplementation((args: Record<string, unknown>) => new MetricExporter(args));

  const AzureMonitorLogExporter = vi
    .fn<[], InstanceType<typeof LogExporter>>()
    .mockImplementation((args: Record<string, unknown>) => new LogExporter(args));

  return {
    AzureMonitorTraceExporter,
    AzureMonitorMetricExporter,
    AzureMonitorLogExporter,
    __test: {
      traceExporterMock: AzureMonitorTraceExporter,
      metricExporterMock: AzureMonitorMetricExporter,
      logExporterMock: AzureMonitorLogExporter,
      TraceExporter,
      MetricExporter,
      LogExporter,
    },
  };
});
```

Then the test setup and assertions become simpler and rely on Vitest’s built-in tracking:

```ts
let traceExporterMock: ReturnType<typeof vi.fn>;
let metricExporterMock: ReturnType<typeof vi.fn>;
let logExporterMock: ReturnType<typeof vi.fn>;

BeforeEachScenario(async () => {
  // ...
  const azureModule = await import('@azure/monitor-opentelemetry-exporter') as any;
  traceExporterMock = azureModule.__test.traceExporterMock;
  metricExporterMock = azureModule.__test.metricExporterMock;
  logExporterMock = azureModule.__test.logExporterMock;

  traceExporterMock.mockClear();
  metricExporterMock.mockClear();
  logExporterMock.mockClear();
});
```

And the expectations:

```ts
expect(exporters.traceExporter).toBeInstanceOf(TraceExporter);
expect(traceExporterMock).toHaveBeenCalledWith({ connectionString: connStr });

expect((exporters.traceExporter as any).__args.connectionString).toBe(connStr);
```

This keeps:

- `instanceof` checks via the inner classes,
- per-instance `__args` for extra safety,
- constructor-argument assertions via `toHaveBeenCalledWith`,

while removing custom `mock.calls` arrays, manual resets, and the extra `clearMocks` helper.
</issue_to_address>

### Comment 3
<location> `packages/sthrift/persistence/src/datasources/readonly/account-plan/account-plan/account-plan.read-repository.test.ts:9` </location>
<code_context>
-	AccountPlanConverter: vi.fn().mockImplementation(() => ({
-		toDomain: vi.fn((doc) => ({ id: doc._id, name: doc.name })),
-	})),
+	// biome-ignore lint/complexity/useArrowFunction: Constructor function must be a regular function
+	AccountPlanConverter: vi.fn(function() {
+		return {
</code_context>

<issue_to_address>
**issue (complexity):** Consider simplifying the mocks and model context so you can remove lint ignores, heavy casting, and unused nested factories while keeping test behavior unchanged.

You can keep all behavior while simplifying the mocks and removing lint ignores / heavy casting.

### 1. Simplify constructor mocks and drop lint suppression

If `AccountPlanConverter` / `AccountPlanDataSourceImpl` don’t need to be used with `new` in these tests, you can revert to a simpler arrow-based implementation:

```ts
vi.mock('../../../domain/account-plan/account-plan/account-plan.domain-adapter.ts', () => ({
  AccountPlanConverter: vi.fn().mockImplementation(() => ({
    toDomain: vi.fn((doc) => ({ id: doc._id, name: doc.name })),
  })),
}));

vi.mock('../account-plan/account-plan.data.ts', () => ({
  AccountPlanDataSourceImpl: vi.fn().mockImplementation(() => ({
    find: vi.fn(),
    findById: vi.fn(),
    findOne: vi.fn(),
  })),
}));
```

If production code requires calling them with `new`, you can still avoid `biome-ignore` by using a regular function in `mockImplementation` (not as the top-level callback):

```ts
vi.mock('../../../domain/account-plan/account-plan/account-plan.domain-adapter.ts', () => ({
  AccountPlanConverter: vi.fn().mockImplementation(function () {
    return {
      toDomain: vi.fn((doc) => ({ id: doc._id, name: doc.name })),
    };
  }),
}));
```

Same shape, no lint ignore needed.

### 2. Slim down `makeModelsContext` to what the test actually uses

Since `AccountPlanDataSourceImpl` is fully mocked and other models are unused, you can keep the context minimal and avoid the `unknown` cast:

```ts
function makeModelsContext(): ModelsContext {
  return {
    AccountPlan: {
      // Still a function so it’s “model-like”, but simple
      AccountPlanModel: vi.fn(() => ({})),
    },
  } as ModelsContext;
}
```

If `ModelsContext` requires other properties at type level but they’re unused in behavior, you can stub them as `{} as any` without adding nested factories:

```ts
function makeModelsContext(): ModelsContext {
  return {
    User: {} as any,
    Listing: {} as any,
    Conversation: {} as any,
    ReservationRequest: {} as any,
    Role: {} as any,
    AppealRequest: {} as any,
    AccountPlan: {
      AccountPlanModel: vi.fn(() => ({})),
    },
  } as ModelsContext;
}
```

This keeps all functionality intact while reducing verbosity, nested mocks, and the need for `as unknown as ModelsContext`.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

nguyenduy and others added 22 commits December 10, 2025 08:55
@nguyenduy
Copy link
Contributor Author

@sourcery-ai review

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • In createStorybookVitestConfig, test.watch is hard-disabled (watch: false), which may make local Storybook/Vitest runs less ergonomic; consider toggling this based on the same isCI flag you use for retries and fileParallelism so watch mode remains available during local development.
  • In the AccountPlan read repository tests you repeatedly cast repository to { mongoDataSource: ... } to reach mongoDataSource; extracting a small typed helper (e.g. getMongoDataSource(repository)) would remove the duplicated casts and make the intent clearer.
  • Several tests now rely on explicit as any/unknown casts for mocks (e.g. OTEL exporter mocks, Mongo unit-of-work helpers); where possible, tightening these with shared mock types or helper factories would reduce the surface area for type regressions while keeping the Vitest v4 constructor patterns.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `createStorybookVitestConfig`, `test.watch` is hard-disabled (`watch: false`), which may make local Storybook/Vitest runs less ergonomic; consider toggling this based on the same `isCI` flag you use for retries and `fileParallelism` so watch mode remains available during local development.
- In the AccountPlan read repository tests you repeatedly cast `repository` to `{ mongoDataSource: ... }` to reach `mongoDataSource`; extracting a small typed helper (e.g. `getMongoDataSource(repository)`) would remove the duplicated casts and make the intent clearer.
- Several tests now rely on explicit `as any`/`unknown` casts for mocks (e.g. OTEL exporter mocks, Mongo unit-of-work helpers); where possible, tightening these with shared mock types or helper factories would reduce the surface area for type regressions while keeping the Vitest v4 constructor patterns.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@nguyenduy
Copy link
Contributor Author

@sourcery-ai review

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • In storybook.config.ts, the CI detection was narrowed from process.env['CI'] === 'true' || process.env['TF_BUILD'] === 'True' to only checking TF_BUILD; if these configs are ever run outside Azure Pipelines (e.g., local CI=true or other CI providers), you may want to keep the generic CI flag to avoid changing retry/parallelism behavior unexpectedly.
  • The new test:coverage:* scripts split node and UI coverage nicely, but test:coverage:merge still calls test:coverage (which runs both phases) rather than the more granular test:coverage:node/ui; consider aligning :merge and :merge:affected to use the same split pattern to avoid accidental duplication or divergence between full and affected coverage workflows.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `storybook.config.ts`, the CI detection was narrowed from `process.env['CI'] === 'true' || process.env['TF_BUILD'] === 'True'` to only checking `TF_BUILD`; if these configs are ever run outside Azure Pipelines (e.g., local `CI=true` or other CI providers), you may want to keep the generic `CI` flag to avoid changing retry/parallelism behavior unexpectedly.
- The new `test:coverage:*` scripts split node and UI coverage nicely, but `test:coverage:merge` still calls `test:coverage` (which runs both phases) rather than the more granular `test:coverage:node/ui`; consider aligning `:merge` and `:merge:affected` to use the same split pattern to avoid accidental duplication or divergence between full and affected coverage workflows.

## Individual Comments

### Comment 1
<location> `packages/cellix/vitest-config/src/configs/storybook.config.ts:23` </location>
<code_context>
-      },
-    },
-  }));
+	const isCI = process.env['TF_BUILD'] === 'True';

-  return mergeConfig(base, defineConfig({}));
</code_context>

<issue_to_address>
**question (bug_risk):** The CI detection now only checks TF_BUILD, which may miss CI runs outside Azure DevOps.

This change means Storybook/Vitest will no longer detect CI in environments like GitHub Actions (where `CI='true'`), causing them to run with local-style settings (lower retries, shorter timeouts, parallel files, watch mode). If you want CI behaviour in any CI system, consider something like:

```ts
const isCI = process.env['TF_BUILD'] === 'True' || process.env['CI'] === 'true';
```

and let each pipeline decide which env vars to set.
</issue_to_address>

### Comment 2
<location> `packages/cellix/vitest-config/src/configs/storybook.config.ts:37-38` </location>
<code_context>
+				ignored: ['**/dist/**', '**/coverage/**', '**/node_modules/**'],
+			},
+		},
+		test: {
+			// Prevent Vite/Vitest from scanning/transpiling build artifacts and coverage temp files.
+			// This greatly reduces the number of open files during coverage runs in CI.
+			exclude: ['**/dist/**', '**/coverage/**', '**/coverage/.tmp/**'],
</code_context>

<issue_to_address>
**suggestion (performance):** Overriding `test.exclude` without including Vitest defaults may unintentionally re-include some files (e.g. node_modules) in test scanning.

Vitest’s `test.exclude` has a non-empty default (node_modules, dist, etc.). Setting it directly replaces that list instead of extending it, which can increase test scan I/O and counteract the goal of reducing open files. Consider merging with `configDefaults.exclude`, for example:

```ts
import { defineConfig, configDefaults } from 'vitest/config';

export default defineConfig({
  test: {
    exclude: [
      ...configDefaults.exclude,
      '**/dist/**',
      '**/coverage/**',
      '**/coverage/.tmp/**',
    ],
  },
});
```

This preserves Vitest’s defaults while adding your extra exclusions.

Suggested implementation:

```typescript
import { defineConfig, configDefaults } from 'vitest/config';

```

```typescript
		test: {
			// Prevent Vite/Vitest from scanning/transpiling build artifacts and coverage temp files.
			// This greatly reduces the number of open files during coverage runs in CI.
			// Extend Vitest's default excludes instead of replacing them outright.
			exclude: [
				...configDefaults.exclude,
				'**/dist/**',
				'**/coverage/**',
				'**/coverage/.tmp/**',
			],

```

If this file currently re-exports or merges other shared Vitest config pieces, ensure they do not also override `test.exclude` in a conflicting way. If the import from `'vitest/config'` is structured differently (e.g. `import * as vitestConfig`), adjust the added `configDefaults` import accordingly.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@nguyenduy
Copy link
Contributor Author

@sourcery-ai review

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • In the new Storybook Vitest config, isCI now only checks TF_BUILD; if you ever run these tests outside Azure DevOps, consider centralizing CI detection (e.g., including CI or a shared helper) so behaviour like retries, timeouts, and fileParallelism stays consistent across environments.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In the new Storybook Vitest config, `isCI` now only checks `TF_BUILD`; if you ever run these tests outside Azure DevOps, consider centralizing CI detection (e.g., including `CI` or a shared helper) so behaviour like retries, timeouts, and fileParallelism stays consistent across environments.

## Individual Comments

### Comment 1
<location> `packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-unit-of-work.test.ts:35` </location>
<code_context>

 class TestEvent extends DomainSeedwork.CustomDomainEventImpl<{ foo: string }> {}

+// Helper types and factories for domain operations
+type RepoOp = (repo: RepoMock) => Promise<void>;
+type RepoOpMock = RepoOp & MockInstance;
</code_context>

<issue_to_address>
**issue (complexity):** Consider simplifying the new test helpers and mocks by inlining scenario-specific setup and using the existing classes/mocks directly instead of extra wrapper types and aliases.

You can keep the new behavior while trimming a lot of the added machinery. The main win is to remove indirection that’s only used in one or two places.

### 1. Drop `RepoOp` / `RepoOpMock` / `makeDomainOp*` indirection

The test-specific domain operations are simple and only used per-scenario. You can inline them or use a tiny local helper without type aliases:

```ts
// Before
type RepoOp = (repo: RepoMock) => Promise<void>;
type RepoOpMock = RepoOp & MockInstance;

function makeDomainOpWithoutEvents(): RepoOpMock {
  const op: RepoOp = async (repo) => {
    const aggregate = await repo.get('agg-1');
    aggregate.foo = 'new-foo';
    await repo.save(aggregate);
  };
  return vi.fn(op) as RepoOpMock;
}

// Scenario
let customDomainOp: RepoOpMock;
Given(..., () => {
  customDomainOp = makeDomainOpWithEvents([event1, event2]);
});
```

```ts
// After – inline per scenario
Given('integration events are emitted during the domain operation', () => {
  event1 = new TestEvent('id');
  event1.payload = { foo: 'bar1' };
  event2 = new TestEvent('id');
  event2.payload = { foo: 'bar2' };

  customDomainOp = vi.fn(async (repo: RepoMock) => {
    const aggregate = await repo.get('agg-1');
    aggregate.foo = 'new-foo';
    aggregate.addIntegrationEvent(TestEvent, event1.payload);
    aggregate.addIntegrationEvent(TestEvent, event2.payload);
    await repo.save(aggregate);
  });
});
```

For the “no events” case, you can similarly inline the operation in the `Given` instead of using `makeDomainOpWithoutEvents`.

### 2. Remove `TestRepoClass` and assert on the actual instance

`TestRepoClass` is a pure alias of `RepoMock` only to allow `expect.any(TestRepoClass)`. You can keep the assertion focused but simpler:

```ts
// Before
class TestRepoClass extends RepoMock {}

unitOfWork = new MongoUnitOfWork(
  eventBus,
  integrationEventBus,
  mockModel,
  typeConverter,
  TestRepoClass,
);

Then('...', () => {
  expect(unitOfWork.repoClass).toBe(TestRepoClass);
});

// In scenario
expect(domainOperation).toHaveBeenCalledWith(expect.any(TestRepoClass));
```

```ts
// After – use the real class / instance
unitOfWork = new MongoUnitOfWork(
  eventBus,
  integrationEventBus,
  mockModel,
  typeConverter,
  RepoMock,
);

Then('...', () => {
  expect(unitOfWork.repoClass).toBe(RepoMock);
});

// Scenario
Then('the transaction is committed and no events are dispatched', () => {
  expect(domainOperation).toHaveBeenCalledWith(repoInstance);
});
```

If you only care that a repo-like object is passed, you can also assert on shape:

```ts
expect(domainOperation).toHaveBeenCalledWith(
  expect.objectContaining({ get: expect.any(Function), save: expect.any(Function) }),
);
```

### 3. Inline `setupMockModelReturningAggregate` / `setupRepoNoEvents` where used

These helpers hide straightforward setup that’s only relevant to a couple of scenarios.

```ts
// Before
function setupRepoNoEvents() {
  repoInstance.getIntegrationEvents = vi.fn(() => []);
  repoInstance.get = vi.fn().mockResolvedValue(
    new AggregateRootMock(
      { id: 'agg-1', foo: 'old-foo', createdAt: new Date(), updatedAt: new Date(), schemaVersion: '1' } as PropType,
      {},
    ),
  );
  repoInstance.save = vi.fn().mockResolvedValue(undefined);
}

Given('a domain operation that emits no domain or integration events', () => {
  setupRepoNoEvents();
});
```

```ts
// After – explicit in the scenario
Given('a domain operation that emits no domain or integration events', () => {
  repoInstance.getIntegrationEvents = vi.fn(() => []);
  repoInstance.get = vi.fn().mockResolvedValue(
    new AggregateRootMock(
      {
        id: 'agg-1',
        foo: 'old-foo',
        createdAt: new Date(),
        updatedAt: new Date(),
        schemaVersion: '1',
      } as PropType,
      {},
    ),
  );
  repoInstance.save = vi.fn().mockResolvedValue(undefined);
});
```

Same for `setupMockModelReturningAggregate` – the inline `findById` mocking is short and makes the test self-contained.

### 4. Use `integrationEventBus` directly instead of `dispatchMock`

You can keep the bus wiring simple and avoid an extra alias:

```ts
// Before
type DispatchMock = ReturnType<typeof vi.fn>;
let dispatchMock: DispatchMock;

dispatchMock = vi.fn() as DispatchMock;
integrationEventBus = {
  dispatch: dispatchMock,
  register: vi.fn(),
} as unknown as DomainSeedwork.EventBus;

When(..., async () => {
  dispatchMock.mockClear();
  dispatchMock.mockResolvedValueOnce(undefined);
});

// Then
expect(dispatchMock).toHaveBeenCalledTimes(2);
```

```ts
// After – mock the bus and assert on `integrationEventBus.dispatch`
integrationEventBus = vi.mocked({
  dispatch: vi.fn(),
  register: vi.fn(),
}) as DomainSeedwork.EventBus;

When(..., async () => {
  (integrationEventBus.dispatch as MockInstance).mockClear();
  (integrationEventBus.dispatch as MockInstance).mockResolvedValueOnce(undefined);
  (integrationEventBus.dispatch as MockInstance).mockResolvedValueOnce(undefined);
});

// Then
expect(integrationEventBus.dispatch).toHaveBeenCalledTimes(2);
expect(integrationEventBus.dispatch).toHaveBeenNthCalledWith(
  1,
  event1.constructor,
  event1.payload,
);
```

This keeps the tests close to the actual behavior (unit of work calling the event bus) without an extra mock layer.

---

Applying these changes retains all new behaviors (domain op mutating aggregate, adding events, dispatch semantics) but removes most of the type-level and helper indirection that makes the file harder to follow.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@nguyenduy
Copy link
Contributor Author

Hey there - I've reviewed your changes - here's some feedback:

  • In the new Storybook Vitest config, isCI now only checks TF_BUILD; if you ever run these tests outside Azure DevOps, consider centralizing CI detection (e.g., including CI or a shared helper) so behaviour like retries, timeouts, and fileParallelism stays consistent across environments.

Prompt for AI Agents

Please address the comments from this code review:

## Overall Comments
- In the new Storybook Vitest config, `isCI` now only checks `TF_BUILD`; if you ever run these tests outside Azure DevOps, consider centralizing CI detection (e.g., including `CI` or a shared helper) so behaviour like retries, timeouts, and fileParallelism stays consistent across environments.

## Individual Comments

### Comment 1
<location> `packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-unit-of-work.test.ts:35` </location>
<code_context>

 class TestEvent extends DomainSeedwork.CustomDomainEventImpl<{ foo: string }> {}

+// Helper types and factories for domain operations
+type RepoOp = (repo: RepoMock) => Promise<void>;
+type RepoOpMock = RepoOp & MockInstance;
</code_context>

<issue_to_address>
**issue (complexity):** Consider simplifying the new test helpers and mocks by inlining scenario-specific setup and using the existing classes/mocks directly instead of extra wrapper types and aliases.

You can keep the new behavior while trimming a lot of the added machinery. The main win is to remove indirection that’s only used in one or two places.

### 1. Drop `RepoOp` / `RepoOpMock` / `makeDomainOp*` indirection

The test-specific domain operations are simple and only used per-scenario. You can inline them or use a tiny local helper without type aliases:

```ts
// Before
type RepoOp = (repo: RepoMock) => Promise<void>;
type RepoOpMock = RepoOp & MockInstance;

function makeDomainOpWithoutEvents(): RepoOpMock {
  const op: RepoOp = async (repo) => {
    const aggregate = await repo.get('agg-1');
    aggregate.foo = 'new-foo';
    await repo.save(aggregate);
  };
  return vi.fn(op) as RepoOpMock;
}

// Scenario
let customDomainOp: RepoOpMock;
Given(..., () => {
  customDomainOp = makeDomainOpWithEvents([event1, event2]);
});
// After – inline per scenario
Given('integration events are emitted during the domain operation', () => {
  event1 = new TestEvent('id');
  event1.payload = { foo: 'bar1' };
  event2 = new TestEvent('id');
  event2.payload = { foo: 'bar2' };

  customDomainOp = vi.fn(async (repo: RepoMock) => {
    const aggregate = await repo.get('agg-1');
    aggregate.foo = 'new-foo';
    aggregate.addIntegrationEvent(TestEvent, event1.payload);
    aggregate.addIntegrationEvent(TestEvent, event2.payload);
    await repo.save(aggregate);
  });
});

For the “no events” case, you can similarly inline the operation in the Given instead of using makeDomainOpWithoutEvents.

2. Remove TestRepoClass and assert on the actual instance

TestRepoClass is a pure alias of RepoMock only to allow expect.any(TestRepoClass). You can keep the assertion focused but simpler:

// Before
class TestRepoClass extends RepoMock {}

unitOfWork = new MongoUnitOfWork(
  eventBus,
  integrationEventBus,
  mockModel,
  typeConverter,
  TestRepoClass,
);

Then('...', () => {
  expect(unitOfWork.repoClass).toBe(TestRepoClass);
});

// In scenario
expect(domainOperation).toHaveBeenCalledWith(expect.any(TestRepoClass));
// After – use the real class / instance
unitOfWork = new MongoUnitOfWork(
  eventBus,
  integrationEventBus,
  mockModel,
  typeConverter,
  RepoMock,
);

Then('...', () => {
  expect(unitOfWork.repoClass).toBe(RepoMock);
});

// Scenario
Then('the transaction is committed and no events are dispatched', () => {
  expect(domainOperation).toHaveBeenCalledWith(repoInstance);
});

If you only care that a repo-like object is passed, you can also assert on shape:

expect(domainOperation).toHaveBeenCalledWith(
  expect.objectContaining({ get: expect.any(Function), save: expect.any(Function) }),
);

3. Inline setupMockModelReturningAggregate / setupRepoNoEvents where used

These helpers hide straightforward setup that’s only relevant to a couple of scenarios.

// Before
function setupRepoNoEvents() {
  repoInstance.getIntegrationEvents = vi.fn(() => []);
  repoInstance.get = vi.fn().mockResolvedValue(
    new AggregateRootMock(
      { id: 'agg-1', foo: 'old-foo', createdAt: new Date(), updatedAt: new Date(), schemaVersion: '1' } as PropType,
      {},
    ),
  );
  repoInstance.save = vi.fn().mockResolvedValue(undefined);
}

Given('a domain operation that emits no domain or integration events', () => {
  setupRepoNoEvents();
});
// After – explicit in the scenario
Given('a domain operation that emits no domain or integration events', () => {
  repoInstance.getIntegrationEvents = vi.fn(() => []);
  repoInstance.get = vi.fn().mockResolvedValue(
    new AggregateRootMock(
      {
        id: 'agg-1',
        foo: 'old-foo',
        createdAt: new Date(),
        updatedAt: new Date(),
        schemaVersion: '1',
      } as PropType,
      {},
    ),
  );
  repoInstance.save = vi.fn().mockResolvedValue(undefined);
});

Same for setupMockModelReturningAggregate – the inline findById mocking is short and makes the test self-contained.

4. Use integrationEventBus directly instead of dispatchMock

You can keep the bus wiring simple and avoid an extra alias:

// Before
type DispatchMock = ReturnType<typeof vi.fn>;
let dispatchMock: DispatchMock;

dispatchMock = vi.fn() as DispatchMock;
integrationEventBus = {
  dispatch: dispatchMock,
  register: vi.fn(),
} as unknown as DomainSeedwork.EventBus;

When(..., async () => {
  dispatchMock.mockClear();
  dispatchMock.mockResolvedValueOnce(undefined);
});

// Then
expect(dispatchMock).toHaveBeenCalledTimes(2);
// After – mock the bus and assert on `integrationEventBus.dispatch`
integrationEventBus = vi.mocked({
  dispatch: vi.fn(),
  register: vi.fn(),
}) as DomainSeedwork.EventBus;

When(..., async () => {
  (integrationEventBus.dispatch as MockInstance).mockClear();
  (integrationEventBus.dispatch as MockInstance).mockResolvedValueOnce(undefined);
  (integrationEventBus.dispatch as MockInstance).mockResolvedValueOnce(undefined);
});

// Then
expect(integrationEventBus.dispatch).toHaveBeenCalledTimes(2);
expect(integrationEventBus.dispatch).toHaveBeenNthCalledWith(
  1,
  event1.constructor,
  event1.payload,
);

This keeps the tests close to the actual behavior (unit of work calling the event bus) without an extra mock layer.


Applying these changes retains all new behaviors (domain op mutating aggregate, adding events, dispatch semantics) but removes most of the type-level and helper indirection that makes the file harder to follow.
</issue_to_address>


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
* [X](https://twitter.com/intent/tweet?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
* [Mastodon](https://mastodon.social/share?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
* [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://sourcery.ai)
* [Facebook](https://www.facebook.com/sharer/sharer.php?u=https://sourcery.ai)

Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

CI is not a predefined env var on Azure DevOps, so I removed it.

@nguyenduy
Copy link
Contributor Author

@sourcery-ai review

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • In the Storybook Vitest config, isCI is now derived only from TF_BUILD, which will skip CI-specific behavior (retries, timeouts, watch/fileParallelism) in other CI providers that rely on the CI env var; consider keeping the previous CI || TF_BUILD check to avoid regressions.
  • Some of the new Vitest 4 constructor mocks (e.g. twilio, mongoose model ctors, exporter mocks) are wrapped in vi.fn(function () { ... }) with repeated inline biome ignores; you may want to factor out small helper factories for these to keep the tests less noisy and reduce duplication.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In the Storybook Vitest config, `isCI` is now derived only from `TF_BUILD`, which will skip CI-specific behavior (retries, timeouts, watch/fileParallelism) in other CI providers that rely on the `CI` env var; consider keeping the previous `CI || TF_BUILD` check to avoid regressions.
- Some of the new Vitest 4 constructor mocks (e.g. twilio, mongoose model ctors, exporter mocks) are wrapped in `vi.fn(function () { ... })` with repeated inline biome ignores; you may want to factor out small helper factories for these to keep the tests less noisy and reduce duplication.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@nguyenduy
Copy link
Contributor Author

@sourcery-ai review

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • The Storybook Vitest config now infers CI solely from TF_BUILD === 'True', whereas it previously also respected CI === 'true'; if any non-Azure environments run these tests, consider keeping CI in the check to avoid accidentally enabling watch/parallelism there.
  • Several repository tests (account plan, reservation request, conversation, user repositories) now repeat the vi.fn(function() { return mockNewDoc; }) constructor pattern; extracting a small shared helper for creating Vitest-4-compatible model constructors would reduce duplication and make the intent clearer.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The Storybook Vitest config now infers CI solely from `TF_BUILD === 'True'`, whereas it previously also respected `CI === 'true'`; if any non-Azure environments run these tests, consider keeping `CI` in the check to avoid accidentally enabling watch/parallelism there.
- Several repository tests (account plan, reservation request, conversation, user repositories) now repeat the `vi.fn(function() { return mockNewDoc; })` constructor pattern; extracting a small shared helper for creating Vitest-4-compatible model constructors would reduce duplication and make the intent clearer.

## Individual Comments

### Comment 1
<location> `packages/cellix/vitest-config/src/configs/storybook.config.ts:23-24` </location>
<code_context>
-      },
-    },
-  }));
+  // https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml
+	const isCI = process.env['TF_BUILD'] === 'True';

-  return mergeConfig(base, defineConfig({}));
</code_context>

<issue_to_address>
**question (bug_risk):** CI detection now ignores the standard `CI` env var, which may change behavior outside Azure DevOps.

The previous `isCI` logic also honored `process.env['CI']`, but the new version only checks `TF_BUILD`. In non-Azure environments (GitHub Actions, local `CI=true`, etc.), this will now behave as non-CI for retries, timeouts, fileParallelism, and watch. If that’s not intentional, please restore `CI` support, e.g.:

```ts
const isCI = process.env['TF_BUILD'] === 'True' || process.env['CI'] === 'true';
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@nguyenduy
Copy link
Contributor Author

@sourcery-ai review

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • The new @cellix/test-utils package is only used from test files, but it's added as a runtime dependency in @sthrift/service-otel and @sthrift/persistence (and even duplicated in both dependencies and devDependencies for service-otel); consider moving it to devDependencies only wherever it’s used to avoid bloating production installs.
  • In packages/cellix/test-utils/src/index.ts, makeNewableMock returns a plain function; if you intend consumers to be able to inspect calls like a Vitest mock, you may want to wrap the implementation with vi.fn (or document that this helper does not provide spy/mocking behavior itself).
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `@cellix/test-utils` package is only used from test files, but it's added as a runtime dependency in `@sthrift/service-otel` and `@sthrift/persistence` (and even duplicated in both `dependencies` and `devDependencies` for `service-otel`); consider moving it to `devDependencies` only wherever it’s used to avoid bloating production installs.
- In `packages/cellix/test-utils/src/index.ts`, `makeNewableMock` returns a plain function; if you intend consumers to be able to inspect calls like a Vitest mock, you may want to wrap the implementation with `vi.fn` (or document that this helper does not provide spy/mocking behavior itself).

## Individual Comments

### Comment 1
<location> `packages/cellix/vitest-config/src/configs/storybook.config.ts:23-24` </location>
<code_context>
-      },
-    },
-  }));
+  // https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml
+	const isCI = process.env['TF_BUILD'] === 'True';

-  return mergeConfig(base, defineConfig({}));
</code_context>

<issue_to_address>
**suggestion:** CI detection now only checks TF_BUILD and may miss other CI environments setting CI=true.

The previous config handled both `CI` and `TF_BUILD` when adjusting retry/timeout/fileParallelism for CI runs. With `isCI` now only checking `TF_BUILD`, other CI providers (or local runs with `CI=true`) will be treated as non‑CI, dropping the stricter CI defaults. Please either include the `CI` env var in the check (e.g. `process.env.CI === 'true' || process.env.TF_BUILD === 'True'`) or clearly document that only Azure Pipelines are supported here.

```suggestion
  // Generic CI detection (CI=true) and Azure Pipelines (TF_BUILD=True)
  // https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml
  const isCI =
    process.env.CI === 'true' ||
    process.env['TF_BUILD'] === 'True';
```
</issue_to_address>

### Comment 2
<location> `packages/sthrift/service-otel/package.json:38-39` </location>
<code_context>
 		"@opentelemetry/sdk-node": "0.57.2",
 		"@opentelemetry/sdk-trace-node": "1.30.1",
-		"@opentelemetry/semantic-conventions": "1.28.0"
+		"@opentelemetry/semantic-conventions": "1.28.0",
+		"@cellix/test-utils": "workspace:*"
 	},
 	"devDependencies": {
</code_context>

<issue_to_address>
**issue:** The @cellix/test-utils dependency is declared in both dependencies and devDependencies.

`@cellix/test-utils` is only used in tests, so it should live solely in `devDependencies` rather than being duplicated in both sections, unless there’s a concrete runtime use case.
</issue_to_address>

### Comment 3
<location> `packages/cellix/mongoose-seedwork/src/mongoose-seedwork/mongo-unit-of-work.test.ts:35` </location>
<code_context>

 class TestEvent extends DomainSeedwork.CustomDomainEventImpl<{ foo: string }> {}

+// Helper types and factories for domain operations
+type RepoOp = (repo: RepoMock) => Promise<void>;
+type RepoOpMock = RepoOp & MockInstance;
</code_context>

<issue_to_address>
**issue (complexity):** Consider simplifying the tests by inlining the new helper types/functions and mocks so each scenario’s flow is explicit without extra indirection layers.

You can keep the new behaviors (e.g. `repoClass` wiring and event assertions) but reduce indirection by inlining some of the abstractions you introduced.

### 1. Drop `RepoOp` / `RepoOpMock` and factories

The `RepoOp`/`RepoOpMock` types plus `makeDomainOpWithoutEvents`/`makeDomainOpWithEvents` add a lot of plumbing for what are essentially 2–3 inline operations.

You can keep the same behavior and assertions while simplifying to per-scenario inline functions:

```ts
// BeforeEachScenario
let domainOperation: ReturnType<typeof vi.fn>;

BeforeEachScenario(() => {
  // ...
  unitOfWork = new MongoUnitOfWork(
    eventBus,
    integrationEventBus,
    mockModel,
    typeConverter,
    TestRepoClass,
  );

  domainOperation = vi.fn(async (repo: RepoMock) => {
    const aggregate = await repo.get('agg-1');
    aggregate.foo = 'new-foo';
    await repo.save(aggregate);
  });

  vi.spyOn(mongoose.connection, 'transaction').mockImplementation(
    async (cb: (session: ClientSession) => Promise<unknown>) =>
      cb({} as ClientSession),
  );
});
```

Then for scenarios that need events, define the domain op inline in the `Given`:

```ts
Scenario('Domain operation emits integration events, all dispatch succeed', ({ Given, When, Then }) => {
  let event1: TestEvent;
  let event2: TestEvent;
  let customDomainOp: ReturnType<typeof vi.fn>;

  Given('integration events are emitted during the domain operation', () => {
    event1 = new TestEvent('id');
    event1.payload = { foo: 'bar1' };
    event2 = new TestEvent('id');
    event2.payload = { foo: 'bar2' };

    customDomainOp = vi.fn(async (repo: RepoMock) => {
      const aggregate = await repo.get('agg-1');
      aggregate.foo = 'new-foo';
      aggregate.addIntegrationEvent(TestEvent, event1.payload);
      aggregate.addIntegrationEvent(TestEvent, event2.payload);
      await repo.save(aggregate);
    });
  });

  When('the transaction completes successfully', async () => {
    (integrationEventBus.dispatch as MockInstance).mockClear();
    (integrationEventBus.dispatch as MockInstance).mockResolvedValueOnce(undefined);
    (integrationEventBus.dispatch as MockInstance).mockResolvedValueOnce(undefined);

    await unitOfWork.withTransaction(Passport, customDomainOp);
  });

  Then('all integration events are dispatched after the transaction commits', () => {
    expect(integrationEventBus.dispatch).toHaveBeenCalledTimes(2);
    // ...
  });
});
```

This preserves the richer coverage and `TestRepoClass` usage while making the flow of each scenario explicit.

### 2. Inline `setupRepoNoEvents`

`setupRepoNoEvents` is only used in a couple of places and just wraps a short mock setup. Inlining keeps the behavior but removes one level of jumping:

```ts
Scenario('Domain operation with no events, completes successfully', ({ Given, When, Then }) => {
  Given('a domain operation that emits no domain or integration events', () => {
    repoInstance.getIntegrationEvents = vi.fn(() => []) as typeof repoInstance.getIntegrationEvents;
    repoInstance.get = vi.fn().mockResolvedValue(
      new AggregateRootMock(
        {
          id: 'agg-1',
          foo: 'old-foo',
          createdAt: new Date(),
          updatedAt: new Date(),
          schemaVersion: '1',
        } as PropType,
        {},
      ),
    );
    repoInstance.save = vi.fn().mockResolvedValue(undefined);
  });

  When('the operation completes successfully', async () => {
    domainOperation.mockClear();
    (integrationEventBus.dispatch as MockInstance).mockClear();
    await unitOfWork.withTransaction(Passport, domainOperation);
  });

  Then('the transaction is committed and no events are dispatched', () => {
    expect(domainOperation).toHaveBeenCalledWith(expect.any(TestRepoClass));
    expect(integrationEventBus.dispatch).not.toHaveBeenCalled();
  });
});
```

You keep the same repo behavior and `TestRepoClass` assertion, but readers don’t have to track a separate helper.

### 3. Assert directly on `integrationEventBus.dispatch`

The dedicated `DispatchMock` alias and extra variable aren’t strictly needed; you can still have a strongly-typed mock by working directly with `integrationEventBus.dispatch`:

```ts
let integrationEventBus: DomainSeedwork.EventBus;

BeforeEachScenario(() => {
  integrationEventBus = vi.mocked({
    dispatch: vi.fn(),
    register: vi.fn(),
  }) as DomainSeedwork.EventBus;
  // ...
});

// In scenarios
When('...', async () => {
  const dispatch = integrationEventBus.dispatch as MockInstance;
  dispatch.mockClear();
  dispatch.mockResolvedValueOnce(undefined);
  // ...
});

Then('...', () => {
  const dispatch = integrationEventBus.dispatch as MockInstance;
  expect(dispatch).toHaveBeenCalledTimes(2);
});
```

This keeps the mock control localized but removes the extra alias and type alias.

### 4. Consider removing `setupMockModelReturningAggregate` if default

If the `BeforeEachScenario` already sets `mockModel.findById` to return the same aggregate shape, the extra `setupMockModelReturningAggregate` call can be removed entirely. If you do need to override it for a specific scenario, keep that override next to the scenario instead of in a shared helper.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Upgrade Vitest to 4.x and Fix All Related Testing

4 participants