Skip to content

test(directus-client): add real Valkey cross-process refresh coordinator E2E coverage #290

Description

@remihuigen

Context

This issue was originally broader, but most of that scope is now obsolete.

PR #280 provides the memory and Redis refresh coordinators. #291 now covers the memory coordinator through a real built Nuxt/Nitro application, including real cookies, overlapping successful refreshes, stale-cookie result reuse, transient/terminal failures, fresh-token bypass, and refreshed-token propagation. The follow-up failure-path work also covers concurrent transient/terminal failure behavior and the remaining memory unit branches.

The remaining integration gap is therefore specifically the real Redis/Valkey path.

Current Redis coordinator unit tests use FakeRedis. They are useful for deterministic lease, publication, TTL, malformed-state, and race coverage, but they cannot prove that:

  • Nitro resolves the configured redis storage mount correctly;
  • Unstorage's real Redis driver exposes the client shape expected by createRedisCoordinator();
  • the real ioredis command signatures used by GET, SET ... NX EX, SET ... EX, and EVAL behave as assumed;
  • refresh coordination actually works across separate Nitro processes, which is the reason the Redis backend exists.

A Redis E2E test that sends two requests to a single Nitro process is not sufficient. It verifies driver wiring, but not the distributed guarantee. This issue should prove coordination between two independently running Nitro server processes sharing one Valkey instance.

Related: #280, #291.


Goal

Add a focused Valkey-backed E2E suite that proves this real runtime chain:

HTTP request A -> Nitro process A --\
                                  -> real Unstorage redis driver -> real ioredis -> Valkey
HTTP request B -> Nitro process B --/
                                  -> one refresh owner
                                  -> mock Directus /auth/refresh
                                  -> published sealed result
                                  -> follower adopts the same result

The test must use:

  • the existing Directus-client basic fixture;
  • a real built Nitro output;
  • two Node processes running that same output on different ports;
  • the existing mock Directus behavior, extracted for reuse;
  • a real ephemeral Valkey service;
  • the normal Nitro redis storage driver and its real driver.getInstance() client.

Do not import coordinator functions in this E2E suite.


Implementation plan

1. Extract the existing mock Directus server for reuse

The mock Directus server currently lives inside:

modules/directus-client/__tests__/e2e.test.ts

Now that the Valkey suite needs the same upstream behavior, this is the point where extraction is justified.

Create something small such as:

modules/directus-client/__tests__/helpers/mock-directus.ts

Move the existing local node:http mock server behavior there without redesigning it into a generic mocking framework.

It must continue to support at least:

/items/pages
/auth/login
/auth/refresh
/auth/logout
/users/me
/auth/password/request
/auth/password/reset
/auth/magic-links/request
/auth/magic-links/redeem

Expose only the mutable controls/observations already needed by the tests, roughly:

type RefreshBehavior = "success" | "transient" | "terminal";

interface MockDirectusState {
  refreshBehavior: RefreshBehavior;
  refreshDelayMs: number;
  loginExpires: number;
  readonly refreshRequests: number;
  readonly loginRequests: number;
  readonly lastItemsAuthorization: string | undefined;
}

The exact API can be a small object/class, but it should provide clear lifecycle methods such as start(), reset(), and close() plus the server URL.

Keep successful refresh output deterministic:

access_token: refreshed-access
refresh_token: refreshed-refresh
expires: 60_000

After extraction, the existing memory e2e.test.ts must retain the same behavior and assertions. Do not weaken or duplicate those tests.


2. Let the existing basic fixture select memory or Redis from environment

Do not create a second mostly-identical Nuxt fixture.

Update:

modules/directus-client/__tests__/fixtures/basic/nuxt.config.ts

The default must remain the explicit memory backend used by the normal E2E suite.

When DIRECTUS_E2E_REDIS_URL is present, configure the same mount as Redis instead:

const refreshRedisUrl = process.env.DIRECTUS_E2E_REDIS_URL;

nitro: {
  storage: {
    "directus-auth-refresh": refreshRedisUrl
      ? {
          driver: "redis",
          url: refreshRedisUrl,
          base: process.env.DIRECTUS_E2E_REDIS_BASE ?? "directus-e2e"
        }
      : {
          driver: "memory"
        }
  }
}

Use url, not hand-parsed host/port options.

Unstorage's Redis driver uses ioredis. Add ioredis as a development dependency for the Directus-client test/build environment (following the repository's catalog dependency conventions). Do not make it a production dependency of @onderwijsin/nuxt-directus-client merely to satisfy this test.


