Skip to content

Commit

Permalink
✨ (route.ts): add new Shopify customer route handlers for POST and GE…
Browse files Browse the repository at this point in the history
…T requests

✨ (index.ts): introduce CreateShopifyCustomer and GetShopifyCustomer handling logic
✅ (index.spec.ts): update tests to reflect new authorization logic and error handling
♻️ (index.ts): refactor error handling to use ForbiddenError for address mismatches
🔧 (index.ts): add CreateShopifyCustomerOptions and GetShopifyCustomerOptions types

✨ (validators.ts): Add CreateShopifyCustomerParams and GetShopifyCustomerParams for Shopify integration
♻️ (apiErrorHandlers.ts): Refactor NotAuthorizedError to use 401 status code and introduce ForbiddenError with 403 status
✅ (index.spec.ts): Update tests to reflect new error handling logic and test ForbiddenError handling
  • Loading branch information
sebpalluel committed Apr 26, 2024
1 parent 4ec3a8f commit f630df8
Show file tree
Hide file tree
Showing 6 changed files with 261 additions and 8 deletions.
21 changes: 21 additions & 0 deletions apps/web/app/api/shopify/customer/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ShopifyWebhookAndApiHandler } from '@integrations/external-api-handlers';
import { NextRequest } from 'next/server';

export async function POST(
req: NextRequest,
{ params: { id } }: { params: { id: string } },
) {
const shopifyHandler = new ShopifyWebhookAndApiHandler();
return shopifyHandler.createShopifyCustomer({
req,
id,
});
}

