Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions packages/core/e2e/apollo-cache.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { KeyValueCache } from '@apollo/utils.keyvaluecache';
import { mergeConfig } from '@vendure/core';
import { createTestEnvironment } from '@vendure/testing';
import path from 'path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';

import { initialData } from '../../../e2e-common/e2e-initial-data';
import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';

import * as Codegen from './graphql/generated-e2e-admin-types';
import { GET_PRODUCT_LIST, GET_PRODUCT_SIMPLE } from './graphql/shared-definitions';

class MockCache implements KeyValueCache<string> {
static getCalls: Array<{ key: string; result: string | undefined }> = [];
static setCalls: Array<{ key: string; value: string; options?: { ttl?: number } }> = [];
static deleteCalls: Array<{ key: string; result: boolean }> = [];

private store = new Map<string, string>();

static reset() {
this.getCalls = [];
this.setCalls = [];
this.deleteCalls = [];
}

get(key: string): Promise<string | undefined> {
// eslint-disable-next-line
console.log(`MockCache get: ${key}`);
const result = this.store.get(key);
MockCache.getCalls.push({ key, result });
return Promise.resolve(result);
}

set(key: string, value: string, options?: { ttl?: number }): Promise<void> {
// eslint-disable-next-line
console.log(`MockCache set: ${key}`, value);
this.store.set(key, value);
MockCache.setCalls.push({ key, value, options });
return Promise.resolve();
}

delete(key: string): Promise<boolean> {
const result = this.store.delete(key);
MockCache.deleteCalls.push({ key, result });
return Promise.resolve(result);
}
}

