Skip to content

Commit 860dc55

Browse files
committed
chore: add unit tests for client, setup requirements, webhooks
Signed-off-by: Frederik Bußmann <frederik@bussmann.io>
1 parent afa634a commit 860dc55

9 files changed

Lines changed: 792 additions & 0 deletions

test/unit/client-api-url.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { configSchema } from '../../src/schemas'
4+
import { createAdminConfig } from '../../src/runtime/utils/clients/admin'
5+
import { createCustomerAccountConfig } from '../../src/runtime/utils/clients/customer-account'
6+
import { createStorefrontConfig } from '../../src/runtime/utils/clients/storefront'
7+
import { createClient } from '../../src/runtime/utils/clients/create'
8+
import {
9+
PROXY_API_VERSION_HEADER,
10+
createTransport,
11+
isVersionedApiUrl,
12+
withApiVersion,
13+
} from '../../src/runtime/utils/clients/transport'
14+
15+
const storefront = { name: 'shop', clients: { storefront: { apiVersion: '2026-04', publicAccessToken: 'tok', retries: 0 } } }
16+
17+
const proxiedStorefront = configSchema.parse({ name: 'shop', clients: { storefront: { apiVersion: '2026-04', publicAccessToken: 'tok' } } })
18+
const admin = { name: 'shop', clients: { admin: { apiVersion: '2026-04', accessToken: 'shpat_x', retries: 0 } } }
19+
const mock = { name: 'shop', clients: { storefront: { apiVersion: '2026-04', mock: true, retries: 0 } } }
20+
21+
const customerAccount = {
22+
name: 'shop',
23+
clients: {
24+
customerAccount: {
25+
apiVersion: '2026-04',
26+
clientId: 'cid',
27+
apiUrl: 'https://shopify.com/1234/account/customer/api/2026-04/graphql',
28+
retries: 0,
29+
},
30+
},
31+
}
32+
33+
describe('withApiVersion', () => {
34+
it('replaces the version segment of every Shopify API url shape', () => {
35+
expect(withApiVersion('https://shop.myshopify.com/api/2026-04/graphql.json', '2026-01'))
36+
.toBe('https://shop.myshopify.com/api/2026-01/graphql.json')
37+
38+
expect(withApiVersion('https://shop.myshopify.com/admin/api/2026-04/graphql.json', '2026-01'))
39+
.toBe('https://shop.myshopify.com/admin/api/2026-01/graphql.json')
40+
41+
expect(withApiVersion('https://shopify.com/1234/account/customer/api/2026-04/graphql', '2026-01'))
42+
.toBe('https://shopify.com/1234/account/customer/api/2026-01/graphql')
43+
44+
expect(withApiVersion('https://mock.shop/api/2026-04/graphql.json', '2026-01'))
45+
.toBe('https://mock.shop/api/2026-01/graphql.json')
46+
})
47+
48+
it('leaves a proxy url untouched', () => {
49+
expect(withApiVersion('http://localhost:3000/_proxy/storefront', '2026-01'))
50+
.toBe('http://localhost:3000/_proxy/storefront')
51+
52+
expect(isVersionedApiUrl('http://localhost:3000/_proxy/storefront')).toBe(false)
53+
expect(isVersionedApiUrl('https://shop.myshopify.com/admin/api/2026-04/graphql.json')).toBe(true)
54+
})
55+
})
56+
57+
describe('per-request apiVersion override', () => {
58+
it('keeps the storefront endpoint', () => {
59+
const transport = createTransport(createStorefrontConfig(storefront as never))
60+
61+
expect(transport.getApiUrl()).toBe('https://shop.myshopify.com/api/2026-04/graphql.json')
62+
expect(transport.getApiUrl('2026-01')).toBe('https://shop.myshopify.com/api/2026-01/graphql.json')
63+
})
64+
65+
it('keeps the admin prefix', () => {
66+
const transport = createTransport(createAdminConfig(admin as never))
67+
68+
expect(transport.getApiUrl('2026-01')).toBe('https://shop.myshopify.com/admin/api/2026-01/graphql.json')
69+
})
70+
71+
it('keeps the customer account host', () => {
72+
const transport = createTransport(createCustomerAccountConfig(customerAccount as never))
73+
74+
expect(transport.getApiUrl('2026-01')).toBe('https://shopify.com/1234/account/customer/api/2026-01/graphql')
75+
})
76+
77+
it('keeps the mock storefront host', () => {
78+
const transport = createTransport(createStorefrontConfig(mock as never))
79+
80+
expect(transport.getApiUrl('2026-01')).toBe('https://mock.shop/api/2026-01/graphql.json')
81+
})
82+
})
83+
84+
describe('client-level apiVersion option', () => {
85+
const definition = { kind: 'storefront', createConfig: createStorefrontConfig, cache: true } as never
86+
87+
it('rewrites the request url, not just the reported version', () => {
88+
const client = createClient(definition, storefront as never, { apiVersion: '2026-01' } as never)
89+
90+
expect(client.config.apiVersion).toBe('2026-01')
91+
expect(client.config.apiUrl).toBe('https://shop.myshopify.com/api/2026-01/graphql.json')
92+
})
93+
94+
it('asks the proxy for the version instead of bypassing it', () => {
95+
const client = createClient(definition, proxiedStorefront as never, {
96+
apiVersion: '2026-01',
97+
origin: 'http://localhost:3000',
98+
} as never)
99+
100+
expect(client.config.apiUrl).toBe('http://localhost:3000/_proxy/storefront')
101+
expect(client.config.headers[PROXY_API_VERSION_HEADER]).toBe('2026-01')
102+
})
103+
104+
it('does not set the proxy version header when no override is given', () => {
105+
const client = createClient(definition, proxiedStorefront as never, { origin: 'http://localhost:3000' } as never)
106+
107+
expect(client.config.apiUrl).toBe('http://localhost:3000/_proxy/storefront')
108+
expect(client.config.headers[PROXY_API_VERSION_HEADER]).toBeUndefined()
109+
})
110+
})
111+
112+
describe('client config resolution', () => {
113+
it('sends the private token in preference to the public one', () => {
114+
const config = createStorefrontConfig({
115+
name: 'shop',
116+
clients: { storefront: { apiVersion: '2026-04', publicAccessToken: 'public', privateAccessToken: 'private', retries: 0 } },
117+
} as never)
118+
119+
expect(config.headers).toMatchObject({ 'Shopify-Storefront-Private-Token': 'private' })
120+
expect(config.headers['X-Shopify-Storefront-Access-Token']).toBeUndefined()
121+
})
122+
123+
it('refuses to build a storefront config without a token', () => {
124+
expect(() => createStorefrontConfig({
125+
name: 'shop',
126+
clients: { storefront: { apiVersion: '2026-04', retries: 0 } },
127+
} as never)).toThrow(/missing access token/)
128+
})
129+
130+
it('refuses to build a client config without a shop name', () => {
131+
expect(() => createStorefrontConfig({
132+
name: '',
133+
clients: { storefront: { apiVersion: '2026-04', publicAccessToken: 'tok', retries: 0 } },
134+
} as never)).toThrow(/missing shop name/)
135+
})
136+
})

test/unit/client-cache.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import type { Storage, StorageValue } from 'unstorage'
2+
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
import useCache from '../../src/runtime/utils/clients/cache'
6+
7+
const options = {
8+
short: { maxAge: 1, staleMaxAge: 9, swr: true },
9+
long: { maxAge: 3600, staleMaxAge: 82800, swr: true },
10+
}
11+
12+
let store: Map<string, unknown>
13+
let ttls: Map<string, unknown>
14+
15+
function createStorage() {
16+
return {
17+
hasItem: async (key: string) => store.has(key),
18+
getItem: async (key: string) => store.get(key) ?? null,
19+
setItem: async (key: string, value: unknown, opts?: unknown) => {
20+
store.set(key, value)
21+
ttls.set(key, opts)
22+
},
23+
} as unknown as Storage<StorageValue>
24+
}
25+
26+
function createRequest(data: unknown = { product: { id: '1' } }) {
27+
return vi.fn(async (..._args: unknown[]) => ({ data, headers: new Headers() }))
28+
}
29+
30+
function requestOptions(request: ReturnType<typeof createRequest>) {
31+
return request.mock.calls[0]?.[1]
32+
}
33+
34+
beforeEach(() => {
35+
store = new Map()
36+
ttls = new Map()
37+
})
38+
39+
describe('client cache', () => {
40+
it('serves a repeated request from the cache', async () => {
41+
const storage = createStorage()
42+
const request = createRequest()
43+
44+
const first = await useCache(storage, request as never, 'query X { a }' as never, { cache: 'short' } as never, options)
45+
const second = await useCache(storage, request as never, 'query X { a }' as never, { cache: 'short' } as never, options)
46+
47+
expect(request).toHaveBeenCalledTimes(1)
48+
expect(second).toStrictEqual(first)
49+
})
50+
51+
it('derives the ttl from the named cache tier', async () => {
52+
const storage = createStorage()
53+
54+
await useCache(storage, createRequest() as never, 'query X { a }' as never, { cache: 'short' } as never, options)
55+
await useCache(storage, createRequest() as never, 'query Y { b }' as never, { cache: 'long' } as never, options)
56+
57+
expect([...ttls.values()]).toStrictEqual([{ ttl: 10_000 }, { ttl: 86_400_000 }])
58+
})
59+
60+
it('keys separately per operation and per variables', async () => {
61+
const storage = createStorage()
62+
const request = createRequest()
63+
64+
await useCache(storage, request as never, 'query X { a }' as never, { cache: 'short' } as never, options)
65+
await useCache(storage, request as never, 'query Y { b }' as never, { cache: 'short' } as never, options)
66+
await useCache(storage, request as never, 'query X { a }' as never, { cache: 'short', variables: { handle: 'a' } } as never, options)
67+
await useCache(storage, request as never, 'query X { a }' as never, { cache: 'short', variables: { handle: 'b' } } as never, options)
68+
69+
expect(request).toHaveBeenCalledTimes(4)
70+
expect(store.size).toBe(4)
71+
})
72+
73+
it('does not cache when no storage is available', async () => {
74+
const request = createRequest()
75+
76+
await useCache(undefined, request as never, 'query X { a }' as never, { cache: 'short' } as never, options)
77+
await useCache(undefined, request as never, 'query X { a }' as never, { cache: 'short' } as never, options)
78+
79+
expect(request).toHaveBeenCalledTimes(2)
80+
})
81+
82+
it('does not cache an unknown cache tier', async () => {
83+
const storage = createStorage()
84+
const request = createRequest()
85+
86+
await useCache(storage, request as never, 'query X { a }' as never, { cache: 'nope' } as never, options)
87+
await useCache(storage, request as never, 'query X { a }' as never, { cache: 'nope' } as never, options)
88+
89+
expect(request).toHaveBeenCalledTimes(2)
90+
expect(store.size).toBe(0)
91+
})
92+
93+
it('does not cache a response that carries errors', async () => {
94+
const storage = createStorage()
95+
const request = vi.fn(async () => ({ data: undefined, errors: { message: 'boom' }, headers: new Headers() }))
96+
97+
await useCache(storage, request as never, 'query X { a }' as never, { cache: 'short' } as never, options)
98+
await useCache(storage, request as never, 'query X { a }' as never, { cache: 'short' } as never, options)
99+
100+
expect(request).toHaveBeenCalledTimes(2)
101+
expect(store.size).toBe(0)
102+
})
103+
104+
it('asks the proxy for the same tier it caches under', async () => {
105+
const request = createRequest()
106+
107+
await useCache(createStorage(), request as never, 'query X { a }' as never, { cache: 'long' } as never, options)
108+
109+
expect(requestOptions(request)).toMatchObject({ headers: { 'X-Shopify-Proxy-Cache': 'long' } })
110+
})
111+
112+
it('supports split client and proxy cache tiers', async () => {
113+
const request = createRequest()
114+
115+
await useCache(
116+
createStorage(),
117+
request as never,
118+
'query X { a }' as never,
119+
{ cache: { client: 'short', proxy: 'long' } } as never,
120+
options,
121+
)
122+
123+
expect(requestOptions(request)).toMatchObject({ headers: { 'X-Shopify-Proxy-Cache': 'long' } })
124+
expect([...ttls.values()]).toStrictEqual([{ ttl: 10_000 }])
125+
})
126+
})

test/unit/client-errors.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
3+
import useErrors from '../../src/runtime/utils/clients/errors'
4+
5+
const graphQLErrors = [{ message: 'Field `nope` doesn\'t exist', path: ['products', 'nope'] }]
6+
7+
describe('client errors', () => {
8+
it('does nothing when there are no errors', async () => {
9+
await expect(useErrors(undefined as never, true)).resolves.toBeUndefined()
10+
})
11+
12+
it('reports graphql errors with their path', async () => {
13+
await expect(useErrors({ graphQLErrors } as never, true)).rejects.toMatchObject({
14+
statusCode: 500,
15+
statusMessage: 'Internal Server Error',
16+
message: expect.stringContaining('(at `products.nope`)'),
17+
})
18+
})
19+
20+
it('keeps the upstream status when Shopify returns one', async () => {
21+
await expect(useErrors({ graphQLErrors, networkStatusCode: 429 } as never, true)).rejects.toMatchObject({
22+
statusCode: 429,
23+
statusMessage: 'Too Many Requests',
24+
})
25+
})
26+
27+
it('falls back to 500 for a success-range network status', async () => {
28+
await expect(useErrors({ graphQLErrors, networkStatusCode: 200 } as never, true)).rejects.toMatchObject({ statusCode: 500 })
29+
})
30+
31+
it('reports a transport error without graphql errors', async () => {
32+
await expect(useErrors({ message: 'network down', networkStatusCode: 503 } as never, true)).rejects.toMatchObject({
33+
statusCode: 503,
34+
statusMessage: 'Service Unavailable',
35+
message: expect.stringContaining('Request failed: network down'),
36+
})
37+
})
38+
39+
it('swallows errors when throwing is disabled', async () => {
40+
await expect(useErrors({ graphQLErrors } as never, false)).resolves.toBeUndefined()
41+
})
42+
43+
it('always notifies the callback, throwing or not', async () => {
44+
const onErrors = vi.fn()
45+
46+
await useErrors({ graphQLErrors } as never, false, onErrors)
47+
await useErrors({ graphQLErrors } as never, true, onErrors).catch(() => {})
48+
49+
expect(onErrors).toHaveBeenCalledTimes(2)
50+
expect(onErrors).toHaveBeenCalledWith({ errors: { graphQLErrors } })
51+
})
52+
})

test/unit/client-functions.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { flattenConnection } from '../../src/runtime/utils/functions/flattenConnection'
4+
import { parseGid } from '../../src/runtime/utils/functions/parseGid'
5+
6+
describe('flattenConnection', () => {
7+
it('unwraps an edges connection', () => {
8+
expect(flattenConnection({ edges: [{ node: { id: '1' } }, { node: { id: '2' } }] })).toStrictEqual([{ id: '1' }, { id: '2' }])
9+
})
10+
11+
it('passes a nodes connection through', () => {
12+
expect(flattenConnection({ nodes: [{ id: '1' }] })).toStrictEqual([{ id: '1' }])
13+
})
14+
15+
it('prefers edges when a connection carries both', () => {
16+
expect(flattenConnection({ edges: [{ node: { id: 'edge' } }], nodes: [{ id: 'node' }] })).toStrictEqual([{ id: 'edge' }])
17+
})
18+
19+
it('returns an empty array for empty, missing and null connections', () => {
20+
expect(flattenConnection({ edges: [] })).toStrictEqual([])
21+
expect(flattenConnection({})).toStrictEqual([])
22+
expect(flattenConnection(null)).toStrictEqual([])
23+
expect(flattenConnection(undefined)).toStrictEqual([])
24+
})
25+
})
26+
27+
describe('parseGid', () => {
28+
it('extracts the numeric id from a global id', () => {
29+
expect(parseGid('gid://shopify/Product/1234567890')).toBe('1234567890')
30+
expect(parseGid('gid://shopify/HydrogenStorefront/1')).toBe('1')
31+
})
32+
33+
it('ignores query parameters appended by the API', () => {
34+
expect(parseGid('gid://shopify/ProductVariant/42?namespace=custom')).toBe('42')
35+
})
36+
37+
it('throws on anything that is not a Shopify global id', () => {
38+
expect(() => parseGid('1234567890')).toThrow(/invalid format/)
39+
expect(() => parseGid('gid://shopify/Product/')).toThrow(/invalid format/)
40+
expect(() => parseGid('')).toThrow(/invalid format/)
41+
})
42+
})

0 commit comments

Comments
 (0)