export async function GET(
req: NextRequest,
{ params: { id } }: { params: { id: string } },
) {
const shopifyHandler = new ShopifyWebhookAndApiHandler();
return shopifyHandler.hasShopifyCustomer({ req, id });
}
124 changes: 121 additions & 3 deletions libs/integrations/external-api-handlers/src/lib/shopify/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ describe('ShopifyWebhookAndApiHandler', () => {
};
const response =
await shopifyHandler.mintLoyaltyCardWithPassword(options);
expect(response.status).toBe(403);
expect(response.status).toBe(401);
});
it('throws BadRequestError for invalid query parameters', async () => {
shopifyHandler.serializeAndValidateParams = jest
Expand Down Expand Up @@ -370,7 +370,7 @@ describe('ShopifyWebhookAndApiHandler', () => {
contractAddress: 'test-contract',
loyaltyCardSdk: mockLoyaltyCardSdk,
});
expect(response.status).toBe(403);
expect(response.status).toBe(401);
expect(JSON.parse(response.body)).toEqual(
expect.objectContaining({
error: expect.stringContaining('Not Authorized: Invalid API key'),
Expand Down Expand Up @@ -491,7 +491,7 @@ describe('ShopifyWebhookAndApiHandler', () => {
});
});

it('should throw NotAuthorizedError if the owner address does not match the customer address', async () => {
it('should throw ForbiddenError if the owner address does not match the customer address', async () => {
(adminSdk.GetShopifyCustomer as jest.Mock).mockResolvedValue({
shopifyCustomer: [
{
Expand Down Expand Up @@ -550,4 +550,122 @@ describe('ShopifyWebhookAndApiHandler', () => {
);
});
});
describe('ShopifyWebhookAndApiHandler - createShopifyCustomer', () => {
let handler: ShopifyWebhookAndApiHandler;
let mockRequest: NextRequest;

beforeEach(() => {
handler = new ShopifyWebhookAndApiHandler();
mockRequest = createMockRequest(
new URLSearchParams({
address: 'test-address',
shop: 'example.myshopify.com',
timestamp: Date.now().toString(),
signature: 'validSignature',
}),
);

handler.extractAndVerifyShopifyRequest = jest.fn().mockResolvedValue({
resultParams: {
address: 'test-address',
},
organizerId: 'test-organizer-id',
});

(adminSdk.GetShopifyCustomer as jest.Mock).mockResolvedValue({
shopifyCustomer: [],
});
});

it('should create a new Shopify customer', async () => {
const response = await handler.createShopifyCustomer({
req: mockRequest,
id: 'test-customer-id',
});

expect(response.status).toBe(200);
expect(adminSdk.InsertShopifyCustomer).toHaveBeenCalledWith({
object: {
organizerId: 'test-organizer-id',
id: 'test-customer-id',
address: 'test-address',
},
});
});

it('should throw BadRequestError if the customer already exists', async () => {
(adminSdk.GetShopifyCustomer as jest.Mock).mockResolvedValue({
shopifyCustomer: [{ address: 'test-address' }],
});

const response = await handler.createShopifyCustomer({
req: mockRequest,
id: 'test-customer-id',
});

expect(response.status).toBe(400);
expect(JSON.parse(response.body)).toEqual(
expect.objectContaining({
error: expect.stringContaining('Customer already exists'),
}),
);
});
});

describe('ShopifyWebhookAndApiHandler - hasShopifyCustomer', () => {
let handler: ShopifyWebhookAndApiHandler;
let mockRequest: NextRequest;

beforeEach(() => {
handler = new ShopifyWebhookAndApiHandler();
mockRequest = createMockRequest(
new URLSearchParams({
address: 'test-address',
shop: 'example.myshopify.com',
timestamp: Date.now().toString(),
signature: 'validSignature',
}),
);

handler.extractAndVerifyShopifyRequest = jest.fn().mockResolvedValue({
resultParams: {
address: 'test-address',
},
organizerId: 'test-organizer-id',
});

(adminSdk.GetShopifyCustomer as jest.Mock).mockResolvedValue({
shopifyCustomer: [{ address: 'test-address' }],
});
});

it('should return the Shopify customer', async () => {
const response = await handler.hasShopifyCustomer({
req: mockRequest,
id: 'test-customer-id',
});

expect(response.status).toBe(200);
expect(JSON.parse(response.body)).toEqual({ address: 'test-address' });
});

it('should throw ForbiddenError if the address does not match', async () => {
(adminSdk.GetShopifyCustomer as jest.Mock).mockResolvedValue({
shopifyCustomer: [{ address: 'different-address' }],
});
const response = await handler.hasShopifyCustomer({
req: mockRequest,
id: 'test-customer-id',
});

expect(response.status).toBe(403);
expect(JSON.parse(response.body)).toEqual(
expect.objectContaining({
error: expect.stringContaining(
'Invalid address. The address must match the address of the customer.',
),
}),
);
});
});
});
89 changes: 86 additions & 3 deletions libs/integrations/external-api-handlers/src/lib/shopify/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { GetShopifyCustomerQueryVariables } from '@gql/admin/types';
import handleApiRequest, {
ApiHandlerOptions,
BadRequestError,
ForbiddenError,
CustomError,
InternalServerError,
NotAuthorizedError,
Expand All @@ -19,19 +20,25 @@ import {
HasLoyaltyCardParams,
MintLoyaltyCardWithCustomerIdParams,
MintLoyaltyCardWithPasswordParams,
CreateShopifyCustomerParams,
GetShopifyCustomerParams,
} from './validators';

export enum RequestType {
MintLoyaltyCardWithPassword = 'MintLoyaltyCardWithPassword',
MintLoyaltyCardWithCustomerId = 'MintLoyaltyCardWithCustomerId',
HasLoyaltyCard = 'HasLoyaltyCard',
CreateShopifyCustomer = 'CreateShopifyCustomer',
GetShopifyCustomer = 'GetShopifyCustomer',
}

const requestTypeValidators = {
[RequestType.MintLoyaltyCardWithPassword]: MintLoyaltyCardWithPasswordParams,
[RequestType.MintLoyaltyCardWithCustomerId]:
MintLoyaltyCardWithCustomerIdParams,
[RequestType.HasLoyaltyCard]: HasLoyaltyCardParams,
[RequestType.CreateShopifyCustomer]: CreateShopifyCustomerParams,
[RequestType.GetShopifyCustomer]: GetShopifyCustomerParams,
};

type RequestTypeToValidator = {
Expand All @@ -42,6 +49,10 @@ type RequestTypeToValidator = {
typeof MintLoyaltyCardWithCustomerIdParams
>;
[RequestType.HasLoyaltyCard]: z.infer<typeof HasLoyaltyCardParams>;
[RequestType.CreateShopifyCustomer]: z.infer<
typeof CreateShopifyCustomerParams
>;
[RequestType.GetShopifyCustomer]: z.infer<typeof GetShopifyCustomerParams>;
};

export interface MintLoyaltyCardOptions extends ApiHandlerOptions {
Expand All @@ -50,6 +61,12 @@ export interface MintLoyaltyCardOptions extends ApiHandlerOptions {
}
export type HasLoyaltyCardOptions = MintLoyaltyCardOptions;

export interface CreateShopifyCustomerOptions extends ApiHandlerOptions {
id: string;
}

export type GetShopifyCustomerOptions = CreateShopifyCustomerOptions;

export class ShopifyWebhookAndApiHandler extends BaseWebhookAndApiHandler {
constructor() {
super();
Expand Down Expand Up @@ -162,6 +179,7 @@ export class ShopifyWebhookAndApiHandler extends BaseWebhookAndApiHandler {
return res?.shopifyCustomer?.[0];
}

// POST apps/web/app/api/shopify/loyalty-card/[contractAddress]/route.ts
mintLoyaltyCardWithCustomerId = handleApiRequest<MintLoyaltyCardOptions>(
async (options) => {
const { req, contractAddress } = options;
Expand All @@ -178,8 +196,11 @@ export class ShopifyWebhookAndApiHandler extends BaseWebhookAndApiHandler {
organizerId,
customerId,
});
if (shopifyCustomer && shopifyCustomer.address !== ownerAddress) {
throw new NotAuthorizedError(
if (
shopifyCustomer &&
shopifyCustomer.address.toLowerCase() !== ownerAddress.toLowerCase()
) {
throw new ForbiddenError(
'Invalid owner address. The owner address must match the address of the customer.',
);
}
Expand Down Expand Up @@ -215,7 +236,7 @@ export class ShopifyWebhookAndApiHandler extends BaseWebhookAndApiHandler {
object: {
organizerId,
customerId,
address: ownerAddress,
address: ownerAddress.toLowerCase(),
},
});
}
Expand Down Expand Up @@ -274,6 +295,7 @@ export class ShopifyWebhookAndApiHandler extends BaseWebhookAndApiHandler {
},
);

// GET apps/web/app/api/shopify/loyalty-card/[contractAddress]/route.ts
hasLoyaltyCard = handleApiRequest<HasLoyaltyCardOptions>(async (options) => {
const { req, contractAddress } = options;

Expand Down Expand Up @@ -307,4 +329,65 @@ export class ShopifyWebhookAndApiHandler extends BaseWebhookAndApiHandler {
headers: { 'Content-Type': 'application/json' },
});
});

// POST apps/web/app/api/shopify/customer/[id]/route.ts
createShopifyCustomer = handleApiRequest<CreateShopifyCustomerOptions>(
async (options) => {
const { req, id } = options;
const { resultParams, organizerId } =
await this.extractAndVerifyShopifyRequest(req);
const validatedParams = await this.serializeAndValidateParams(
RequestType.CreateShopifyCustomer,
resultParams,
);
const shopifyCustomer = await this.getShopifyCustomer({
organizerId,
id,
});
if (shopifyCustomer) {
throw new BadRequestError('Customer already exists');
}
await adminSdk.InsertShopifyCustomer({
object: {
organizerId,
id,
address: validatedParams.address.toLowerCase(),
},
});
return new NextResponse(JSON.stringify({}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
},
);

// GET apps/web/app/api/shopify/customer/[id]/route.ts
hasShopifyCustomer = handleApiRequest<GetShopifyCustomerOptions>(
async (options) => {
const { req, id } = options;
const { resultParams, organizerId } =
await this.extractAndVerifyShopifyRequest(req);
const validatedParams = await this.serializeAndValidateParams(
RequestType.CreateShopifyCustomer,
resultParams,
);
const shopifyCustomer = await this.getShopifyCustomer({
organizerId,
id,
});
if (
shopifyCustomer &&
shopifyCustomer.address.toLowerCase() !==
validatedParams.address.toLowerCase()
) {
throw new ForbiddenError(
'Invalid address. The address must match the address of the customer.',
);
}
return new NextResponse(JSON.stringify(shopifyCustomer), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
},
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ export const HasLoyaltyCardParams = z.object({
ownerAddress: z.string(),
});

export const CreateShopifyCustomerParams = z.object({
address: z.string(),
});

export const GetShopifyCustomerParams = z.object({
address: z.string(),
});

export const LineItemSchema = z.object({
key: z.string(),
destination_location_id: z.number(),
Expand Down
12 changes: 11 additions & 1 deletion libs/next/api-handler/src/lib/apiErrorHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,23 @@ abstract class CustomError extends Error {
}

class NotAuthorizedError extends CustomError {
statusCode = 403; // HTTP status code for Forbidden
statusCode = 401; // HTTP status code for Unauthorized

constructor(message = 'Not Authorized') {
super(message);
this.name = 'NotAuthorizedError';
}
}

class ForbiddenError extends CustomError {
statusCode = 403; // HTTP status code for Forbidden

constructor(message = 'Forbidden') {
super(message);
this.name = 'ForbiddenError';
}
}

class BadRequestError extends CustomError {
statusCode = 400; // HTTP status code for Bad Request

Expand Down Expand Up @@ -49,6 +58,7 @@ export {
BadRequestError,
CustomError,
InternalServerError,
ForbiddenError,
NotAuthorizedError,
NotFoundError,
};
Loading

0 comments on commit f630df8

Please sign in to comment.