Skip to content

Commit

Permalink
fix a bunch of edge cases around listings and orders (#1075)
Browse files Browse the repository at this point in the history
Co-authored-by: takaro-ci-bot[bot] <138661031+takaro-ci-bot[bot]@users.noreply.github.com>
  • Loading branch information
niekcandaele and takaro-ci-bot[bot] authored Jul 18, 2024
1 parent 19be204 commit 19ba462
Show file tree
Hide file tree
Showing 14 changed files with 369 additions and 52 deletions.
8 changes: 7 additions & 1 deletion packages/app-api/src/controllers/Shop/Listing.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IsNumber, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
import { IsBoolean, IsISO8601, IsNumber, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
import { ITakaroQuery } from '@takaro/db';
import { APIOutput, apiResponse } from '@takaro/http';
import { ShopListingService } from '../../service/Shop/index.js';
Expand Down Expand Up @@ -37,12 +37,18 @@ class ShopListingSearchInputAllowedFilters {
@IsOptional()
@IsString({ each: true })
name: string[];
@IsOptional()
@IsBoolean()
draft: boolean;
}

class ShopSearchInputAllowedRangeFilter extends RangeFilterCreatedAndUpdatedAt {
@IsOptional()
@IsNumber()
price: number;
@IsOptional()
@IsISO8601()
deletedAt!: string;
}

class ShopListingSearchInputDTO extends ITakaroQuery<ShopListingSearchInputAllowedFilters> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ const tests = [
group,
snapshot: true,
name: 'Delete',
filteredFields: ['itemId', 'gameServerId', 'gameserverId', 'listingId'],
setup: shopSetup,
expectedStatus: 404,
test: async function () {
Expand Down Expand Up @@ -153,6 +154,19 @@ const tests = [
});
},
}),
// Should not include deleted listings in search
new IntegrationTest<IShopSetup>({
group,
snapshot: true,
name: 'Search with deleted listing',
setup: shopSetup,
test: async function () {
await this.client.shopListing.shopListingControllerDelete(this.setupData.listing.id);
const res = await this.client.shopListing.shopListingControllerSearch({});
expect(res.data.data.length).to.be.equal(0);
return res;
},
}),
];

