feat(carts): refactor cart manager to match layers

Because:
- Refactor the Cart Manager to serve as higher level logic for the
  cart db model.

This commit:
- Removes service level logic.
- Adds common methods to be used by Cart Manager consumers.
- Adds checks for valid state by action and valid state transitions.
- Reverts playwright tests back to xlarge instance size

Closes FXA-8128
This commit is contained in:
Reino Muhl 2023-08-11 09:52:52 -04:00
Родитель e61b71909a
Коммит 9c1afee646
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: C86660FCF998897A
16 изменённых файлов: 750 добавлений и 345 удалений

Просмотреть файл

@ -597,7 +597,6 @@ jobs:
environment:
NODE_ENV: test
# Runs integration tests suites across packages with changes. Integration tests can take
# longer to run, so this job supports splitting.
integration-test-part:
@ -810,7 +809,7 @@ workflows:
- Build (PR)
- playwright-functional-tests:
name: Functional Tests - Playwright (PR)
resource_class: large
resource_class: xlarge
requires:
- Build (PR)
- build-and-deploy-storybooks:
@ -999,7 +998,7 @@ workflows:
- Build
- playwright-functional-tests:
name: Functional Tests - Playwright
resource_class: large
resource_class: xlarge
filters:
branches:
ignore: /.*/
@ -1095,7 +1094,7 @@ workflows:
- Build (nightly)
- playwright-functional-tests:
name: Functional Tests - Playwright (nightly)
resource_class: large
resource_class: xlarge
requires:
- Build (nightly)
- on-complete:

Просмотреть файл

@ -1,6 +1,6 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
export * from './lib/types';
export * from './lib/factories';
export * from './lib/manager';
export * from './lib/cart.types';
export * from './lib/cart.factories';
export * from './lib/cart.manager';

Просмотреть файл

@ -0,0 +1,81 @@
import { BaseError } from '@fxa/shared/error';
import {
FinishCart,
FinishErrorCart,
SetupCart,
UpdateCart,
} from './cart.types';
import { CartState } from '@fxa/shared/db/mysql/account';
export class CartError extends BaseError {
constructor(message: string, cause?: Error) {
super(
{
name: 'CartError',
...(cause && { cause }),
},
message
);
}
}
// TODO - Add information about the cart that caused the errors
export class CartNotCreatedError extends CartError {
data: SetupCart;
constructor(data: SetupCart, cause: Error) {
super('Cart not created', cause);
this.data = data;
}
}
export class CartNotFoundError extends CartError {
cartId: string;
constructor(cartId: string, cause: Error) {
super('Cart not found', cause);
this.cartId = cartId;
}
}
export class CartNotUpdatedError extends CartError {
cartId: string;
data?: FinishCart | FinishErrorCart | UpdateCart;
constructor(
cartId: string,
data?: FinishCart | FinishErrorCart | UpdateCart,
cause?: Error
) {
super('Cart not updated', cause);
this.cartId = cartId;
this.data = data;
}
}
export class CartStateFinishedError extends CartError {
constructor() {
super('Cart state is already finished');
}
}
export class CartNotDeletedError extends CartError {
cartId: string;
constructor(cartId: string, cause?: Error) {
super('Cart not deleted', cause);
this.cartId = cartId;
}
}
export class CartNotRestartedError extends CartError {
previousCartId: string;
constructor(previousCartId: string, cause: Error) {
super('Cart not created', cause);
this.previousCartId = previousCartId;
}
}
export class CartInvalidStateForActionError extends CartError {
cartId: string;
state: CartState;
action: string;
constructor(cartId: string, state: CartState, action: string) {
super('Invalid state for executed action');
this.cartId = cartId;
this.state = state;
this.action = action;
}
}

Просмотреть файл

@ -2,7 +2,14 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { faker } from '@faker-js/faker';
import { Invoice, SetupCart, TaxAmount, UpdateCart } from './types';
import {
FinishCart,
FinishErrorCart,
Invoice,
SetupCart,
TaxAmount,
UpdateCart,
} from './cart.types';
const OFFERING_CONFIG_IDS = [
'vpn',
@ -12,8 +19,12 @@ const OFFERING_CONFIG_IDS = [
'mdnplus',
];
const INTERVALS = ['daily', 'weekly', 'monthly', '6monthly', 'yearly'];
export const SetupCartFactory = (override?: Partial<SetupCart>): SetupCart => ({
offeringConfigId: faker.helpers.arrayElement(OFFERING_CONFIG_IDS),
interval: faker.helpers.arrayElement(INTERVALS),
amount: faker.number.int(10000),
...override,
});
@ -32,6 +43,19 @@ export const InvoiceFactory = (override?: Partial<Invoice>): Invoice => ({
export const UpdateCartFactory = (
override?: Partial<UpdateCart>
): UpdateCart => ({
id: faker.string.uuid(),
...override,
});
export const FinishCartFactory = (
override?: Partial<FinishCart>
): FinishCart => ({
amount: faker.number.int(10000),
...override,
});
export const FinishErrorCartFactory = (
override?: Partial<FinishErrorCart>
): FinishErrorCart => ({
errorReasonId: 'error-general',
...override,
});

Просмотреть файл

@ -0,0 +1,350 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Knex } from 'knex';
import { v4 as uuidv4 } from 'uuid';
import {
Cart,
CartFactory,
CartState,
} from '../../../../shared/db/mysql/account/src';
import { Logger } from '../../../../shared/log/src';
import {
FinishCartFactory,
FinishErrorCartFactory,
SetupCartFactory,
UpdateCartFactory,
} from './cart.factories';
import { CartManager } from './cart.manager';
import { testCartDatabaseSetup } from './tests';
import {
CartInvalidStateForActionError,
CartNotDeletedError,
CartNotFoundError,
CartNotUpdatedError,
CartNotRestartedError,
CartNotCreatedError,
} from './cart.error';
const CART_ID = '8730e0d5939c450286e6e6cc1bbeeab2';
const RANDOM_ID = uuidv4({}, Buffer.alloc(16)).toString('hex');
const RANDOM_VERSION = 1234;
describe('#payments-cart - manager', () => {
let knex: Knex;
let cartManager: CartManager;
let testCart: Cart;
const mockLogger: Logger = {
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
trace: jest.fn(),
warn: jest.fn(),
};
beforeAll(async () => {
cartManager = new CartManager(mockLogger);
knex = await testCartDatabaseSetup();
await Cart.query().insert({
...CartFactory(),
id: CART_ID,
});
});
afterAll(async () => {
await knex.destroy();
});
beforeEach(async () => {
testCart = await cartManager.createCart(SetupCartFactory());
});
describe('createCart', () => {
it('succeeds', async () => {
const setupCart = SetupCartFactory({
interval: 'annually',
});
const cart = await cartManager.createCart(setupCart);
expect(cart).toEqual(expect.objectContaining(setupCart));
});
it('fails - with unexpected error', async () => {
const setupCart = SetupCartFactory({
interval: 'annually',
uid: 0 as unknown as string,
});
try {
await cartManager.createCart(setupCart);
} catch (error) {
expect(error).toBeInstanceOf(CartNotCreatedError);
expect(error.jse_cause).not.toBeUndefined();
}
});
});
describe('fetchCartById', () => {
it('succeeds', async () => {
const cart = await cartManager.fetchCartById(CART_ID);
expect(cart.id).toEqual(CART_ID);
});
it('errors - NotFound', async () => {
try {
await cartManager.fetchCartById(RANDOM_ID);
fail('Error in fetchCartById');
} catch (error) {
expect(error).toBeInstanceOf(CartNotFoundError);
}
});
it('errors - with unexpected error', async () => {
try {
await cartManager.fetchCartById(0 as unknown as string);
fail('Error in fetchCartById');
} catch (error) {
expect(error).toBeInstanceOf(CartNotFoundError);
expect(error.jse_cause).not.toBeUndefined();
}
});
});
describe('updateFreshCart', () => {
it('succeeds', async () => {
const updateItems = UpdateCartFactory({
couponCode: 'testcoupon',
email: 'test@example.com',
});
const cart = await cartManager.updateFreshCart(testCart, updateItems);
expect(cart).toEqual(expect.objectContaining(updateItems));
});
it('fails - invalid state', async () => {
testCart.setCart({
state: CartState.FAIL,
});
try {
await cartManager.updateFreshCart(testCart, UpdateCartFactory());
fail('Error in updateFreshCart');
} catch (error) {
expect(error).toBeInstanceOf(CartInvalidStateForActionError);
}
});
it('fails - cart could not be updated', async () => {
testCart.setCart({
state: CartState.START,
version: RANDOM_VERSION,
});
try {
await cartManager.updateFreshCart(testCart, UpdateCartFactory());
fail('Error in updateFreshCart');
} catch (error) {
expect(error).toBeInstanceOf(CartNotUpdatedError);
}
});
it('fails - with unexpected error', async () => {
testCart.setCart({
id: 0 as unknown as string,
});
try {
await cartManager.updateFreshCart(testCart, UpdateCartFactory());
fail('Error in updateFreshCart');
} catch (error) {
expect(error).toBeInstanceOf(CartNotUpdatedError);
expect(error.jse_cause).not.toBeUndefined();
}
});
});
describe('finishCart', () => {
it('succeeds', async () => {
const items = FinishCartFactory();
testCart.setCart({
state: CartState.PROCESSING,
});
const cart = await cartManager.finishCart(testCart, items);
expect(cart).toEqual(expect.objectContaining(items));
expect(cart.state).toEqual(CartState.SUCCESS);
});
it('fails - invalid state', async () => {
try {
await cartManager.finishCart(testCart, FinishCartFactory());
fail('Error in finishCart');
} catch (error) {
expect(error).toBeInstanceOf(CartInvalidStateForActionError);
}
});
it('fails - cart could not be updated', async () => {
testCart.setCart({
state: CartState.PROCESSING,
version: RANDOM_VERSION,
});
try {
await cartManager.finishCart(testCart, FinishCartFactory());
fail('Error in finishCart');
} catch (error) {
expect(error).toBeInstanceOf(CartNotUpdatedError);
}
});
it('fails - with unexpected error', async () => {
testCart.setCart({
id: 0 as unknown as string,
state: CartState.PROCESSING,
});
try {
await cartManager.finishCart(testCart, FinishCartFactory());
fail('Error in finishCart');
} catch (error) {
expect(error).toBeInstanceOf(CartNotUpdatedError);
expect(error.jse_cause).not.toBeUndefined();
}
});
});
describe('finishErrorCart', () => {
it('succeeds', async () => {
const items = FinishErrorCartFactory();
const cart = await cartManager.finishErrorCart(testCart, items);
expect(cart).toEqual(expect.objectContaining(items));
expect(cart.state).toEqual(CartState.FAIL);
});
it('fails - invalid state', async () => {
testCart.setCart({
state: CartState.FAIL,
});
try {
await cartManager.finishErrorCart(testCart, FinishErrorCartFactory());
fail('Error in finishErrorCart');
} catch (error) {
expect(error).toBeInstanceOf(CartInvalidStateForActionError);
}
});
it('fails - cart could not be updated', async () => {
testCart.setCart({
state: CartState.START,
version: RANDOM_VERSION,
});
try {
await cartManager.finishErrorCart(testCart, FinishErrorCartFactory());
fail('Error in finishErrorCart');
} catch (error) {
expect(error).toBeInstanceOf(CartNotUpdatedError);
expect(error.jse_cause).toBeUndefined();
}
});
it('fails - with unexpected error', async () => {
testCart.setCart({
id: 0 as unknown as string,
});
try {
await cartManager.finishErrorCart(testCart, FinishErrorCartFactory());
fail('Error in finishErrorCart');
} catch (error) {
expect(error).toBeInstanceOf(CartNotUpdatedError);
expect(error.jse_cause).not.toBeUndefined();
}
});
});
describe('deleteCart', () => {
it('succeeds', async () => {
const result = await cartManager.deleteCart(testCart);
expect(result).toEqual(1);
});
it('fails - invalid state', async () => {
testCart.setCart({
state: CartState.FAIL,
});
try {
await cartManager.deleteCart(testCart);
fail('Error in deleteCart');
} catch (error) {
expect(error).toBeInstanceOf(CartInvalidStateForActionError);
}
});
it('fails - cart could not be updated', async () => {
testCart.setCart({
state: CartState.START,
version: RANDOM_VERSION,
});
try {
await cartManager.deleteCart(testCart);
fail('Error in deleteCart');
} catch (error) {
expect(error).toBeInstanceOf(CartNotDeletedError);
}
});
it('fails - with unexpected error', async () => {
testCart.setCart({
id: 0 as unknown as string,
});
try {
await cartManager.deleteCart(testCart);
fail('Error in deleteCart');
} catch (error) {
expect(error).toBeInstanceOf(CartNotDeletedError);
expect(error.jse_cause).not.toBeUndefined();
}
});
});
describe('restartCart', () => {
it('succeeds', async () => {
const cart = await cartManager.restartCart(testCart);
expect(cart).toEqual(
expect.objectContaining({
state: CartState.START,
offeringConfigId: testCart.offeringConfigId,
interval: testCart.interval,
amount: testCart.amount,
})
);
expect(cart.id).not.toEqual(testCart.id);
expect(cart.createdAt).not.toEqual(testCart.createdAt);
expect(cart.updatedAt).not.toEqual(testCart.updatedAt);
});
it('fails - invalid state', async () => {
testCart.setCart({
state: CartState.SUCCESS,
});
try {
await cartManager.restartCart(testCart);
fail('Error in restartCart');
} catch (error) {
expect(error).toBeInstanceOf(CartInvalidStateForActionError);
}
});
it('fails - with unexpected error', async () => {
testCart.setCart({
amount: 'fakeamount' as unknown as number,
});
try {
await cartManager.restartCart(testCart);
fail('Error in restartCart');
} catch (error) {
expect(error).toBeInstanceOf(CartNotRestartedError);
expect(error.jse_cause).not.toBeUndefined();
}
});
});
});

Просмотреть файл

@ -0,0 +1,162 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { NotFoundError } from 'objection';
import { Cart, CartState } from '@fxa/shared/db/mysql/account';
import { Logger } from '@fxa/shared/log';
import {
FinishCart,
FinishErrorCart,
SetupCart,
UpdateCart,
} from './cart.types';
import {
CartInvalidStateForActionError,
CartNotCreatedError,
CartNotDeletedError,
CartNotFoundError,
CartNotRestartedError,
CartNotUpdatedError,
} from './cart.error';
// For an action to be executed, the cart state needs to be in one of
// valid states listed in the array of CartStates below
const ACTIONS_VALID_STATE = {
updateFreshCart: [CartState.START],
finishCart: [CartState.PROCESSING],
finishErrorCart: [CartState.START, CartState.PROCESSING],
deleteCart: [CartState.START, CartState.PROCESSING],
restartCart: [CartState.START, CartState.PROCESSING, CartState.FAIL],
};
// Type guard to check if action is valid key in ACTIONS_VALID_STATE
const isAction = (action: string): action is keyof typeof ACTIONS_VALID_STATE =>
action in ACTIONS_VALID_STATE;
export class CartManager {
private log: Logger;
constructor(log: Logger) {
this.log = log;
}
private async handleUpdates(cart: Cart) {
const updatedRows = await cart.update();
if (!updatedRows) {
throw new CartNotUpdatedError(cart.id);
} else {
return cart;
}
}
/**
* Ensure that the action being executed has a valid Cart state for
* that action. For example, updateFreshCart is only allowed on carts
* with state CartState.START.
*/
private checkActionForValidCartState(cart: Cart, action: string) {
const isValid =
isAction(action) && ACTIONS_VALID_STATE[action].includes(cart.state);
if (!isValid) {
throw new CartInvalidStateForActionError(cart.id, cart.state, action);
} else {
return true;
}
}
public async createCart(input: SetupCart) {
try {
return await Cart.create({
...input,
state: CartState.START,
});
} catch (error) {
throw new CartNotCreatedError(input, error);
}
}
public async fetchCartById(id: string) {
try {
return await Cart.findById(id);
} catch (error) {
const cause = error instanceof NotFoundError ? undefined : error;
throw new CartNotFoundError(id, cause);
}
}
public async updateFreshCart(cart: Cart, items: UpdateCart) {
this.checkActionForValidCartState(cart, 'updateFreshCart');
cart.setCart({
...items,
});
try {
return await this.handleUpdates(cart);
} catch (error) {
const cause = error instanceof CartNotUpdatedError ? undefined : error;
throw new CartNotUpdatedError(cart.id, items, cause);
}
}
public async finishCart(cart: Cart, items: FinishCart) {
this.checkActionForValidCartState(cart, 'finishCart');
cart.setCart({
state: CartState.SUCCESS,
...items,
});
try {
return await this.handleUpdates(cart);
} catch (error) {
const cause = error instanceof CartNotUpdatedError ? undefined : error;
throw new CartNotUpdatedError(cart.id, items, cause);
}
}
public async finishErrorCart(cart: Cart, items: FinishErrorCart) {
this.checkActionForValidCartState(cart, 'finishErrorCart');
cart.setCart({
state: CartState.FAIL,
...items,
});
try {
return await this.handleUpdates(cart);
} catch (error) {
const cause = error instanceof CartNotUpdatedError ? undefined : error;
throw new CartNotUpdatedError(cart.id, items, cause);
}
}
public async deleteCart(cart: Cart) {
this.checkActionForValidCartState(cart, 'deleteCart');
try {
const result = await cart.delete();
if (!result) {
throw new CartNotDeletedError(cart.id);
} else {
return result;
}
} catch (error) {
const cause = error instanceof CartNotDeletedError ? undefined : error;
throw new CartNotDeletedError(cart.id, cause);
}
}
public async restartCart(cart: Cart) {
this.checkActionForValidCartState(cart, 'restartCart');
try {
return await Cart.create({
...cart,
state: CartState.START,
});
} catch (error) {
throw new CartNotRestartedError(cart.id, error);
}
}
}

Просмотреть файл

@ -1,10 +1,7 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import {
Cart as CartDB,
CartFields,
} from '../../../../shared/db/mysql/account/src';
import { Cart as CartDB, CartFields } from '@fxa/shared/db/mysql/account';
export interface TaxAmount {
title: string;
@ -24,6 +21,7 @@ export type Cart = CartFields & {
export type SetupCart = Pick<
CartDB,
| 'uid'
| 'interval'
| 'errorReasonId'
| 'offeringConfigId'
| 'experiment'
@ -31,9 +29,13 @@ export type SetupCart = Pick<
| 'couponCode'
| 'stripeCustomerId'
| 'email'
> & { interval?: string };
export type UpdateCart = Pick<
CartDB,
'id' | 'taxAddress' | 'couponCode' | 'email'
| 'amount'
>;
export type UpdateCart = Pick<CartDB, 'taxAddress' | 'couponCode' | 'email'>;
export type FinishCart = Pick<CartDB, 'uid' | 'amount' | 'stripeCustomerId'>;
export type FinishErrorCart = { errorReasonId: string } & Partial<
Pick<CartDB, 'uid' | 'amount' | 'stripeCustomerId'>
>;

Просмотреть файл

@ -1,114 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Knex } from 'knex';
import { NotFoundError } from 'objection';
import { v4 as uuidv4 } from 'uuid';
import { Cart, CartFactory } from '../../../../shared/db/mysql/account/src';
import { Logger } from '../../../../shared/log/src';
import { SetupCartFactory, UpdateCartFactory } from './factories';
import { CartManager, ERRORS } from './manager';
import { testCartDatabaseSetup } from './tests';
import { CartState } from '../../../../shared/db/mysql/account/src';
import { uuidTransformer } from '../../../../shared/db/mysql/core/src';
const CART_ID = '8730e0d5939c450286e6e6cc1bbeeab2';
const RANDOM_ID = uuidv4({}, Buffer.alloc(16)).toString('hex');
describe('#payments-cart - manager', () => {
let knex: Knex;
let cartManager: CartManager;
const mockLogger: Logger = {
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
trace: jest.fn(),
warn: jest.fn(),
};
beforeAll(async () => {
cartManager = new CartManager(mockLogger);
knex = await testCartDatabaseSetup();
await Cart.query().insert({
...CartFactory(),
id: CART_ID,
});
});
afterAll(async () => {
await knex.destroy();
});
describe('setupCart', () => {
it('should successfully create a new cart', async () => {
const setupCart = SetupCartFactory({
interval: 'annually',
});
const cart = await cartManager.setupCart(setupCart);
expect(cart).toEqual(expect.objectContaining(setupCart));
});
});
describe('restartCart', () => {
it('should successfully set cart state to "START"', async () => {
await Cart.query()
.update({ state: CartState.PROCESSING })
.where('id', uuidTransformer.to(CART_ID));
const cart = await cartManager.restartCart(CART_ID);
expect(cart?.state).toBe(CartState.START);
});
it('should throw NotFoundError if no cart with provided ID is found', async () => {
try {
await cartManager.restartCart(RANDOM_ID);
} catch (error) {
expect(error).toStrictEqual(
new NotFoundError({ message: ERRORS.CART_NOT_FOUND })
);
}
});
});
describe('checkoutCart', () => {
it('should successfully set cart state to "PROCESSING"', async () => {
await Cart.query()
.update({ state: CartState.START })
.where('id', uuidTransformer.to(CART_ID));
const cart = await cartManager.checkoutCart(CART_ID);
expect(cart?.state).toBe(CartState.PROCESSING);
});
it('should throw NotFoundError if no cart with provided ID is found', async () => {
try {
await cartManager.checkoutCart(RANDOM_ID);
} catch (error) {
expect(error).toStrictEqual(
new NotFoundError({ message: ERRORS.CART_NOT_FOUND })
);
}
});
});
describe('updateCart', () => {
it('should successfully update an existing cart', async () => {
const updateCart = UpdateCartFactory({
id: CART_ID,
});
const cart = await cartManager.updateCart(updateCart);
expect(cart).toEqual(expect.objectContaining(updateCart));
});
it('should throw NotFoundError if no cart with provided ID is found', async () => {
const updateCart = UpdateCartFactory({
id: RANDOM_ID,
});
try {
await cartManager.updateCart(updateCart);
} catch (error) {
expect(error).toStrictEqual(
new NotFoundError({ message: ERRORS.CART_NOT_FOUND })
);
}
});
});
});

Просмотреть файл

@ -1,78 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { NotFoundError } from 'objection';
import { Cart, CartState } from '@fxa/shared/db/mysql/account';
import { Logger } from '@fxa/shared/log';
import { InvoiceFactory } from './factories';
import { Cart as CartType, SetupCart, UpdateCart } from './types';
const DEFAULT_INTERVAL = 'monthly';
// TODO - Adopt error library developed as part of FXA-7656
export enum ERRORS {
CART_NOT_FOUND = 'Cart not found for id',
}
export class CartManager {
private log: Logger;
constructor(log: Logger) {
this.log = log;
}
public async setupCart(input: SetupCart): Promise<CartType> {
const cart = await Cart.create({
...input,
state: CartState.START,
interval: input.interval || DEFAULT_INTERVAL,
amount: 0, // Hardcoded to 0 for now. TODO - Actual amount to be added in FXA-7521
});
return {
...cart,
nextInvoice: InvoiceFactory(), // Temporary
};
}
public async restartCart(cartId: string): Promise<CartType> {
try {
const cart = await Cart.patchById(cartId, { state: CartState.START });
return {
...cart,
nextInvoice: InvoiceFactory(), // Temporary
};
} catch (error) {
throw new NotFoundError({ message: ERRORS.CART_NOT_FOUND });
}
}
public async checkoutCart(cartId: string): Promise<CartType> {
try {
const cart = await Cart.patchById(cartId, {
state: CartState.PROCESSING,
});
return {
...cart,
nextInvoice: InvoiceFactory(), // Temporary
};
} catch (error) {
throw new NotFoundError({ message: ERRORS.CART_NOT_FOUND });
}
}
public async updateCart(input: UpdateCart): Promise<CartType> {
const { id: cartId, ...rest } = input;
try {
const cart = await Cart.patchById(cartId, { ...rest });
return {
...cart,
nextInvoice: InvoiceFactory(), // Temporary
};
} catch (error) {
throw new NotFoundError({ message: ERRORS.CART_NOT_FOUND });
}
}
}

Просмотреть файл

@ -46,6 +46,7 @@ export class Cart extends BaseModel {
id: generateFxAUuid(),
createdAt: currentDate,
updatedAt: currentDate,
version: 0,
});
}
@ -56,20 +57,16 @@ export class Cart extends BaseModel {
.throwIfNotFound();
}
// TODO - Remove in FXA-8128 in favor of using update
static async patchById(id: string, items: Partial<Cart>) {
const cart = await this.findById(id);
// Patch and fetch instance
// Use update if you update the whole row with all its columns. Otherwise, using the patch method is recommended.
// https://vincit.github.io/objection.js/api/query-builder/mutate-methods.html#update
await cart.$query().patchAndFetch({
...items,
updatedAt: Date.now(),
});
async delete() {
return Cart.query()
.delete()
.where('id', uuidTransformer.to(this.id))
.where('version', this.version)
.throwIfNotFound();
}
await Cart.query().patch(cart).where('id', id);
return cart;
setCart(cartItems: Partial<Cart>) {
this.$set(cartItems);
}
async update() {
@ -78,14 +75,9 @@ export class Cart extends BaseModel {
updatedAt: Date.now(),
version: currentVersion + 1,
});
const updatedRows = await Cart.query()
return Cart.query()
.update(this)
.where('id', uuidTransformer.to(this.id))
.where('version', currentVersion);
if (!updatedRows) {
throw new Error('No rows were updated.');
}
return this;
}
}