3. Add a dedicated Valkey E2E test file

Create:

modules/directus-client/__tests__/e2e-valkey.test.ts

This suite should only run when:

DIRECTUS_E2E_REDIS_URL

is configured. It may be skipped during an ordinary local test run when no Valkey URL is supplied, but CI must always provide the URL whenever this suite is selected.

Do not make pnpm test unexpectedly require a developer to have a permanent Redis service running.

Build once, do not build two fixtures

Start the mock Directus server first and set the required build-time environment values.

Then build the existing basic fixture once with Nuxt Test Utils, without starting its normal single test server:

await setupFixture(import.meta.url, "basic", {
  dev: false,
  server: false,
  build: true
});

Use:

useTestContext().nuxt.options.nitro.output.dir

to locate the built Node server entry:

<output.dir>/server/index.mjs

Do not run two Nuxt builds. Both processes must execute the same built output, matching two replicas of one deployment.


4. Start two independent Nitro processes

Add a small local helper in the Valkey E2E file (or a narrowly scoped helper file if it stays cleaner) that starts the built Nitro server with Node:

node <output.dir>/server/index.mjs

Start two child processes with:

HOST=127.0.0.1
PORT=<different free port>
NODE_ENV=test

Use Node APIs for free-port allocation; do not add a package solely for this.

The helper must:

  • spawn with process.execPath;
  • assign different ports to process A and process B;
  • wait until each process is actually accepting HTTP requests before continuing;
  • retain stdout/stderr so startup failures produce useful test errors;
  • always terminate both processes during teardown, including after a failed assertion.

A request to:

GET /_directus/auth/session

without a cookie is sufficient as a readiness probe; it should not trigger a refresh.

Do not use @nuxt/test-utils' single-server startServer() for both instances because it stops/replaces the current test server. These two server processes must overlap for the duration of the distributed tests.


Required E2E scenarios

Use process A for login to obtain a real sealed session cookie. The deterministic fixture session secret means process B must be able to read the same cookie.

Every test should capture the mock Directus refresh counter before acting rather than assuming a global absolute count.

Reset mutable mock behavior in beforeEach.

5. Cross-process overlapping success: exactly one refresh

This is the merge-critical test.

Arrange:

login through process A with expires = 1
refreshDelayMs = 250

Then send the same original stale cookie concurrently to both processes:

process A -> GET /_directus/auth/session
process B -> GET /_directus/auth/session

Assert:

both responses are 200
both snapshots contain userId === "user-1"
mock Directus /auth/refresh count increased by exactly 1
both responses set a rotated directus_session cookie
both rotated cookie values are equal
rotated cookie != original stale cookie

This assertion must remain exactly one upstream refresh. Do not weaken it to <= 2 or retry around failures.

This one test proves:

process A and B do not share memory
-> they both reach the real Redis coordinator
-> one acquires the Valkey lease
-> one refreshes Directus
-> the other consumes the published sealed result

6. Completed result is reusable across processes

This proves Redis result publication survives beyond only the in-flight overlap.

Arrange an expiring cookie.

  1. Request the session through process A and let it refresh successfully.
  2. Capture A's rotated cookie.
  3. Immediately send the original stale cookie to process B.

Assert:

only one upstream refresh total
process B succeeds
process B returns the same rotated cookie as process A

Do not wait for the 30-second completed TTL to expire. Exact TTL mechanics remain unit-test responsibility.


7. Cross-process transient failure is shared and later recovers

Arrange:

refreshBehavior = transient
refreshDelayMs = 250

Send the same stale cookie concurrently to A and B.

Assert:

both responses are HTTP 503
only one upstream refresh occurred
neither response clears directus_session

Change the mock to success and immediately retry the stale cookie through either process.

Assert:

response is still 503
refresh count has not increased

Then wait slightly longer than the real one-second transient result window (for example ~1.1s) and retry.

Assert:

response is 200
refresh count increased by one new attempt
rotated session cookie is returned

This is the only real-time TTL wait needed in this suite. Do not add 5-second or 30-second sleeps.


8. Cross-process terminal rejection is shared

Arrange:

refreshBehavior = terminal
refreshDelayMs = 250

Send the same stale cookie concurrently to A and B.

Assert:

only one upstream refresh occurred
both responses resolve as unauthenticated according to the existing session endpoint contract
both responses clear directus_session