describe('Apollo cache configuration', () => {
describe('with custom cache implementation', () => {
const mockCache = new MockCache();
const { server, adminClient, shopClient } = createTestEnvironment(
mergeConfig(testConfig(), {
apiOptions: {
cache: mockCache,
},
}),
);
Comment on lines +21 to +27
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Tests don’t actually verify that Apollo uses the provided cache

Right now we only assert that queries succeed. Without a plugin calling server.cache (e.g. responseCachePlugin), the cache won’t be exercised. Either:

  • Wire in a cache-using plugin and assert MockCache.getCalls/setCalls, or
  • Rename the tests to reflect that we’re only validating successful boot with a custom cache.

Minimal, self-contained approach (no external deps): add a tiny plugin that touches server.cache so we can assert calls. Apply these diffs:

  1. Add a local plugin that uses the server cache
@@
-import { mergeConfig } from '@vendure/core';
+import { mergeConfig } from '@vendure/core';
+import type { ApolloServerPlugin, GraphQLRequestContext, GraphQLRequestListener } from '@apollo/server';
@@
 class MockCache implements KeyValueCache<string> {
@@
 }
 
+// A minimal plugin that exercises server.cache on each request
+const touchCachePlugin: ApolloServerPlugin = {
+    async requestDidStart(): Promise<GraphQLRequestListener> {
+        return {
+            async willSendResponse(requestContext: GraphQLRequestContext<any>) {
+                // Use the internal server cache if available
+                // @ts-expect-error Apollo types do not expose `server` on the context, but Vendure forwards the underlying Apollo `server` with `cache` attached
+                const server = requestContext.server;
+                if (server?.cache) {
+                    await server.cache.set('vendure:e2e:ping', 'pong', { ttl: 5 });
+                    await server.cache.get('vendure:e2e:ping');
+                }
+            },
+        };
+    },
+};
  1. Register the plugin and assert cache activity for the “custom cache” suite
@@
-        const { server, adminClient, shopClient } = createTestEnvironment(
+        const { server, adminClient, shopClient } = createTestEnvironment(
             mergeConfig(testConfig(), {
                 apiOptions: {
                     cache: mockCache,
+                    apolloServerPlugins: [touchCachePlugin],
                 },
             }),
         );
@@
-        it('should configure Apollo Server with custom cache', async () => {
+        it('should configure Apollo Server with custom cache and exercise it', async () => {
             MockCache.reset();
@@
             expect(result.products.items[0].name).toBe('Laptop');
+
+            // Verify our plugin touched the custom cache
+            expect(MockCache.setCalls.length).toBeGreaterThan(0);
+            expect(MockCache.getCalls.length).toBeGreaterThan(0);
         });

If the underlying Apollo context in this environment doesn’t expose requestContext.server.cache, switch to using the official @apollo/server-plugin-response-cache and assert on MockCache calls after issuing duplicate requests. I can provide an alternate diff once you confirm the plugin you prefer.

Also applies to: 73-90, 92-116, 117-139


beforeAll(async () => {
await server.init({
initialData,
productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
customerCount: 1,
});
await adminClient.asSuperAdmin();
}, TEST_SETUP_TIMEOUT_MS);

afterAll(async () => {
await server.destroy();
});

it('should configure Apollo Server with custom cache', async () => {
MockCache.reset();

// Make a GraphQL query that could potentially be cached
const result = await shopClient.query<
Codegen.GetProductListQuery,
Codegen.GetProductListQueryVariables
>(GET_PRODUCT_LIST, {
options: {
filter: { id: { eq: 'T_1' } },
take: 1,
},
});

expect(result.products.items.length).toBe(1);
expect(result.products.items[0].id).toBe('T_1');
expect(result.products.items[0].name).toBe('Laptop');
});

it('should handle cache operations without errors', async () => {
MockCache.reset();

// Test multiple queries to potentially trigger cache operations
const shopResponse = await shopClient.query<
Codegen.GetProductListQuery,
Codegen.GetProductListQueryVariables
>(GET_PRODUCT_LIST, {
options: {
take: 1,
},
});

expect(shopResponse.products.items.length).toBe(1);

const adminResponse = await adminClient.query<
Codegen.GetProductListQuery,
Codegen.GetProductListQueryVariables
>(GET_PRODUCT_LIST, {
options: { take: 1 },
});

expect(adminResponse.products.items.length).toBe(1);
});

it('should work with both shop and admin APIs', async () => {
MockCache.reset();

const [shopResult, adminResult] = await Promise.all([
shopClient.query<Codegen.GetProductSimpleQuery, Codegen.GetProductSimpleQueryVariables>(
GET_PRODUCT_SIMPLE,
{
id: 'T_1',
},
),
adminClient.query<Codegen.GetProductSimpleQuery, Codegen.GetProductSimpleQueryVariables>(
GET_PRODUCT_SIMPLE,
{
id: 'T_1',
},
),
]);

expect(shopResult.product).toBeDefined();
expect(adminResult.product).toBeDefined();
expect(shopResult.product?.id).toBe(adminResult.product?.id);
});
});

describe('with bounded cache', () => {
const { server, adminClient, shopClient } = createTestEnvironment(
mergeConfig(testConfig(), {
apiOptions: {
cache: 'bounded',
},
}),
);

beforeAll(async () => {
await server.init({
initialData,
productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
customerCount: 1,
});
await adminClient.asSuperAdmin();
}, TEST_SETUP_TIMEOUT_MS);

afterAll(async () => {
await server.destroy();
});

it('should configure Apollo Server with bounded cache', async () => {
const result = await shopClient.query<
Codegen.GetProductSimpleQuery,
Codegen.GetProductSimpleQueryVariables
>(GET_PRODUCT_SIMPLE, {
id: 'T_1',
});

expect(result.product).toBeDefined();
expect(result.product?.id).toBe('T_1');
});

it('should handle concurrent requests with bounded cache', async () => {
const queries = Array.from({ length: 5 }, (_, i) =>
shopClient.query<Codegen.GetProductSimpleQuery, Codegen.GetProductSimpleQueryVariables>(
GET_PRODUCT_SIMPLE,
{
id: `T_${i + 1}`,
},
),
);

const results = await Promise.all(queries);

results.forEach((result, index) => {
if (result.product) {
expect(result.product.id).toBe(`T_${index + 1}`);
}
});
});
});

describe('without cache configuration', () => {
const { server, adminClient, shopClient } = createTestEnvironment(testConfig());

beforeAll(async () => {
await server.init({
initialData,
productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
customerCount: 1,
});
await adminClient.asSuperAdmin();
}, TEST_SETUP_TIMEOUT_MS);

afterAll(async () => {
await server.destroy();
});

it('should work without cache configuration', async () => {
const result = await shopClient.query<
Codegen.GetProductSimpleQuery,
Codegen.GetProductSimpleQueryVariables
>(GET_PRODUCT_SIMPLE, {
id: 'T_1',
});

expect(result.product).toBeDefined();
expect(result.product?.id).toBe('T_1');
});
});
});
2 changes: 2 additions & 0 deletions packages/core/src/api/config/configure-graphql-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ async function createGraphQLOptions(
new AssetInterceptorPlugin(configService),
...configService.apiOptions.apolloServerPlugins,
];

// We only need to add the IdCodecPlugin if the user has configured
// a non-default EntityIdStrategy. This is a performance optimization
// that prevents unnecessary traversal of each response when no
Expand Down Expand Up @@ -113,6 +114,7 @@ async function createGraphQLOptions(
context: (req: any) => req,
// This is handled by the Express cors plugin
cors: false,
cache: configService.apiOptions.cache,
plugins: apolloServerPlugins,
validationRules: options.validationRules,
introspection: configService.apiOptions.introspection ?? true,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/config/default-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export const defaultConfig: RuntimeVendureConfig = {
middleware: [],
introspection: true,
apolloServerPlugins: [],
cache: 'bounded',
},
entityIdStrategy: new AutoIncrementIdStrategy(),
authOptions: {
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/config/vendure-config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ApolloServerPlugin } from '@apollo/server';
import type { KeyValueCache } from '@apollo/utils.keyvaluecache';
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Public API now references an external Apollo type; ensure dependency and consider decoupling

ApiOptions.cache exports a type from '@apollo/utils.keyvaluecache'. This will surface in emitted d.ts and requires downstream consumers to resolve that package. Two follow-ups:

  • Ensure packages/core lists '@apollo/utils.keyvaluecache' as a dependency (not just a transitive dep via '@apollo/server'), to avoid type resolution issues for consumers.
  • Optional: define a vendure-owned structural type (e.g. KeyValueCacheLike) compatible with Apollo’s interface to avoid exporting third-party types in public API.

Run this to verify dependency presence and where the type appears in the emitted d.ts:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Check dependency in packages/core/package.json"
jq -r '.dependencies["@apollo/utils.keyvaluecache"], .peerDependencies["@apollo/utils.keyvaluecache"], .devDependencies["@apollo/utils.keyvaluecache"]' packages/core/package.json || true

echo
echo "Find references to '@apollo/utils.keyvaluecache' in core source"
rg -n "@apollo/utils\.keyvaluecache" packages/core/src -C1

echo
echo "Optional: confirm the type import is emitted in d.ts (requires build artifacts)"
fd -a 'vendure-config.d.ts' packages/core/dist | xargs -I{} rg -n "@apollo/utils\.keyvaluecache" {}

Length of output: 1247


Ensure explicit dependency on '@apollo/utils.keyvaluecache' and avoid leaking third-party types

ApiOptions.cache currently imports and re-exports Apollo’s KeyValueCache, which will surface in the generated d.ts and force downstream consumers to install that package:

  • In packages/core/src/config/vendure-config.ts (line 2):
    import type { KeyValueCache } from '@apollo/utils.keyvaluecache';
  • Add @apollo/utils.keyvaluecache to dependencies in packages/core/package.json:
     "dependencies": {
  • "@apollo/utils.keyvaluecache": "^",
    /* existing dependencies */
    }
- After running the build, **inspect** `packages/core/dist/vendure-config.d.ts` to confirm the `@apollo/utils.keyvaluecache` import is present, ensuring consumers can resolve the type.  
- **Optional**: introduce a Vendure-owned structural type (e.g. `KeyValueCacheLike`) matching Apollo’s interface, and use that in the public API to decouple from third-party types.

<details>
<summary>🤖 Prompt for AI Agents</summary>

In packages/core/src/config/vendure-config.ts around line 2, the file imports
the third-party type KeyValueCache from '@apollo/utils.keyvaluecache', which
will surface in the published d.ts and force consumers to install that package;
add '@apollo/utils.keyvaluecache' to packages/core/package.json dependencies
(not devDependencies), rebuild and verify packages/core/dist/vendure-config.d.ts
contains the same import so consumers can resolve it; alternatively, to avoid
leaking the third‑party type, define and export a Vendure-owned structural type
(e.g. KeyValueCacheLike) matching Apollo’s interface and replace public API
usages with that type, then update tests/build accordingly.


</details>

<!-- fingerprinting:phantom:poseidon:chinchilla -->

<!-- This is an auto-generated comment by CodeRabbit -->

import { RenderPageOptions } from '@apollographql/graphql-playground-html';
import { DynamicModule, Type } from '@nestjs/common';
import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
Expand Down Expand Up @@ -209,6 +210,17 @@ export interface ApiOptions {
* @default []
*/
apolloServerPlugins?: ApolloServerPlugin[];
/**
* @description
* Pass a [custom Apollo cache](https://www.apollographql.com/docs/apollo-server/performance/caching) to the underlying Apollo Server.
* Note: this option only supplies the cache instance. To enable GraphQL response caching you must also add
* the `responseCachePlugin` (or another plugin that uses `server.cache`) to `apolloServerPlugins`, and set
* appropriate cache hints on fields. This option is unrelated to Vendure's {@link SystemOptions}.`cacheStrategy`.
*
* @default 'bounded'
* @since 3.5.0
*/
cache?: KeyValueCache<string> | 'bounded';
/**
* @description
* Controls whether introspection of the GraphQL APIs is enabled. For production, it is recommended to disable
Expand Down
Loading