mirror of
https://github.com/hanzoai/extension.git
synced 2026-11-01 18:02:17 +00:00
Add packages/shopify (@hanzo/shopify): a Shopify embedded admin app that puts
Hanzo AI over the store as system of record.
- Product content: generate/rewrite descriptions + SEO title/meta from product
attributes, then write back via the productUpdate mutation (only changed fields).
- Orders/support: summarize an order, draft a customer reply, extract issues.
- Ask: freeform question over a product or order, grounded in its data.
Server (src/server, holds the only secrets):
- oauth.ts Shopify OAuth request shaping + OAuth-callback HMAC verify (hex over
Shopify-canonical URLSearchParams form) + state (CSRF) check.
- shopify-api.ts Admin GraphQL wrappers (getProduct/getOrder/updateProduct) —
pure request shaping + parsing + productUpdate userErrors.
- hanzo.ts thin over the PUBLISHED @hanzo/ai createAiClient; product/order
context assembly + prompt building. No transport reimplemented.
- actions.ts product + order AI action catalogs, one prompt each over ask.
- webhooks.ts webhook HMAC verify (base64 over RAW body) + topic routing
(orders/create, app/uninstalled, the 3 GDPR topics).
- server.ts OAuth + /v1/* proxy the frontend calls + /webhooks. HMAC verified
before any param/body is trusted; shop validated to *.myshopify.com.
Frontend (src/app): Polaris + App Bridge React panel — api.ts (proxy client,
no secrets), context.ts (launch context → GID), session-fetch.ts (App Bridge
session-token fetch), App/Assistant/main. App Bridge loaded from Shopify CDN;
public API key stamped into the app, secret never bundled.
Plus shopify.app.toml, an admin-link extension, build.js (esbuild: app bundle +
Node server), tsconfig, vitest config, README. 108 vitest tests (pure), tsc
--noEmit clean, build green. Model calls via @hanzo/ai to api.hanzo.ai/v1 only.
Register packages/shopify in pnpm-workspace.yaml.
77 lines
3.6 KiB
TypeScript
77 lines
3.6 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { createApi } from '../src/app/api';
|
|
|
|
const SHOP = 'my-store.myshopify.com';
|
|
|
|
// A fetch stub recording requests and replaying queued responses. Lets us assert
|
|
// the exact URL + body the panel's client shapes, with no network.
|
|
function stubFetch(responses: Array<{ status?: number; body: unknown }>) {
|
|
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
let i = 0;
|
|
const fetchImpl = (async (url: any, init?: RequestInit) => {
|
|
calls.push({ url: String(url), init });
|
|
const r = responses[i++] ?? { status: 200, body: {} };
|
|
return new Response(JSON.stringify(r.body), { status: r.status ?? 200, headers: { 'Content-Type': 'application/json' } });
|
|
}) as unknown as typeof fetch;
|
|
return { fetch: fetchImpl, calls };
|
|
}
|
|
|
|
describe('createApi request shaping', () => {
|
|
it('getProduct GETs /v1/product with the shop + id query', async () => {
|
|
const s = stubFetch([{ body: { id: 'gid://shopify/Product/1', title: 'Beanie' } }]);
|
|
const api = createApi({ baseUrl: 'https://shopify.hanzo.ai', shop: SHOP, fetch: s.fetch });
|
|
const p = await api.getProduct('gid://shopify/Product/1');
|
|
expect(p.title).toBe('Beanie');
|
|
const url = new URL(s.calls[0].url);
|
|
expect(url.pathname).toBe('/v1/product');
|
|
expect(url.searchParams.get('shop')).toBe(SHOP);
|
|
expect(url.searchParams.get('id')).toBe('gid://shopify/Product/1');
|
|
});
|
|
|
|
it('runProductAction POSTs /v1/product/action with shop+id+action+model and returns content', async () => {
|
|
const s = stubFetch([{ body: { content: 'A great description.' } }]);
|
|
const api = createApi({ shop: SHOP, fetch: s.fetch });
|
|
const out = await api.runProductAction('gid://shopify/Product/1', 'writeDescription', 'zen5');
|
|
expect(out).toBe('A great description.');
|
|
expect(s.calls[0].url).toContain('/v1/product/action');
|
|
expect(s.calls[0].init?.method).toBe('POST');
|
|
expect(JSON.parse(String(s.calls[0].init?.body))).toEqual({
|
|
shop: SHOP,
|
|
id: 'gid://shopify/Product/1',
|
|
action: 'writeDescription',
|
|
model: 'zen5',
|
|
});
|
|
});
|
|
|
|
it('updateProduct POSTs /v1/product/update with only the fields set', async () => {
|
|
const s = stubFetch([{ body: { ok: true, product: { id: 'gid://shopify/Product/1', title: 'Beanie' } } }]);
|
|
const api = createApi({ shop: SHOP, fetch: s.fetch });
|
|
const res = await api.updateProduct('gid://shopify/Product/1', { descriptionHtml: '<p>new</p>' });
|
|
expect(res).toEqual({ id: 'gid://shopify/Product/1', title: 'Beanie' });
|
|
expect(JSON.parse(String(s.calls[0].init?.body))).toEqual({
|
|
shop: SHOP,
|
|
id: 'gid://shopify/Product/1',
|
|
descriptionHtml: '<p>new</p>',
|
|
});
|
|
});
|
|
|
|
it('listModels reads { models } from /v1/models', async () => {
|
|
const s = stubFetch([{ body: { models: ['zen5', 'zen5-mini'] } }]);
|
|
const api = createApi({ shop: SHOP, fetch: s.fetch });
|
|
expect(await api.listModels()).toEqual(['zen5', 'zen5-mini']);
|
|
});
|
|
|
|
it('throws the server error message on a non-ok response', async () => {
|
|
const s = stubFetch([{ status: 400, body: { error: 'shop not installed' } }]);
|
|
const api = createApi({ shop: SHOP, fetch: s.fetch });
|
|
await expect(api.getProduct('gid://shopify/Product/1')).rejects.toThrow('shop not installed');
|
|
});
|
|
|
|
it('same-origin base ("" ) builds a root-relative URL', async () => {
|
|
const s = stubFetch([{ body: { id: 'x', name: '#1', lineItems: [] } }]);
|
|
const api = createApi({ shop: SHOP, fetch: s.fetch });
|
|
await api.getOrder('gid://shopify/Order/1');
|
|
expect(s.calls[0].url.startsWith('/v1/order?')).toBe(true);
|
|
});
|
|
});
|