Use the same semantic cookie-deletion assertion already used by the memory E2E tests. Do not assert the entire serialized cookie header.


Valkey and CI

Use a real Valkey container in CI. Do not use FakeRedis, Testcontainers, MSW, or an in-process Redis implementation for this issue.

Pin the image to:

valkey/valkey:8.1.10-alpine

Integrate it into the existing CI jobs rather than creating another general E2E framework.

Focused CI

In focused_quality_check, before the focused test step:

  • only when TEST_PATHS contains modules/directus-client/__tests__ and tests are enabled;
  • start the Valkey container with Docker;
  • publish port 6379 to localhost;
  • wait for valkey-cli ping to return PONG;
  • append this to $GITHUB_ENV:
DIRECTUS_E2E_REDIS_URL=redis://127.0.0.1:6379

After the tests, add an if: always() cleanup step that removes the container when it was started.

Full CI

When the full test phase runs, start the same Valkey container before pnpm test:coverage, export the same environment variable, and clean it up afterward with if: always().

Do not start Valkey for documentation-only/light jobs.

If Valkey does not become healthy, fail immediately and print its container logs. Do not silently skip the suite in CI.

Use a unique, test-only Redis base namespace. The CI container itself is ephemeral, so do not add broad FLUSHALL cleanup logic.


Local execution

The suite may skip when DIRECTUS_E2E_REDIS_URL is absent.

A developer must be able to run it explicitly with an ephemeral container, for example:

docker run --rm -d \
  --name nuxt-modules-valkey-e2e \
  -p 16379:6379 \
  valkey/valkey:8.1.10-alpine

DIRECTUS_E2E_REDIS_URL=redis://127.0.0.1:16379 \
  pnpm exec vitest run modules/directus-client/__tests__/e2e-valkey.test.ts

docker rm -f nuxt-modules-valkey-e2e

The test itself must still clean up mock Directus and both Nitro child processes.


Do not add

Do not add:

another Nuxt fixture
another HTTP mocking library
Testcontainers
Playwright/browser tests
a fake Redis client in the Valkey E2E suite
direct coordinator imports from the E2E test
30-second real-time TTL tests
Redis key implementation assertions
production APIs exposed only for tests
another generic E2E framework

The existing Redis unit tests remain responsible for exhaustive lease, malformed-state, publication-failure, ownership, polling, and exact TTL edge cases.

This issue is specifically about proving the real distributed runtime integration.


Expected files

Likely changes:

modules/directus-client/__tests__/e2e.test.ts
modules/directus-client/__tests__/e2e-valkey.test.ts
modules/directus-client/__tests__/helpers/mock-directus.ts
modules/directus-client/__tests__/fixtures/basic/nuxt.config.ts
modules/directus-client/package.json
pnpm-workspace.yaml              # only if required for the repository catalog entry
pnpm-lock.yaml
.github/workflows/ci.yml

Production refresh-coordinator code should not need modification. If the real-driver tests expose an incompatibility, fix the production bug rather than mocking around it.


Validation

First run the existing memory E2E suite after extracting the mock server:

pnpm exec vitest run modules/directus-client/__tests__/e2e.test.ts

Then run the Valkey suite against a real local Valkey container:

DIRECTUS_E2E_REDIS_URL=redis://127.0.0.1:16379 \
  pnpm exec vitest run modules/directus-client/__tests__/e2e-valkey.test.ts

Then:

pnpm exec vitest run modules/directus-client/__tests__
pnpm --filter @onderwijsin/nuxt-directus-client typecheck
pnpm format:check
pnpm lint

Finally run the normal repository validation relevant to the changed CI/package files.


Definition of done

This issue is complete when the repository proves through real HTTP and real Valkey that:

Scenario Required observation
Two separate Nitro processes, same stale cookie exactly one Directus refresh
Cross-process follower receives the same rotated sealed-session cookie
Completed result reused by other process no second refresh
Concurrent transient failure one upstream attempt, both callers get 503, cookie preserved
Immediate transient retry shared failure reused, no new refresh
Retry after transient TTL a new refresh occurs and succeeds
Concurrent terminal rejection one upstream attempt, both sessions cleared
Runtime wiring real Nitro redis mount + Unstorage Redis driver + real ioredis + Valkey are exercised
CI Valkey-backed suite runs reliably and container/process teardown always occurs

Do not claim this task complete if both concurrent requests are handled by only one Nitro process. The cross-process behavior is the central guarantee being tested.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions