Context
The Directus client already has the E2E foundations needed to test the memory refresh coordinator through a real Nuxt/Nitro runtime. The existing basic fixture provides:
- a real Nuxt Test Utils fixture;
- auth enabled with a deterministic session secret;
- a local
node:http mock Directus server;
- real login/session cookies;
- an existing refresh-through-SSR test;
- the real
/_directus/auth/session route, which resolves through readDirectusSessionSnapshot() and therefore the refresh coordinator.
We should build on that existing harness rather than introduce a separate E2E framework. The work in this issue should primarily extend modules/directus-client/__tests__/e2e.test.ts and its existing basic fixture.
Implementation plan
Goal
Prove the memory refresh coordinator behaves correctly through the complete runtime stack:
real HTTP request
→ built/running Nuxt/Nitro fixture
→ sealed session cookie
→ auth/session resolution
→ ensureFreshDirectusSession()
→ memory refresh coordinator
→ mock Directus /auth/refresh
→ published/shared refresh result
→ rotated cookie in HTTP response
Do not import coordinator functions directly in these tests. That is already covered by unit tests. These tests should only observe consumer-visible HTTP behavior.
The main guarantees we want to prove are:
1. overlapping requests -> one upstream refresh
2. follower receives the rotated session
3. recently completed refresh -> stale cookie reuses result, no second refresh
4. transient failure does not poison future refreshes
5. terminal rejection clears the session
PR #280 is still open, so implement this on top of feat/coordinator or after #280 has merged, not against current main alone.
1. Reuse the existing basic fixture
Keep:
modules/directus-client/__tests__/fixtures/basic/
Do not create refresh, auth-e2e, or another duplicate fixture.
The existing fixture already enables Directus auth and points instance.baseUrl to DIRECTUS_E2E_URL.
I would make one small fixture change: configure the refresh storage explicitly as memory:
nitro: {
storage: {
"directus-auth-refresh": {
driver: "memory"
}
}
}
Although Nitro's root storage is memory by default, making this explicit is valuable here because the test is specifically proving the memory backend selection path introduced by #280.
Do not expose coordinator internals or add a test-only coordinator config option.
2. Extend the existing mock Directus server, don't replace it
The current e2e.test.ts already starts a local HTTP server and mocks:
/items/pages
/auth/login
/auth/refresh
/auth/logout
/users/me
/auth/password/request
/auth/password/reset
/auth/magic-links/request
/auth/magic-links/redeem
That is already a perfectly adequate Directus mock.
Do not introduce MSW, H3 as a second server framework, a generic mock framework, or a full fake Directus application.
Add only the controls required for the new scenarios.
A reasonable local shape is:
type RefreshBehavior = "success" | "transient" | "terminal";
let refreshBehavior: RefreshBehavior = "success";
let refreshDelayMs = 0;
let refreshRequests = 0;
let loginExpires = 1;
let lastItemsAuthorization: string | undefined;
Keep successful refresh tokens deterministic:
access_token: "refreshed-access"
refresh_token: "refreshed-refresh"
expires: 60_000
For /auth/refresh:
success
-> existing 200 response
transient
-> HTTP 500
-> normal Directus-shaped errors body
terminal
-> HTTP 401
-> Directus error code INVALID_TOKEN
For overlapping-request tests, support a short configurable delay before sending the refresh response.
Something straightforward such as:
if (refreshDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, refreshDelayMs));
}
is enough.
Do not create elaborate deferred/barrier infrastructure unless the simple delayed response proves flaky.
3. Add tiny HTTP helpers to the test file
Avoid repeating cookie/header parsing everywhere.
Add local helpers along these lines:
function getSessionCookie(response: Response): string | undefined {
return response.headers.get("set-cookie")?.split(";", 1)[0];
}
function requestSession(cookie: string): Promise<Response> {
return fetch(url("/_directus/auth/session"), {
headers: { cookie }
});
}
Generalize the existing:
loginWithExpiringAccessToken()
slightly so tests can choose the access-token lifetime.
For example:
async function loginWithAccessToken(expires = 1): Promise<string>
Before the login request, set the mock's loginExpires value.
The current default safety window is 30 seconds, so:
forces refresh, while something like:
gives ample room to prove no refresh occurs. The configured default refresh safety window is 30,000 ms.
4. Reset only mutable mock behavior between tests
Add beforeEach.
Reset:
refreshBehavior = "success";
refreshDelayMs = 0;
loginExpires = 1;
lastItemsAuthorization = undefined;
I would not reset cumulative request counters. Instead, every test should capture:
const refreshCountBefore = refreshRequests;
and compare against that baseline.
This avoids accidental interference if any previous HTTP request finishes slightly later than expected.
Do not mark these tests .concurrent. They deliberately mutate the mock server's behavior.
Core coordinator E2E tests
These are the important ones.
5. Test: overlapping requests execute one refresh
This is the highest-value memory coordinator test.
Arrange:
const cookie = await loginWithAccessToken(1);
const refreshCountBefore = refreshRequests;
refreshDelayMs = 250;
Then issue two HTTP requests at essentially the same time, both carrying the exact same original cookie:
const [first, second] = await Promise.all([
requestSession(cookie),
requestSession(cookie)
]);
The delay exists specifically to keep the owner's /auth/refresh in flight long enough for the second Nitro request to encounter the existing memory flight.
Assert:
both HTTP responses are 200
refreshRequests === refreshCountBefore + 1
both response bodies contain:
userId === "user-1"
both responses contain a Set-Cookie
neither returned cookie equals the original stale cookie
both returned cookie values are equal
That last assertion is important.
The owner seals the rotated session. The follower should receive the reusable completed flight and adopt that same sealed session. If both responses emit the same rotated cookie, we've tested more than just "Directus was called once": we've proved the follower actually received the owner result.
Do not weaken this test to
expect(refreshRequests).toBeLessThanOrEqual(2)
It must be exactly one.
6. Test: completed result is reused by a request carrying the stale cookie
This tests a different coordinator behavior.
Arrange:
const staleCookie = await loginWithAccessToken(1);
const refreshCountBefore = refreshRequests;
Perform the first request normally:
const first = await requestSession(staleCookie);
Capture its rotated cookie.
At this point:
R1 -> R2 happened
memory coordinator still holds completed R1 result
Now deliberately make another request with the old R1 cookie, not the newly returned R2 cookie:
const second = await requestSession(staleCookie);
Assert:
refreshRequests === refreshCountBefore + 1
not +2.
Also assert:
second request succeeds
second snapshot is user-1
second response sets the rotated cookie
second rotated cookie === first rotated cookie
This is the cleanest E2E proof of the 30-second completed-result reuse behavior from #280.
Do not E2E-test actual 30-second expiry. The unit tests already verify the precise TTL. Waiting 30 seconds in CI adds cost without testing an integration boundary.
7. Test: transient failure is shared briefly and then recoverable
This gives us a useful coordinator + auth-policy integration test.
Arrange:
const cookie = await loginWithAccessToken(1);
const refreshCountBefore = refreshRequests;
refreshBehavior = "transient";
Request:
GET /_directus/auth/session
Cookie: original session
Assert:
HTTP 503
refreshRequests === before + 1
Most importantly, assert the response does not clear the session cookie.
It is fine if there is no Set-Cookie at all. What must not be present is a deletion of directus_session.
Then change the mock:
refreshBehavior = "success";
Immediately retry with the same original cookie.
Because transient results live for one second, this immediate retry should still consume the shared transient result:
HTTP 503
refreshRequests remains before + 1
Then wait slightly longer than the actual one-second transient reuse window:
await new Promise((resolve) => setTimeout(resolve, 1_100));
Retry once more.
Assert:
HTTP 200
refreshRequests === before + 2
session snapshot is authenticated
rotated cookie is returned
This one test proves:
transient failure
→ cached briefly
→ doesn't clear session
→ doesn't cause immediate upstream burst
→ expires
→ later refresh can recover
That is excellent coverage for #260 through the actual Nitro boundary.
Do not use fake timers here. The Nuxt application is running outside the Vitest test execution context; fake timers would not control its clock consistently.
8. Test: terminal rejection clears the cookie
This isn't the coordinator's primary responsibility, but once the mock supports failure modes it's nearly free and verifies a dangerous boundary.
Arrange:
const cookie = await loginWithAccessToken(1);
refreshBehavior = "terminal";
Have Directus return:
with:
{
"errors": [
{
"message": "Invalid token",
"extensions": {
"code": "INVALID_TOKEN"
}
}
]
}
Request:
Expected behavior:
snapshot becomes null / unauthenticated
Directus session cookie is cleared
Assert the Set-Cookie response contains deletion for the default directus_session cookie. The default cookie name is directus_session.
Do not over-specify the entire cookie header. Assert only meaningful semantics, e.g. cookie name + empty/deletion/max-age behavior.
Cheap additional wins
9. Test: a fresh access token does not refresh
This is almost free once login expiry is controllable.
Login with:
const cookie = await loginWithAccessToken(120_000);
Capture refresh count.
Call:
Assert:
response is authenticated
refreshRequests unchanged
This protects the refresh boundary itself:
fresh token -> coordinator isn't entered
expiring token -> coordinator is entered
That's useful because unnecessarily refreshing healthy sessions would materially increase the chance of token-rotation races.
10. Test: authenticated server requests use the newly refreshed access token
This is the extra test I would definitely include.
The fixture already exposes:
and it calls useDirectusServer(readItems(...), event).
Extend the mock /items/pages handler to record:
lastItemsAuthorization = request.headers.authorization;
Then:
login with expiring access token
call /api/directus-server using that cookie
Assert:
refresh happened exactly once
/items/pages was requested successfully
Authorization === "Bearer refreshed-access"
This verifies the actual end-to-end sequence:
incoming stale session
→ refresh
→ rotated access token
→ authenticated Directus request uses NEW token
That catches a class of integration bugs the coordinator unit tests cannot catch.
Things explicitly not to add in this phase
Keep this implementation focused.
Do not add:
Valkey / Redis
Docker / Testcontainers
another Nuxt fixture
another HTTP mocking library
browser/Playwright tests
30-second real-time TTL tests
access to coordinator internals from the fixture
test-only runtime coordinator APIs
generic mock Directus abstractions
Redis-equivalent distributed assertions
lease tests
malformed coordination-state tests
memory follower timeout tests
Those either belong to the existing unit tests or the later Valkey E2E phase.
The repository's own testing guidance says E2E tests should exercise consumer-visible behavior requiring real Nuxt infrastructure, while pure mechanics stay in focused unit tests.
Expected file changes
Ideally this remains only:
modules/directus-client/__tests__/e2e.test.ts
modules/directus-client/__tests__/fixtures/basic/nuxt.config.ts
I would not extract the mock Directus server yet.
The existing server is already local to this suite. Adding a few mutable controls and request observations is less code than introducing another helper abstraction.
If the Valkey work later needs the same mock server from a separate E2E suite, that is the moment to extract it into something like:
modules/directus-client/__tests__/helpers/mock-directus.ts
Don't pre-abstract for that now.
Test order I would give the agent
Implement in this order:
- Explicitly configure
directus-auth-refresh as memory in the fixture.
- Add controllable refresh behavior/delay to the existing mock.
- Add the overlapping-request test.
- Add stale-cookie completed-result reuse.
- Add transient failure → cached transient → recovery.
- Add terminal failure cookie clearing.
- Add fresh-token/no-refresh.
- Add refreshed-token Authorization propagation.
- Run the existing E2E suite and make sure none of the existing login, SSR, Turnstile, magic-link, or composable tests were weakened.
The first two coordinator tests are the merge-critical output of this work. The rest are cheap integration wins enabled by the same harness.
Validation commands
The agent should finish by running:
pnpm exec vitest run modules/directus-client/__tests__/e2e.test.ts
then:
pnpm exec vitest run modules/directus-client/__tests__
then:
pnpm --filter @onderwijsin/nuxt-directus-client typecheck
and finally the normal repository validation relevant to the changed package.
Do not "fix" flaky concurrency tests by loosening assertions. If the two-request test ever observes two upstream refreshes, treat that as either a real coordinator bug or a test synchronization problem and determine which one.
Definition of done
I would consider the memory E2E work complete when we can prove, through real HTTP/Nitro execution:
| Scenario |
Required observation |
| Single expiring session |
one refresh, rotated cookie |
| Two overlapping requests with same cookie |
exactly one upstream refresh |
| Follower request |
receives same rotated cookie as owner |
| Old cookie immediately reused |
no second upstream refresh |
| Transient Directus failure |
session preserved |
| Immediate transient retry |
no new upstream refresh |
| Retry after ~1s |
new refresh occurs and succeeds |
| Terminal rejection |
session cookie cleared |
| Fresh access token |
no refresh |
| Server Directus call after refresh |
uses Bearer refreshed-access |
That gives us strong coverage of the memory coordinator's actual correctness while adding very little infrastructure.
Related: #280, #290
Context
The Directus client already has the E2E foundations needed to test the memory refresh coordinator through a real Nuxt/Nitro runtime. The existing
basicfixture provides:node:httpmock Directus server;/_directus/auth/sessionroute, which resolves throughreadDirectusSessionSnapshot()and therefore the refresh coordinator.We should build on that existing harness rather than introduce a separate E2E framework. The work in this issue should primarily extend
modules/directus-client/__tests__/e2e.test.tsand its existingbasicfixture.Implementation plan
Goal
Prove the memory refresh coordinator behaves correctly through the complete runtime stack:
Do not import coordinator functions directly in these tests. That is already covered by unit tests. These tests should only observe consumer-visible HTTP behavior.
The main guarantees we want to prove are:
PR #280 is still open, so implement this on top of
feat/coordinatoror after #280 has merged, not against currentmainalone.1. Reuse the existing
basicfixtureKeep:
Do not create
refresh,auth-e2e, or another duplicate fixture.The existing fixture already enables Directus auth and points
instance.baseUrltoDIRECTUS_E2E_URL.I would make one small fixture change: configure the refresh storage explicitly as memory:
Although Nitro's root storage is memory by default, making this explicit is valuable here because the test is specifically proving the memory backend selection path introduced by #280.
Do not expose coordinator internals or add a test-only coordinator config option.
2. Extend the existing mock Directus server, don't replace it
The current
e2e.test.tsalready starts a local HTTP server and mocks:That is already a perfectly adequate Directus mock.
Do not introduce MSW, H3 as a second server framework, a generic mock framework, or a full fake Directus application.
Add only the controls required for the new scenarios.
A reasonable local shape is:
Keep successful refresh tokens deterministic:
For
/auth/refresh:For overlapping-request tests, support a short configurable delay before sending the refresh response.
Something straightforward such as:
is enough.
Do not create elaborate deferred/barrier infrastructure unless the simple delayed response proves flaky.
3. Add tiny HTTP helpers to the test file
Avoid repeating cookie/header parsing everywhere.
Add local helpers along these lines:
Generalize the existing:
slightly so tests can choose the access-token lifetime.
For example:
Before the login request, set the mock's
loginExpiresvalue.The current default safety window is 30 seconds, so:
forces refresh, while something like:
gives ample room to prove no refresh occurs. The configured default refresh safety window is 30,000 ms.
4. Reset only mutable mock behavior between tests
Add
beforeEach.Reset:
I would not reset cumulative request counters. Instead, every test should capture:
and compare against that baseline.
This avoids accidental interference if any previous HTTP request finishes slightly later than expected.
Do not mark these tests
.concurrent. They deliberately mutate the mock server's behavior.Core coordinator E2E tests
These are the important ones.
5. Test: overlapping requests execute one refresh
This is the highest-value memory coordinator test.
Arrange:
Then issue two HTTP requests at essentially the same time, both carrying the exact same original cookie:
The delay exists specifically to keep the owner's
/auth/refreshin flight long enough for the second Nitro request to encounter the existing memory flight.Assert:
That last assertion is important.
The owner seals the rotated session. The follower should receive the reusable completed flight and adopt that same sealed session. If both responses emit the same rotated cookie, we've tested more than just "Directus was called once": we've proved the follower actually received the owner result.
Do not weaken this test to
It must be exactly one.
6. Test: completed result is reused by a request carrying the stale cookie
This tests a different coordinator behavior.
Arrange:
Perform the first request normally:
Capture its rotated cookie.
At this point:
Now deliberately make another request with the old R1 cookie, not the newly returned R2 cookie:
Assert:
not
+2.Also assert:
This is the cleanest E2E proof of the 30-second completed-result reuse behavior from #280.
Do not E2E-test actual 30-second expiry. The unit tests already verify the precise TTL. Waiting 30 seconds in CI adds cost without testing an integration boundary.
7. Test: transient failure is shared briefly and then recoverable
This gives us a useful coordinator + auth-policy integration test.
Arrange:
Request:
Assert:
Most importantly, assert the response does not clear the session cookie.
It is fine if there is no
Set-Cookieat all. What must not be present is a deletion ofdirectus_session.Then change the mock:
Immediately retry with the same original cookie.
Because transient results live for one second, this immediate retry should still consume the shared transient result:
Then wait slightly longer than the actual one-second transient reuse window:
Retry once more.
Assert:
This one test proves:
That is excellent coverage for #260 through the actual Nitro boundary.
Do not use fake timers here. The Nuxt application is running outside the Vitest test execution context; fake timers would not control its clock consistently.
8. Test: terminal rejection clears the cookie
This isn't the coordinator's primary responsibility, but once the mock supports failure modes it's nearly free and verifies a dangerous boundary.
Arrange:
Have Directus return:
401with:
{ "errors": [ { "message": "Invalid token", "extensions": { "code": "INVALID_TOKEN" } } ] }Request:
Expected behavior:
Assert the
Set-Cookieresponse contains deletion for the defaultdirectus_sessioncookie. The default cookie name isdirectus_session.Do not over-specify the entire cookie header. Assert only meaningful semantics, e.g. cookie name + empty/deletion/max-age behavior.
Cheap additional wins
9. Test: a fresh access token does not refresh
This is almost free once login expiry is controllable.
Login with:
Capture refresh count.
Call:
Assert:
This protects the refresh boundary itself:
That's useful because unnecessarily refreshing healthy sessions would materially increase the chance of token-rotation races.
10. Test: authenticated server requests use the newly refreshed access token
This is the extra test I would definitely include.
The fixture already exposes:
and it calls
useDirectusServer(readItems(...), event).Extend the mock
/items/pageshandler to record:Then:
Assert:
This verifies the actual end-to-end sequence:
That catches a class of integration bugs the coordinator unit tests cannot catch.
Things explicitly not to add in this phase
Keep this implementation focused.
Do not add:
Those either belong to the existing unit tests or the later Valkey E2E phase.
The repository's own testing guidance says E2E tests should exercise consumer-visible behavior requiring real Nuxt infrastructure, while pure mechanics stay in focused unit tests.
Expected file changes
Ideally this remains only:
I would not extract the mock Directus server yet.
The existing server is already local to this suite. Adding a few mutable controls and request observations is less code than introducing another helper abstraction.
If the Valkey work later needs the same mock server from a separate E2E suite, that is the moment to extract it into something like:
Don't pre-abstract for that now.
Test order I would give the agent
Implement in this order:
directus-auth-refreshas memory in the fixture.The first two coordinator tests are the merge-critical output of this work. The rest are cheap integration wins enabled by the same harness.
Validation commands
The agent should finish by running:
pnpm exec vitest run modules/directus-client/__tests__/e2e.test.tsthen:
pnpm exec vitest run modules/directus-client/__tests__then:
and finally the normal repository validation relevant to the changed package.
Do not "fix" flaky concurrency tests by loosening assertions. If the two-request test ever observes two upstream refreshes, treat that as either a real coordinator bug or a test synchronization problem and determine which one.
Definition of done
I would consider the memory E2E work complete when we can prove, through real HTTP/Nitro execution:
Bearer refreshed-accessThat gives us strong coverage of the memory coordinator's actual correctness while adding very little infrastructure.
Related: #280, #290