describe(group, function () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,175 @@ const tests = [
}
},
}),
new IntegrationTest<IShopSetup>({
group,
snapshot: false,
name: 'Cancelling an order returns the currency',
setup: shopSetup,
test: async function () {
await this.client.playerOnGameserver.playerOnGameServerControllerSetCurrency(
this.setupData.gameServer1.id,
this.setupData.pogs1[0].playerId,
{ currency: 250 }
);

const orderRes = await this.setupData.client1.shopOrder.shopOrderControllerCreate({
listingId: this.setupData.listing100.id,
amount: 1,
});

const order = orderRes.data.data;

const pogsResBefore = await this.client.playerOnGameserver.playerOnGameServerControllerGetOne(
this.setupData.gameServer1.id,
this.setupData.pogs1[0].playerId
);

expect(pogsResBefore.data.data.currency).to.be.eq(150);

await this.setupData.client1.shopOrder.shopOrderControllerCancel(order.id);

const pogResAfter = await this.client.playerOnGameserver.playerOnGameServerControllerGetOne(
this.setupData.gameServer1.id,
this.setupData.pogs1[0].playerId
);

expect(pogResAfter.data.data.currency).to.be.eq(250);

return pogResAfter;
},
}),
new IntegrationTest<IShopSetup>({
group,
snapshot: false,
name: 'Deleting a listing, cancels orders and refunds currency',
setup: shopSetup,
test: async function () {
await this.client.playerOnGameserver.playerOnGameServerControllerSetCurrency(
this.setupData.gameServer1.id,
this.setupData.pogs1[0].playerId,
{ currency: 250 }
);

const orderRes = await this.setupData.client1.shopOrder.shopOrderControllerCreate({
listingId: this.setupData.listing100.id,
amount: 1,
});

const order = orderRes.data.data;

const pogsResBefore = await this.client.playerOnGameserver.playerOnGameServerControllerGetOne(
this.setupData.gameServer1.id,
this.setupData.pogs1[0].playerId
);

expect(pogsResBefore.data.data.currency).to.be.eq(150);

await this.client.shopListing.shopListingControllerDelete(this.setupData.listing100.id);

const pogResAfter = await this.client.playerOnGameserver.playerOnGameServerControllerGetOne(
this.setupData.gameServer1.id,
this.setupData.pogs1[0].playerId
);

expect(pogResAfter.data.data.currency).to.be.eq(250);

const orderResAfter = await this.setupData.client1.shopOrder.shopOrderControllerGetOne(order.id);

expect(orderResAfter.data.data.status).to.be.eq(ShopOrderOutputDTOStatusEnum.Canceled);

return pogResAfter;
},
}),
new IntegrationTest<IShopSetup>({
group,
snapshot: false,
name: 'Cannot buy a deleted listing',
setup: shopSetup,
expectedStatus: 404,
test: async function () {
await this.client.shopListing.shopListingControllerDelete(this.setupData.listing100.id);

try {
await this.setupData.client1.shopOrder.shopOrderControllerCreate({
listingId: this.setupData.listing100.id,
amount: 1,
});
throw new Error('Should not be able to create order');
} catch (error) {
if (!isAxiosError(error)) throw error;
if (!error.response) throw error;
expect(error.response.data.meta.error.code).to.be.eq('NotFoundError');
return error.response;
}
},
}),
new IntegrationTest<IShopSetup>({
group,
snapshot: false,
name: 'Cannot buy a draft listing',
setup: shopSetup,
expectedStatus: 400,
test: async function () {
await this.client.shopListing.shopListingControllerUpdate(this.setupData.listing100.id, { draft: true });

try {
await this.setupData.client1.shopOrder.shopOrderControllerCreate({
listingId: this.setupData.listing100.id,
amount: 1,
});
throw new Error('Should not be able to create order');
} catch (error) {
if (!isAxiosError(error)) throw error;
if (!error.response) throw error;
expect(error.response.data.meta.error.code).to.be.eq('BadRequestError');
expect(error.response.data.meta.error.message).to.be.eq('Cannot order a draft listing');
return error.response;
}
},
}),
new IntegrationTest<IShopSetup>({
group,
snapshot: false,
name: 'Setting a listing to draft cancels pending orders',
setup: shopSetup,
test: async function () {
await this.client.playerOnGameserver.playerOnGameServerControllerSetCurrency(
this.setupData.gameServer1.id,
this.setupData.pogs1[0].playerId,
{ currency: 250 }
);

const orderRes = await this.setupData.client1.shopOrder.shopOrderControllerCreate({
listingId: this.setupData.listing100.id,
amount: 1,
});

const order = orderRes.data.data;

const pogsResBefore = await this.client.playerOnGameserver.playerOnGameServerControllerGetOne(
this.setupData.gameServer1.id,
this.setupData.pogs1[0].playerId
);

expect(pogsResBefore.data.data.currency).to.be.eq(150);

await this.client.shopListing.shopListingControllerUpdate(this.setupData.listing100.id, { draft: true });

const pogResAfter = await this.client.playerOnGameserver.playerOnGameServerControllerGetOne(
this.setupData.gameServer1.id,
this.setupData.pogs1[0].playerId
);

expect(pogResAfter.data.data.currency).to.be.eq(250);

const orderResAfter = await this.setupData.client1.shopOrder.shopOrderControllerGetOne(order.id);

expect(orderResAfter.data.data.status).to.be.eq(ShopOrderOutputDTOStatusEnum.Canceled);

return pogResAfter;
},
}),
];