Просмотреть файл

@ -13,7 +13,7 @@ CREATE TABLE `carts` (
`stripeCustomerId` varchar(32) COLLATE utf8mb4_bin DEFAULT NULL,
`email` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,
`amount` int unsigned NOT NULL,
`version` smallint unsigned NOT NULL DEFAULT 0,
`version` smallint unsigned DEFAULT 0 NOT NULL,
PRIMARY KEY (`id`),
KEY `uid` (`uid`),
CONSTRAINT `carts_ibfk_1` FOREIGN KEY (`uid`) REFERENCES `accounts` (`uid`) ON DELETE CASCADE

Просмотреть файл

@ -58,7 +58,8 @@
"react-ga4": "^2.1.0",
"replace-in-file": "^7.0.1",
"semver": "^7.5.3",
"tslib": "^2.5.0"
"tslib": "^2.5.0",
"uuid": "^9.0.0"
},
"engines": {
"node": "^18.14.2"
@ -108,6 +109,7 @@
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-test-renderer": "^18",
"@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^5.59.1",
"@typescript-eslint/parser": "^5.59.1",
"autoprefixer": "^10.4.14",

Просмотреть файл

@ -1,90 +1,97 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Provider } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
import { Logger } from '@fxa/shared/log';
import {
CartIdInputFactory,
SetupCartInputFactory,
UpdateCartInputFactory,
} from './lib/factories';
import { CartManager } from '@fxa/payments/cart';
import { CartResolver } from './cart.resolver';
const fakeSetupCart = jest.fn();
const fakeRestartCart = jest.fn();
const fakeCheckoutCart = jest.fn();
const fakeUpdateCart = jest.fn();
jest.mock('@fxa/payments/cart', () => {
// Works and lets you check for constructor calls:
return {
CartManager: jest.fn().mockImplementation(() => {
return {
setupCart: fakeSetupCart,
restartCart: fakeRestartCart,
checkoutCart: fakeCheckoutCart,
updateCart: fakeUpdateCart,
};
}),
};
});
describe('#payments-cart - resolvers', () => {
let resolver: CartResolver;
const mockLogger: Logger = {
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
trace: jest.fn(),
warn: jest.fn(),
};
beforeEach(async () => {
(CartManager as jest.Mock).mockClear();
const MockMozLogger: Provider = {
provide: MozLoggerService,
useValue: mockLogger,
};
const module: TestingModule = await Test.createTestingModule({
providers: [CartResolver, MockMozLogger],
}).compile();
resolver = module.get<CartResolver>(CartResolver);
});
describe('setupCart', () => {
it('should successfully create a new cart', async () => {
const input = SetupCartInputFactory({
interval: 'annually',
});
await resolver.setupCart(input);
expect(fakeSetupCart).toBeCalledWith(expect.objectContaining(input));
});
});
describe('restartCart', () => {
it('should successfully set cart state to "START"', async () => {
const input = CartIdInputFactory();
await resolver.restartCart(input);
expect(fakeRestartCart).toBeCalledWith(input.id);
});
});
describe('checkoutCart', () => {
it('should successfully set cart state to "PROCESSING"', async () => {
const input = CartIdInputFactory();
await resolver.checkoutCart(input);
expect(fakeCheckoutCart).toBeCalledWith(input.id);
});
});
describe('updateCart', () => {
it('should successfully update an existing cart', async () => {
const input = UpdateCartInputFactory();
await resolver.updateCart(input);
expect(fakeUpdateCart).toBeCalledWith(input);
});
it('succeeds', () => {
expect(true).toEqual(true);
});
});
// import { Provider } from '@nestjs/common';
// import { Test, TestingModule } from '@nestjs/testing';
// import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
// import { Logger } from '@fxa/shared/log';
// import {
// CartIdInputFactory,
// SetupCartInputFactory,
// UpdateCartInputFactory,
// } from './lib/factories';
// import { CartManager } from '@fxa/payments/cart';
// import { CartResolver } from './cart.resolver';
// const fakeSetupCart = jest.fn();
// const fakeRestartCart = jest.fn();
// const fakeCheckoutCart = jest.fn();
// const fakeUpdateCart = jest.fn();
// jest.mock('@fxa/payments/cart', () => {
// // Works and lets you check for constructor calls:
// return {
// CartManager: jest.fn().mockImplementation(() => {
// return {
// setupCart: fakeSetupCart,
// restartCart: fakeRestartCart,
// checkoutCart: fakeCheckoutCart,
// updateCart: fakeUpdateCart,
// };
// }),
// };
// });
// describe.skip('#payments-cart - resolvers', () => {
// let resolver: CartResolver;
// const mockLogger: Logger = {
// debug: jest.fn(),
// error: jest.fn(),
// info: jest.fn(),
// trace: jest.fn(),
// warn: jest.fn(),
// };
// beforeEach(async () => {
// (CartManager as jest.Mock).mockClear();
// const MockMozLogger: Provider = {
// provide: MozLoggerService,
// useValue: mockLogger,
// };
// const module: TestingModule = await Test.createTestingModule({
// providers: [CartResolver, MockMozLogger],
// }).compile();
// resolver = module.get<CartResolver>(CartResolver);
// });
// describe('setupCart', () => {
// it('should successfully create a new cart', async () => {
// const input = SetupCartInputFactory({
// interval: 'annually',
// });
// await resolver.setupCart(input);
// expect(fakeSetupCart).toBeCalledWith(expect.objectContaining(input));
// });
// });
// describe('restartCart', () => {
// it('should successfully set cart state to "START"', async () => {
// const input = CartIdInputFactory();
// await resolver.restartCart(input);
// expect(fakeRestartCart).toBeCalledWith(input.id);
// });
// });
// describe('checkoutCart', () => {
// it('should successfully set cart state to "PROCESSING"', async () => {
// const input = CartIdInputFactory();
// await resolver.checkoutCart(input);
// expect(fakeCheckoutCart).toBeCalledWith(input.id);
// });
// });
// describe('updateCart', () => {
// it('should successfully update an existing cart', async () => {
// const input = UpdateCartInputFactory();
// await resolver.updateCart(input);
// expect(fakeUpdateCart).toBeCalledWith(input);
// });
// });
// });

Просмотреть файл

@ -3,9 +3,6 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
import { Cart } from '@fxa/shared/db/mysql/account';
import { InvoiceFactory } from './lib/factories';
import { CartManager } from '@fxa/payments/cart';
import { Cart as CartType } from './model/cart.model';
import { SetupCartInput } from './dto/input/setup-cart.input';
import { CartIdInput } from './dto/input/cart-id.input';
@ -13,24 +10,11 @@ import { UpdateCartInput } from './dto/input/update-cart.input';
@Resolver((of: any) => CartType)
export class CartResolver {
private cartManager: CartManager;
constructor(private log: MozLoggerService) {
this.cartManager = new CartManager(this.log);
}
constructor(private log: MozLoggerService) {}
@Query((returns) => CartType, { nullable: true })
public async cart(): Promise<CartType | null> {
// Query just for testing purposes
// TODO - To be done in FXA-7521
const cart = await Cart.query().first();
if (!cart) {
return null;
}
return {
...cart,
nextInvoice: InvoiceFactory(), // Temporary
};
return null;
}
@Mutation((returns) => CartType, { nullable: true })
@ -38,9 +22,7 @@ export class CartResolver {
@Args('input', { type: () => SetupCartInput })
input: SetupCartInput
): Promise<CartType | null> {
return this.cartManager.setupCart({
...input,
});
return null;
}
@Mutation((returns) => CartType, { nullable: true })
@ -48,7 +30,7 @@ export class CartResolver {
@Args('input', { type: () => CartIdInput })
input: CartIdInput
): Promise<CartType | null> {
return this.cartManager.restartCart(input.id);
return null;
}
@Mutation((returns) => CartType, { nullable: true })
@ -56,7 +38,7 @@ export class CartResolver {
@Args('input', { type: () => CartIdInput })
input: CartIdInput
): Promise<CartType | null> {
return this.cartManager.checkoutCart(input.id);
return null;
}
@Mutation((returns) => CartType, { nullable: true })
@ -64,6 +46,6 @@ export class CartResolver {
@Args('input', { type: () => UpdateCartInput })
input: UpdateCartInput
): Promise<CartType | null> {
return this.cartManager.updateCart(input);
return null;
}
}

Просмотреть файл

@ -67,13 +67,7 @@ export const GraphQLConfigFactory = async (
@Module({
imports: [BackendModule, CustomsModule],
providers: [
AccountResolver,
CustomsService,
SessionResolver,
LegalResolver,
CartResolver,
],
providers: [AccountResolver, CustomsService, SessionResolver, LegalResolver],
})
export class GqlModule implements NestModule {
configure(consumer: MiddlewareConsumer) {

Просмотреть файл

@ -31365,6 +31365,7 @@ fsevents@~2.1.1:
"@types/react": ^18
"@types/react-dom": ^18
"@types/react-test-renderer": ^18
"@types/uuid": ^8.3.0
"@typescript-eslint/eslint-plugin": ^5.59.1
"@typescript-eslint/parser": ^5.59.1
autoprefixer: ^10.4.14
@ -31415,6 +31416,7 @@ fsevents@~2.1.1:
ts-node: ^10.9.1
tslib: ^2.5.0
typescript: ^5.0.4
uuid: ^9.0.0
languageName: unknown
linkType: soft