describe(group, function () {
Expand Down
14 changes: 8 additions & 6 deletions packages/app-api/src/db/shopListing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export class ShopListingModel extends TakaroModel {
price!: number;
name?: string;

deletedAt?: Date;
draft: boolean;

items: ItemsModel[];

static get relationMappings() {
Expand Down Expand Up @@ -172,7 +175,7 @@ export class ShopListingRepo extends ITakaroRepo<

async find(filters: ITakaroQuery<ShopListingOutputDTO>) {
const { query } = await this.getModel();

query.where('deletedAt', null);
const result = await new QueryBuilder<ShopListingModel, ShopListingOutputDTO>({
...filters,
extend: [...(filters.extend || []), 'items.item'],
Expand All @@ -187,9 +190,8 @@ export class ShopListingRepo extends ITakaroRepo<
async findOne(id: string): Promise<ShopListingOutputDTO> {
const { query } = await this.getModel();
const res = await query.findById(id).withGraphFetched('items.item');
if (!res) {
throw new errors.NotFoundError();
}
if (!res) throw new errors.NotFoundError();
if (res.deletedAt) throw new errors.NotFoundError();
return new ShopListingOutputDTO(res);
}

Expand Down Expand Up @@ -226,8 +228,8 @@ export class ShopListingRepo extends ITakaroRepo<
if (!existing) throw new errors.NotFoundError();

const { query } = await this.getModel();
const data = await query.deleteById(id);
return !!data;
await query.updateAndFetchById(id, { deletedAt: new Date() });
return true;
}

async addRole(listingId: string, roleId: string): Promise<ShopListingOutputDTO> {
Expand Down
26 changes: 25 additions & 1 deletion packages/app-api/src/service/Shop/dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
import { IsUUID, IsOptional, IsNumber, IsString, IsEnum, ValidateNested, Min } from 'class-validator';
import {
IsUUID,
IsOptional,
IsNumber,
IsString,
IsEnum,
ValidateNested,
Min,
IsISO8601,
IsBoolean,
} from 'class-validator';
import { TakaroModelDTO, TakaroDTO } from '@takaro/util';
import { Type } from 'class-transformer';
import { ItemsOutputDTO } from '../ItemsService.js';
Expand Down Expand Up @@ -37,6 +47,11 @@ export class ShopListingOutputDTO extends TakaroModelDTO<ShopListingOutputDTO> {
@IsString()
@IsOptional()
name?: string;
@IsISO8601()
@IsOptional()
deletedAt?: Date;
@IsBoolean()
draft: boolean;
}

export class ShopListingCreateDTO<T = void> extends TakaroDTO<T> {
Expand All @@ -50,19 +65,28 @@ export class ShopListingCreateDTO<T = void> extends TakaroDTO<T> {
@IsString()
@IsOptional()
name?: string;
@IsBoolean()
@IsOptional()
draft?: boolean;
}

export class ShopListingUpdateDTO extends TakaroDTO<ShopListingUpdateDTO> {
@IsUUID()
@IsOptional()
gameServerId!: string;
@ValidateNested({ each: true })
@Type(() => ShopListingItemMetaInputDTO)
@IsOptional()
items: ShopListingItemMetaInputDTO[];
@IsNumber()
@IsOptional()
price!: number;
@IsString()
@IsOptional()
name?: string;
@IsBoolean()
@IsOptional()
draft?: boolean;
}

export enum ShopOrderStatus {
Expand Down
21 changes: 21 additions & 0 deletions packages/app-api/src/service/Shop/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ export class ShopListingService extends TakaroService<
}

async update(id: string, item: ShopListingUpdateDTO): Promise<ShopListingOutputDTO> {
const existing = await this.findOne(id);

// Going from non-draft to draft means we need to cancel all pending orders
if (!existing.draft && item.draft) {
const orders = await this.orderRepo.find({ filters: { listingId: [id], status: [ShopOrderStatus.PAID] } });
await Promise.allSettled(orders.results.map((order) => this.cancelOrder(order.id)));
}

const updated = await this.repo.update(id, item);

await this.eventService.create(
Expand All @@ -110,6 +118,10 @@ export class ShopListingService extends TakaroService<
}

async delete(id: string): Promise<string> {
// Find all related orders and cancel them
const orders = await this.orderRepo.find({ filters: { listingId: [id], status: [ShopOrderStatus.PAID] } });
await Promise.allSettled(orders.results.map((order) => this.cancelOrder(order.id)));

await this.repo.delete(id);

await this.eventService.create(
Expand Down Expand Up @@ -161,6 +173,8 @@ export class ShopListingService extends TakaroService<
);

const listing = await this.findOne(listingId);
if (listing.draft) throw new errors.BadRequestError('Cannot order a draft listing');
if (listing.deletedAt) throw new errors.BadRequestError('Cannot order a deleted listing');
const gameServerId = listing.gameServerId;

const playerService = new PlayerService(this.domainId);
Expand Down Expand Up @@ -280,6 +294,13 @@ export class ShopListingService extends TakaroService<

const user = await new UserService(this.domainId).findOne(order.userId);

// Refund the player
const pogsService = new PlayerOnGameServerService(this.domainId);
const pog = (await pogsService.find({ filters: { playerId: [user.playerId], gameServerId: [gameServerId] } }))
.results[0];
if (!pog) throw new errors.NotFoundError('Player not found');
await pogsService.addCurrency(pog.id, listing.price * order.amount);

await this.eventService.create(
new EventCreateDTO({
eventName: EVENT_TYPES.SHOP_ORDER_STATUS_CHANGED,
Expand Down
Loading

0 comments on commit 19ba462

Please sign in to comment.