diff --git a/nftopia-backend/src/graphql/graphql.module.ts b/nftopia-backend/src/graphql/graphql.module.ts index 03f2ddfb..2e6f6e1c 100644 --- a/nftopia-backend/src/graphql/graphql.module.ts +++ b/nftopia-backend/src/graphql/graphql.module.ts @@ -9,6 +9,10 @@ import { JwtStrategy } from '../auth/jwt.strategy'; import { GqlAuthGuard } from '../common/guards/gql-auth.guard'; import { CollectionModule } from '../modules/collection/collection.module'; import { NftModule } from '../modules/nft/nft.module'; +import { ListingModule } from '../modules/listing/listing.module'; +import { AuctionModule } from '../modules/auction/auction.module'; +import { OrderModule } from '../modules/order/order.module'; +import { UsersModule } from '../users/users.module'; import { SearchModule } from '../search/search.module'; import { GraphqlContextFactory } from './context/context.factory'; import { GraphqlAuthMiddleware } from './middleware/auth.middleware'; @@ -49,6 +53,10 @@ const jwtAccessExpiresInSeconds = parseInt( }), CollectionModule, NftModule, + ListingModule, + AuctionModule, + OrderModule, + UsersModule, SearchModule, ], providers: [ diff --git a/nftopia-backend/src/graphql/inputs/auction.inputs.ts b/nftopia-backend/src/graphql/inputs/auction.inputs.ts new file mode 100644 index 00000000..3436b5eb --- /dev/null +++ b/nftopia-backend/src/graphql/inputs/auction.inputs.ts @@ -0,0 +1,76 @@ +import { Field, Float, ID, InputType } from '@nestjs/graphql'; +import { + IsEnum, + IsNumber, + IsOptional, + IsString, + IsUUID, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { AuctionStatus } from '../../modules/auction/interfaces/auction.interface'; + +@InputType() +export class AuctionFilterInput { + @Field(() => ID, { nullable: true }) + @IsOptional() + @IsUUID() + sellerId?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + nftContractId?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + nftTokenId?: string; + + @Field(() => AuctionStatus, { nullable: true }) + @IsOptional() + @IsEnum(AuctionStatus) + status?: AuctionStatus; +} + +@InputType() +export class CreateAuctionInput { + @Field() + @IsString() + nftContractId: string; + + @Field() + @IsString() + nftTokenId: string; + + @Field(() => Float) + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 7 }) + @Min(0.0000001) + startPrice: number; + + @Field(() => Float, { nullable: true }) + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 7 }) + @Min(0.0000001) + reservePrice?: number; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + startTime?: string; + + @Field() + @IsString() + endTime: string; +} + +@InputType() +export class PlaceBidInput { + @Field(() => Float) + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 7 }) + @Min(0.0000001) + amount: number; +} diff --git a/nftopia-backend/src/graphql/inputs/listing.inputs.ts b/nftopia-backend/src/graphql/inputs/listing.inputs.ts new file mode 100644 index 00000000..23b918c9 --- /dev/null +++ b/nftopia-backend/src/graphql/inputs/listing.inputs.ts @@ -0,0 +1,61 @@ +import { Field, Float, ID, InputType } from '@nestjs/graphql'; +import { + IsEnum, + IsNumber, + IsOptional, + IsString, + IsUUID, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ListingStatus } from '../../modules/listing/interfaces/listing.interface'; + +@InputType() +export class ListingFilterInput { + @Field(() => ID, { nullable: true }) + @IsOptional() + @IsUUID() + sellerId?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + nftContractId?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + nftTokenId?: string; + + @Field(() => ListingStatus, { nullable: true }) + @IsOptional() + @IsEnum(ListingStatus) + status?: ListingStatus; +} + +@InputType() +export class CreateListingInput { + @Field() + @IsString() + nftContractId: string; + + @Field() + @IsString() + nftTokenId: string; + + @Field(() => Float) + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 7 }) + @Min(0.0000001) + price: number; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + currency?: string; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + expiresAt?: string; +} diff --git a/nftopia-backend/src/graphql/inputs/order.inputs.ts b/nftopia-backend/src/graphql/inputs/order.inputs.ts new file mode 100644 index 00000000..2ead23d6 --- /dev/null +++ b/nftopia-backend/src/graphql/inputs/order.inputs.ts @@ -0,0 +1,34 @@ +import { Field, ID, InputType } from '@nestjs/graphql'; +import { IsEnum, IsOptional, IsUUID } from 'class-validator'; +import { + OrderStatus, + OrderType, +} from '../../modules/order/dto/create-order.dto'; + +@InputType() +export class OrderFilterInput { + @Field(() => ID, { nullable: true }) + @IsOptional() + @IsUUID() + nftId?: string; + + @Field(() => ID, { nullable: true }) + @IsOptional() + @IsUUID() + buyerId?: string; + + @Field(() => ID, { nullable: true }) + @IsOptional() + @IsUUID() + sellerId?: string; + + @Field(() => OrderType, { nullable: true }) + @IsOptional() + @IsEnum(OrderType) + type?: OrderType; + + @Field(() => OrderStatus, { nullable: true }) + @IsOptional() + @IsEnum(OrderStatus) + status?: OrderStatus; +} diff --git a/nftopia-backend/src/graphql/resolvers/auction.resolver.spec.ts b/nftopia-backend/src/graphql/resolvers/auction.resolver.spec.ts new file mode 100644 index 00000000..e278eca4 --- /dev/null +++ b/nftopia-backend/src/graphql/resolvers/auction.resolver.spec.ts @@ -0,0 +1,111 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AuctionResolver } from './auction.resolver'; +import { AuctionService } from '../../modules/auction/auction.service'; +import { AuctionStatus } from '../../modules/auction/interfaces/auction.interface'; + +const mockAuctionService = { + findOne: jest.fn(), + findAll: jest.fn(), + create: jest.fn(), + cancelAuction: jest.fn(), + placeBid: jest.fn(), +}; + +const baseAuction = { + id: 'auction-1', + nftContractId: 'C'.repeat(56), + nftTokenId: 'token-1', + sellerId: 'seller-1', + startPrice: 5.0, + currentPrice: 5.0, + reservePrice: undefined, + startTime: new Date('2026-03-20T10:00:00.000Z'), + endTime: new Date('2026-03-25T10:00:00.000Z'), + status: AuctionStatus.ACTIVE, + winnerId: undefined, + createdAt: new Date('2026-03-20T10:00:00.000Z'), + updatedAt: new Date('2026-03-20T10:00:00.000Z'), +}; + +describe('AuctionResolver', () => { + let resolver: AuctionResolver; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuctionResolver, + { provide: AuctionService, useValue: mockAuctionService }, + ], + }).compile(); + + resolver = module.get(AuctionResolver); + jest.clearAllMocks(); + }); + + it('returns a single auction by id', async () => { + mockAuctionService.findOne.mockResolvedValue(baseAuction); + + const result = await resolver.auction('auction-1'); + + expect(mockAuctionService.findOne).toHaveBeenCalledWith('auction-1'); + expect(result.id).toBe('auction-1'); + expect(result.status).toBe(AuctionStatus.ACTIVE); + }); + + it('returns an auction connection from findAll', async () => { + mockAuctionService.findAll.mockResolvedValue([baseAuction]); + + const result = await resolver.auctions({ first: 5 }, { status: AuctionStatus.ACTIVE }); + + expect(result.edges).toHaveLength(1); + expect(result.totalCount).toBe(1); + expect(result.edges[0].cursor).toEqual(expect.any(String)); + }); + + it('creates an auction for authenticated caller', async () => { + mockAuctionService.create.mockResolvedValue(baseAuction); + + const result = await resolver.createAuction( + { + nftContractId: 'C'.repeat(56), + nftTokenId: 'token-1', + startPrice: 5.0, + endTime: '2026-03-25T10:00:00.000Z', + }, + { req: {} as never, res: {} as never, user: { userId: 'seller-1' } }, + ); + + expect(mockAuctionService.create).toHaveBeenCalledWith( + expect.objectContaining({ startPrice: 5.0 }), + 'seller-1', + ); + expect(result.sellerId).toBe('seller-1'); + }); + + it('rejects createAuction when unauthenticated', async () => { + await expect( + resolver.createAuction( + { nftContractId: 'C'.repeat(56), nftTokenId: 'token-1', startPrice: 5.0, endTime: '2026-03-25T10:00:00.000Z' }, + { req: {} as never, res: {} as never }, + ), + ).rejects.toThrow(UnauthorizedException); + }); + + it('places a bid and returns updated auction', async () => { + mockAuctionService.placeBid.mockResolvedValue({}); + mockAuctionService.findOne.mockResolvedValue({ + ...baseAuction, + currentPrice: 12.0, + }); + + const result = await resolver.placeBid( + 'auction-1', + { amount: 12.0 }, + { req: {} as never, res: {} as never, user: { userId: 'bidder-1' } }, + ); + + expect(mockAuctionService.placeBid).toHaveBeenCalledWith('auction-1', 'bidder-1', { amount: 12.0 }); + expect(result.currentPrice).toBe(12.0); + }); +}); diff --git a/nftopia-backend/src/graphql/resolvers/auction.resolver.ts b/nftopia-backend/src/graphql/resolvers/auction.resolver.ts new file mode 100644 index 00000000..bb83219f --- /dev/null +++ b/nftopia-backend/src/graphql/resolvers/auction.resolver.ts @@ -0,0 +1,166 @@ +import { + Args, + Context, + ID, + Mutation, + Query, + Resolver, +} from '@nestjs/graphql'; +import { UnauthorizedException, UseGuards } from '@nestjs/common'; +import { GqlAuthGuard } from '../../common/guards/gql-auth.guard'; +import type { GraphqlContext } from '../context/context.interface'; +import { AuctionService } from '../../modules/auction/auction.service'; +import type { Auction } from '../../modules/auction/entities/auction.entity'; +import { AuctionStatus } from '../../modules/auction/interfaces/auction.interface'; +import { GraphqlAuction, AuctionConnection } from '../types/auction.types'; +import { + AuctionFilterInput, + CreateAuctionInput, + PlaceBidInput, +} from '../inputs/auction.inputs'; +import { PaginationInput } from '../inputs/nft.inputs'; +import { PageInfo } from '../types/nft.types'; + +@Resolver(() => GraphqlAuction) +export class AuctionResolver { + constructor(private readonly auctionService: AuctionService) {} + + @Query(() => GraphqlAuction, { + name: 'auction', + description: 'Fetch a single auction by ID', + }) + async auction( + @Args('id', { type: () => ID }) id: string, + ): Promise { + const auction = await this.auctionService.findOne(id); + return this.toGraphqlAuction(auction); + } + + @Query(() => AuctionConnection, { + name: 'auctions', + description: 'Fetch auctions with pagination and optional filters', + }) + async auctions( + @Args('pagination', { type: () => PaginationInput, nullable: true }) + pagination?: PaginationInput, + @Args('filter', { type: () => AuctionFilterInput, nullable: true }) + filter?: AuctionFilterInput, + ): Promise { + const limit = pagination?.first ?? 20; + + const items = await this.auctionService.findAll({ + status: filter?.status, + sellerId: filter?.sellerId, + nftContractId: filter?.nftContractId, + nftTokenId: filter?.nftTokenId, + page: 1, + limit, + }); + + return this.toConnection(items, items.length, false); + } + + @UseGuards(GqlAuthGuard) + @Mutation(() => GraphqlAuction, { + name: 'createAuction', + description: 'Create a new NFT auction', + }) + async createAuction( + @Args('input', { type: () => CreateAuctionInput }) input: CreateAuctionInput, + @Context() context: GraphqlContext, + ): Promise { + const callerId = this.getAuthenticatedUserId(context); + const auction = await this.auctionService.create( + { + nftContractId: input.nftContractId, + nftTokenId: input.nftTokenId, + startPrice: input.startPrice, + reservePrice: input.reservePrice, + startTime: input.startTime, + endTime: input.endTime, + }, + callerId, + ); + return this.toGraphqlAuction(auction); + } + + @UseGuards(GqlAuthGuard) + @Mutation(() => GraphqlAuction, { + name: 'cancelAuction', + description: 'Cancel an active auction', + }) + async cancelAuction( + @Args('id', { type: () => ID }) id: string, + @Context() context: GraphqlContext, + ): Promise { + const callerId = this.getAuthenticatedUserId(context); + const auction = await this.auctionService.cancelAuction(id, callerId); + return this.toGraphqlAuction(auction); + } + + @UseGuards(GqlAuthGuard) + @Mutation(() => GraphqlAuction, { + name: 'placeBid', + description: 'Place a bid on an active auction', + }) + async placeBid( + @Args('auctionId', { type: () => ID }) auctionId: string, + @Args('input', { type: () => PlaceBidInput }) input: PlaceBidInput, + @Context() context: GraphqlContext, + ): Promise { + const callerId = this.getAuthenticatedUserId(context); + await this.auctionService.placeBid(auctionId, callerId, { + amount: input.amount, + }); + const auction = await this.auctionService.findOne(auctionId); + return this.toGraphqlAuction(auction); + } + + private getAuthenticatedUserId(context: GraphqlContext): string { + const userId = context.user?.userId; + if (!userId) { + throw new UnauthorizedException('Authentication is required'); + } + return userId; + } + + private toConnection( + items: Auction[], + totalCount: number, + hasNextPage: boolean, + ): AuctionConnection { + const edges = items.map((a) => ({ + node: this.toGraphqlAuction(a), + cursor: Buffer.from(a.createdAt.toISOString() + ':' + a.id, 'utf8').toString('base64url'), + })); + + return { + edges, + pageInfo: { + hasNextPage, + startCursor: edges[0]?.cursor, + endCursor: edges.at(-1)?.cursor, + } as PageInfo, + totalCount, + }; + } + + private toGraphqlAuction(auction: Auction): GraphqlAuction { + return { + id: auction.id, + nftContractId: auction.nftContractId, + nftTokenId: auction.nftTokenId, + sellerId: auction.sellerId, + startPrice: Number(auction.startPrice), + currentPrice: Number(auction.currentPrice), + reservePrice: + auction.reservePrice != null ? Number(auction.reservePrice) : undefined, + startTime: auction.startTime, + endTime: auction.endTime, + status: auction.status, + winnerId: auction.winnerId, + createdAt: auction.createdAt, + updatedAt: auction.updatedAt, + }; + } +} diff --git a/nftopia-backend/src/graphql/resolvers/index.ts b/nftopia-backend/src/graphql/resolvers/index.ts index e8248d12..a6ee59c5 100644 --- a/nftopia-backend/src/graphql/resolvers/index.ts +++ b/nftopia-backend/src/graphql/resolvers/index.ts @@ -1,12 +1,20 @@ import { BaseResolver } from './base.resolver'; import { CollectionResolver } from './collection.resolver'; import { NftResolver } from './nft.resolver'; +import { ListingResolver } from './listing.resolver'; +import { AuctionResolver } from './auction.resolver'; +import { OrderResolver } from './order.resolver'; +import { UserResolver } from './user.resolver'; import { JsonScalar } from '../types/nft.types'; export const graphqlResolvers = [ BaseResolver, NftResolver, CollectionResolver, + ListingResolver, + AuctionResolver, + OrderResolver, + UserResolver, ] as const; export const graphqlScalarClasses = [JsonScalar] as const; @@ -14,4 +22,8 @@ export const graphqlScalarClasses = [JsonScalar] as const; export { BaseResolver }; export { CollectionResolver }; export { NftResolver }; +export { ListingResolver }; +export { AuctionResolver }; +export { OrderResolver }; +export { UserResolver }; export { JsonScalar }; diff --git a/nftopia-backend/src/graphql/resolvers/listing.resolver.spec.ts b/nftopia-backend/src/graphql/resolvers/listing.resolver.spec.ts new file mode 100644 index 00000000..02dda0e9 --- /dev/null +++ b/nftopia-backend/src/graphql/resolvers/listing.resolver.spec.ts @@ -0,0 +1,103 @@ +import { NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ListingResolver } from './listing.resolver'; +import { ListingService } from '../../modules/listing/listing.service'; +import { ListingStatus } from '../../modules/listing/interfaces/listing.interface'; + +const mockListingService = { + findOne: jest.fn(), + findAll: jest.fn(), + create: jest.fn(), + cancel: jest.fn(), +}; + +const baseListing = { + id: 'listing-1', + nftContractId: 'C'.repeat(56), + nftTokenId: 'token-1', + sellerId: 'seller-1', + price: 10.5, + currency: 'XLM', + status: 'ACTIVE', + expiresAt: undefined, + createdAt: new Date('2026-03-20T10:00:00.000Z'), + updatedAt: new Date('2026-03-20T10:00:00.000Z'), +}; + +describe('ListingResolver', () => { + let resolver: ListingResolver; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ListingResolver, + { provide: ListingService, useValue: mockListingService }, + ], + }).compile(); + + resolver = module.get(ListingResolver); + jest.clearAllMocks(); + }); + + it('returns a single listing by id', async () => { + mockListingService.findOne.mockResolvedValue(baseListing); + + const result = await resolver.listing('listing-1'); + + expect(mockListingService.findOne).toHaveBeenCalledWith('listing-1'); + expect(result.id).toBe('listing-1'); + expect(result.status).toBe(ListingStatus.ACTIVE); + expect(result.price).toBe(10.5); + }); + + it('returns a listing connection from findAll', async () => { + mockListingService.findAll.mockResolvedValue([baseListing]); + + const result = await resolver.listings({ first: 10 }, { status: ListingStatus.ACTIVE }); + + expect(result.edges).toHaveLength(1); + expect(result.totalCount).toBe(1); + expect(result.edges[0].node.id).toBe('listing-1'); + expect(result.edges[0].cursor).toEqual(expect.any(String)); + }); + + it('creates a listing for authenticated caller', async () => { + mockListingService.create.mockResolvedValue(baseListing); + + const result = await resolver.createListing( + { nftContractId: 'C'.repeat(56), nftTokenId: 'token-1', price: 10.5 }, + { req: {} as never, res: {} as never, user: { userId: 'seller-1' } }, + ); + + expect(mockListingService.create).toHaveBeenCalledWith( + expect.objectContaining({ price: 10.5 }), + 'seller-1', + ); + expect(result.sellerId).toBe('seller-1'); + }); + + it('rejects createListing when unauthenticated', async () => { + await expect( + resolver.createListing( + { nftContractId: 'C'.repeat(56), nftTokenId: 'token-1', price: 10.5 }, + { req: {} as never, res: {} as never }, + ), + ).rejects.toThrow(UnauthorizedException); + }); + + it('cancels a listing for authenticated caller', async () => { + mockListingService.cancel.mockResolvedValue({ + ...baseListing, + status: 'CANCELLED', + }); + + const result = await resolver.cancelListing('listing-1', { + req: {} as never, + res: {} as never, + user: { userId: 'seller-1' }, + }); + + expect(mockListingService.cancel).toHaveBeenCalledWith('listing-1', 'seller-1'); + expect(result.status).toBe(ListingStatus.CANCELLED); + }); +}); diff --git a/nftopia-backend/src/graphql/resolvers/listing.resolver.ts b/nftopia-backend/src/graphql/resolvers/listing.resolver.ts new file mode 100644 index 00000000..2ef9f95a --- /dev/null +++ b/nftopia-backend/src/graphql/resolvers/listing.resolver.ts @@ -0,0 +1,143 @@ +import { + Args, + Context, + ID, + Mutation, + Query, + Resolver, +} from '@nestjs/graphql'; +import { UnauthorizedException, UseGuards } from '@nestjs/common'; +import { GqlAuthGuard } from '../../common/guards/gql-auth.guard'; +import type { GraphqlContext } from '../context/context.interface'; +import { ListingService } from '../../modules/listing/listing.service'; +import type { Listing } from '../../modules/listing/entities/listing.entity'; +import { ListingStatus } from '../../modules/listing/interfaces/listing.interface'; +import { GraphqlListing, ListingConnection } from '../types/listing.types'; +import { + CreateListingInput, + ListingFilterInput, +} from '../inputs/listing.inputs'; +import { PaginationInput } from '../inputs/nft.inputs'; +import { PageInfo } from '../types/nft.types'; + +@Resolver(() => GraphqlListing) +export class ListingResolver { + constructor(private readonly listingService: ListingService) {} + + @Query(() => GraphqlListing, { + name: 'listing', + description: 'Fetch a single listing by ID', + }) + async listing( + @Args('id', { type: () => ID }) id: string, + ): Promise { + const listing = await this.listingService.findOne(id); + return this.toGraphqlListing(listing); + } + + @Query(() => ListingConnection, { + name: 'listings', + description: 'Fetch listings with pagination and optional filters', + }) + async listings( + @Args('pagination', { type: () => PaginationInput, nullable: true }) + pagination?: PaginationInput, + @Args('filter', { type: () => ListingFilterInput, nullable: true }) + filter?: ListingFilterInput, + ): Promise { + const limit = pagination?.first ?? 20; + const page = 1; + + const items = await this.listingService.findAll({ + status: filter?.status, + sellerId: filter?.sellerId, + nftContractId: filter?.nftContractId, + nftTokenId: filter?.nftTokenId, + page, + limit, + }); + + return this.toConnection(items, items.length, false); + } + + @UseGuards(GqlAuthGuard) + @Mutation(() => GraphqlListing, { + name: 'createListing', + description: 'Create a new NFT listing', + }) + async createListing( + @Args('input', { type: () => CreateListingInput }) input: CreateListingInput, + @Context() context: GraphqlContext, + ): Promise { + const callerId = this.getAuthenticatedUserId(context); + const listing = await this.listingService.create( + { + nftContractId: input.nftContractId, + nftTokenId: input.nftTokenId, + price: input.price, + currency: input.currency, + expiresAt: input.expiresAt, + }, + callerId, + ); + return this.toGraphqlListing(listing); + } + + @UseGuards(GqlAuthGuard) + @Mutation(() => GraphqlListing, { + name: 'cancelListing', + description: 'Cancel an active listing', + }) + async cancelListing( + @Args('id', { type: () => ID }) id: string, + @Context() context: GraphqlContext, + ): Promise { + const callerId = this.getAuthenticatedUserId(context); + const listing = await this.listingService.cancel(id, callerId); + return this.toGraphqlListing(listing); + } + + private getAuthenticatedUserId(context: GraphqlContext): string { + const userId = context.user?.userId; + if (!userId) { + throw new UnauthorizedException('Authentication is required'); + } + return userId; + } + + private toConnection( + items: Listing[], + totalCount: number, + hasNextPage: boolean, + ): ListingConnection { + const edges = items.map((l) => ({ + node: this.toGraphqlListing(l), + cursor: Buffer.from(l.createdAt.toISOString() + ':' + l.id, 'utf8').toString('base64url'), + })); + + return { + edges, + pageInfo: { + hasNextPage, + startCursor: edges[0]?.cursor, + endCursor: edges.at(-1)?.cursor, + } as PageInfo, + totalCount, + }; + } + + private toGraphqlListing(listing: Listing): GraphqlListing { + return { + id: listing.id, + nftContractId: listing.nftContractId, + nftTokenId: listing.nftTokenId, + sellerId: listing.sellerId, + price: Number(listing.price), + currency: listing.currency, + status: listing.status as ListingStatus, + expiresAt: listing.expiresAt, + createdAt: listing.createdAt, + updatedAt: listing.updatedAt, + }; + } +} diff --git a/nftopia-backend/src/graphql/resolvers/order.resolver.spec.ts b/nftopia-backend/src/graphql/resolvers/order.resolver.spec.ts new file mode 100644 index 00000000..bc424b64 --- /dev/null +++ b/nftopia-backend/src/graphql/resolvers/order.resolver.spec.ts @@ -0,0 +1,76 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { OrderResolver } from './order.resolver'; +import { OrderService } from '../../modules/order/order.service'; +import { OrderStatus, OrderType } from '../../modules/order/dto/create-order.dto'; + +const mockOrderService = { + findOne: jest.fn(), + findAll: jest.fn(), + getStats: jest.fn(), +}; + +const baseOrder = { + id: 'order-1', + nftId: 'nft-1', + buyerId: 'buyer-1', + sellerId: 'seller-1', + price: '10.5000000', + currency: 'XLM', + type: OrderType.SALE, + status: OrderStatus.COMPLETED, + transactionHash: 'abc123', + listingId: 'listing-1', + auctionId: undefined, + createdAt: new Date('2026-03-20T10:00:00.000Z'), +}; + +describe('OrderResolver', () => { + let resolver: OrderResolver; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + OrderResolver, + { provide: OrderService, useValue: mockOrderService }, + ], + }).compile(); + + resolver = module.get(OrderResolver); + jest.clearAllMocks(); + }); + + it('returns a single order by id', async () => { + mockOrderService.findOne.mockResolvedValue(baseOrder); + + const result = await resolver.order('order-1'); + + expect(mockOrderService.findOne).toHaveBeenCalledWith('order-1'); + expect(result.id).toBe('order-1'); + expect(result.type).toBe(OrderType.SALE); + expect(result.status).toBe(OrderStatus.COMPLETED); + }); + + it('returns an order connection from findAll', async () => { + mockOrderService.findAll.mockResolvedValue([baseOrder]); + + const result = await resolver.orders({ first: 10 }, { buyerId: 'buyer-1' }); + + expect(result.edges).toHaveLength(1); + expect(result.totalCount).toBe(1); + expect(result.edges[0].cursor).toEqual(expect.any(String)); + }); + + it('returns order stats for an NFT', async () => { + mockOrderService.getStats.mockResolvedValue({ + volume: '105.0000000', + count: 10, + averagePrice: '10.5000000', + }); + + const result = await resolver.orderStats('nft-1'); + + expect(mockOrderService.getStats).toHaveBeenCalledWith('nft-1'); + expect(result.count).toBe(10); + expect(result.volume).toBe('105.0000000'); + }); +}); diff --git a/nftopia-backend/src/graphql/resolvers/order.resolver.ts b/nftopia-backend/src/graphql/resolvers/order.resolver.ts new file mode 100644 index 00000000..6f1cb16e --- /dev/null +++ b/nftopia-backend/src/graphql/resolvers/order.resolver.ts @@ -0,0 +1,104 @@ +import { Args, ID, Query, Resolver } from '@nestjs/graphql'; +import { OrderService } from '../../modules/order/order.service'; +import { OrderInterface } from '../../modules/order/interfaces/order.interface'; +import { + OrderType, + OrderStatus, +} from '../../modules/order/dto/create-order.dto'; +import { + GraphqlOrder, + GraphqlOrderStats, + OrderConnection, +} from '../types/order.types'; +import { OrderFilterInput } from '../inputs/order.inputs'; +import { PaginationInput } from '../inputs/nft.inputs'; +import { PageInfo } from '../types/nft.types'; + +@Resolver(() => GraphqlOrder) +export class OrderResolver { + constructor(private readonly orderService: OrderService) {} + + @Query(() => GraphqlOrder, { + name: 'order', + description: 'Fetch a single order by ID', + }) + async order( + @Args('id', { type: () => ID }) id: string, + ): Promise { + const order = await this.orderService.findOne(id); + return this.toGraphqlOrder(order); + } + + @Query(() => OrderConnection, { + name: 'orders', + description: 'Fetch orders with pagination and optional filters', + }) + async orders( + @Args('pagination', { type: () => PaginationInput, nullable: true }) + pagination?: PaginationInput, + @Args('filter', { type: () => OrderFilterInput, nullable: true }) + filter?: OrderFilterInput, + ): Promise { + const limit = pagination?.first ?? 20; + + const items = await this.orderService.findAll({ + nftId: filter?.nftId, + buyerId: filter?.buyerId, + sellerId: filter?.sellerId, + type: filter?.type, + status: filter?.status, + page: 1, + limit, + }); + + return this.toConnection(items, items.length, false); + } + + @Query(() => GraphqlOrderStats, { + name: 'orderStats', + description: 'Fetch aggregated order statistics for an NFT', + }) + async orderStats( + @Args('nftId', { type: () => ID }) nftId: string, + ): Promise { + return this.orderService.getStats(nftId); + } + + private toConnection( + items: OrderInterface[], + totalCount: number, + hasNextPage: boolean, + ): OrderConnection { + const edges = items.map((o) => ({ + node: this.toGraphqlOrder(o), + cursor: Buffer.from(o.createdAt.toISOString() + ':' + o.id, 'utf8').toString('base64url'), + })); + + return { + edges, + pageInfo: { + hasNextPage, + startCursor: edges[0]?.cursor, + endCursor: edges.at(-1)?.cursor, + } as PageInfo, + totalCount, + }; + } + + private toGraphqlOrder(order: OrderInterface): GraphqlOrder { + return { + id: order.id, + nftId: order.nftId, + buyerId: order.buyerId, + sellerId: order.sellerId, + price: order.price, + currency: order.currency, + type: order.type as OrderType, + status: order.status as OrderStatus, + transactionHash: order.transactionHash, + listingId: order.listingId, + auctionId: order.auctionId, + createdAt: order.createdAt, + }; + } +} diff --git a/nftopia-backend/src/graphql/resolvers/user.resolver.spec.ts b/nftopia-backend/src/graphql/resolvers/user.resolver.spec.ts new file mode 100644 index 00000000..8e8bbdb5 --- /dev/null +++ b/nftopia-backend/src/graphql/resolvers/user.resolver.spec.ts @@ -0,0 +1,74 @@ +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { UserResolver } from './user.resolver'; +import { UsersService } from '../../users/users.service'; + +const mockUsersService = { + findById: jest.fn(), + findByAddress: jest.fn(), +}; + +const baseUser = { + id: 'user-1', + address: 'GABC123', + email: 'test@example.com', + username: 'stellardev', + bio: 'Building on Stellar', + avatarUrl: 'https://example.com/avatar.png', + walletAddress: 'GABC123', + walletPublicKey: null, + walletProvider: null, + walletConnectedAt: null, + isEmailVerified: true, + lastLoginAt: new Date('2026-03-20T10:00:00.000Z'), + passwordHash: null, + wallets: [], +}; + +describe('UserResolver', () => { + let resolver: UserResolver; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UserResolver, + { provide: UsersService, useValue: mockUsersService }, + ], + }).compile(); + + resolver = module.get(UserResolver); + jest.clearAllMocks(); + }); + + it('returns a user by id', async () => { + mockUsersService.findById.mockResolvedValue(baseUser); + + const result = await resolver.user('user-1'); + + expect(mockUsersService.findById).toHaveBeenCalledWith('user-1'); + expect(result.id).toBe('user-1'); + expect(result.username).toBe('stellardev'); + expect(result.isEmailVerified).toBe(true); + }); + + it('throws NotFoundException when user not found by id', async () => { + mockUsersService.findById.mockResolvedValue(null); + + await expect(resolver.user('missing-id')).rejects.toThrow(NotFoundException); + }); + + it('returns a user by Stellar address', async () => { + mockUsersService.findByAddress.mockResolvedValue(baseUser); + + const result = await resolver.userByAddress('GABC123'); + + expect(mockUsersService.findByAddress).toHaveBeenCalledWith('GABC123'); + expect(result.address).toBe('GABC123'); + }); + + it('throws NotFoundException when address not found', async () => { + mockUsersService.findByAddress.mockResolvedValue(null); + + await expect(resolver.userByAddress('GNONE')).rejects.toThrow(NotFoundException); + }); +}); diff --git a/nftopia-backend/src/graphql/resolvers/user.resolver.ts b/nftopia-backend/src/graphql/resolvers/user.resolver.ts new file mode 100644 index 00000000..31f275f1 --- /dev/null +++ b/nftopia-backend/src/graphql/resolvers/user.resolver.ts @@ -0,0 +1,48 @@ +import { Args, ID, Query, Resolver } from '@nestjs/graphql'; +import { NotFoundException } from '@nestjs/common'; +import { UsersService } from '../../users/users.service'; +import type { User } from '../../users/user.entity'; +import { GraphqlUser } from '../types/user.types'; + +@Resolver(() => GraphqlUser) +export class UserResolver { + constructor(private readonly usersService: UsersService) {} + + @Query(() => GraphqlUser, { + name: 'user', + description: 'Fetch a user by ID', + }) + async user( + @Args('id', { type: () => ID }) id: string, + ): Promise { + const u = await this.usersService.findById(id); + if (!u) throw new NotFoundException('User not found'); + return this.toGraphqlUser(u); + } + + @Query(() => GraphqlUser, { + name: 'userByAddress', + description: 'Fetch a user by their Stellar address', + }) + async userByAddress( + @Args('address') address: string, + ): Promise { + const u = await this.usersService.findByAddress(address); + if (!u) throw new NotFoundException('User not found'); + return this.toGraphqlUser(u); + } + + private toGraphqlUser(u: User): GraphqlUser { + return { + id: u.id, + address: u.address ?? null, + email: u.email ?? null, + username: u.username, + bio: u.bio, + avatarUrl: u.avatarUrl, + walletAddress: u.walletAddress ?? null, + isEmailVerified: u.isEmailVerified, + lastLoginAt: u.lastLoginAt ?? null, + }; + } +} diff --git a/nftopia-backend/src/graphql/types/auction.types.ts b/nftopia-backend/src/graphql/types/auction.types.ts new file mode 100644 index 00000000..a1c47ea4 --- /dev/null +++ b/nftopia-backend/src/graphql/types/auction.types.ts @@ -0,0 +1,76 @@ +import { + Field, + Float, + GraphQLISODateTime, + ID, + Int, + ObjectType, + registerEnumType, +} from '@nestjs/graphql'; +import { AuctionStatus } from '../../modules/auction/interfaces/auction.interface'; +import { PageInfo } from './nft.types'; + +registerEnumType(AuctionStatus, { name: 'AuctionStatus' }); + +@ObjectType('Auction') +export class GraphqlAuction { + @Field(() => ID) + id: string; + + @Field() + nftContractId: string; + + @Field() + nftTokenId: string; + + @Field(() => ID) + sellerId: string; + + @Field(() => Float) + startPrice: number; + + @Field(() => Float) + currentPrice: number; + + @Field(() => Float, { nullable: true }) + reservePrice?: number; + + @Field(() => GraphQLISODateTime) + startTime: Date; + + @Field(() => GraphQLISODateTime) + endTime: Date; + + @Field(() => AuctionStatus) + status: AuctionStatus; + + @Field(() => ID, { nullable: true }) + winnerId?: string; + + @Field(() => GraphQLISODateTime) + createdAt: Date; + + @Field(() => GraphQLISODateTime) + updatedAt: Date; +} + +@ObjectType() +export class AuctionEdge { + @Field(() => GraphqlAuction) + node: GraphqlAuction; + + @Field() + cursor: string; +} + +@ObjectType() +export class AuctionConnection { + @Field(() => [AuctionEdge]) + edges: AuctionEdge[]; + + @Field(() => PageInfo) + pageInfo: PageInfo; + + @Field(() => Int) + totalCount: number; +} diff --git a/nftopia-backend/src/graphql/types/listing.types.ts b/nftopia-backend/src/graphql/types/listing.types.ts new file mode 100644 index 00000000..01533da2 --- /dev/null +++ b/nftopia-backend/src/graphql/types/listing.types.ts @@ -0,0 +1,67 @@ +import { + Field, + Float, + GraphQLISODateTime, + ID, + Int, + ObjectType, + registerEnumType, +} from '@nestjs/graphql'; +import { ListingStatus } from '../../modules/listing/interfaces/listing.interface'; +import { PageInfo } from './nft.types'; + +registerEnumType(ListingStatus, { name: 'ListingStatus' }); + +@ObjectType('Listing') +export class GraphqlListing { + @Field(() => ID) + id: string; + + @Field() + nftContractId: string; + + @Field() + nftTokenId: string; + + @Field(() => ID) + sellerId: string; + + @Field(() => Float) + price: number; + + @Field() + currency: string; + + @Field(() => ListingStatus) + status: ListingStatus; + + @Field(() => GraphQLISODateTime, { nullable: true }) + expiresAt?: Date; + + @Field(() => GraphQLISODateTime) + createdAt: Date; + + @Field(() => GraphQLISODateTime) + updatedAt: Date; +} + +@ObjectType() +export class ListingEdge { + @Field(() => GraphqlListing) + node: GraphqlListing; + + @Field() + cursor: string; +} + +@ObjectType() +export class ListingConnection { + @Field(() => [ListingEdge]) + edges: ListingEdge[]; + + @Field(() => PageInfo) + pageInfo: PageInfo; + + @Field(() => Int) + totalCount: number; +} diff --git a/nftopia-backend/src/graphql/types/order.types.ts b/nftopia-backend/src/graphql/types/order.types.ts new file mode 100644 index 00000000..96d91494 --- /dev/null +++ b/nftopia-backend/src/graphql/types/order.types.ts @@ -0,0 +1,88 @@ +import { + Field, + GraphQLISODateTime, + ID, + Int, + ObjectType, + registerEnumType, +} from '@nestjs/graphql'; +import { + OrderStatus, + OrderType, +} from '../../modules/order/dto/create-order.dto'; +import { PageInfo } from './nft.types'; + +registerEnumType(OrderType, { name: 'OrderType' }); +registerEnumType(OrderStatus, { name: 'OrderStatus' }); + +@ObjectType('Order') +export class GraphqlOrder { + @Field(() => ID) + id: string; + + @Field(() => ID) + nftId: string; + + @Field(() => ID) + buyerId: string; + + @Field(() => ID) + sellerId: string; + + @Field() + price: string; + + @Field() + currency: string; + + @Field(() => OrderType) + type: OrderType; + + @Field(() => OrderStatus) + status: OrderStatus; + + @Field(() => String, { nullable: true }) + transactionHash?: string; + + @Field(() => ID, { nullable: true }) + listingId?: string; + + @Field(() => ID, { nullable: true }) + auctionId?: string; + + @Field(() => GraphQLISODateTime) + createdAt: Date; +} + +@ObjectType() +export class OrderEdge { + @Field(() => GraphqlOrder) + node: GraphqlOrder; + + @Field() + cursor: string; +} + +@ObjectType() +export class OrderConnection { + @Field(() => [OrderEdge]) + edges: OrderEdge[]; + + @Field(() => PageInfo) + pageInfo: PageInfo; + + @Field(() => Int) + totalCount: number; +} + +@ObjectType() +export class GraphqlOrderStats { + @Field() + volume: string; + + @Field(() => Int) + count: number; + + @Field() + averagePrice: string; +} diff --git a/nftopia-backend/src/graphql/types/user.types.ts b/nftopia-backend/src/graphql/types/user.types.ts new file mode 100644 index 00000000..a92095b3 --- /dev/null +++ b/nftopia-backend/src/graphql/types/user.types.ts @@ -0,0 +1,36 @@ +import { + Field, + GraphQLISODateTime, + ID, + ObjectType, +} from '@nestjs/graphql'; + +@ObjectType('User') +export class GraphqlUser { + @Field(() => ID) + id: string; + + @Field(() => String, { nullable: true }) + address?: string | null; + + @Field(() => String, { nullable: true }) + email?: string | null; + + @Field(() => String, { nullable: true }) + username?: string; + + @Field(() => String, { nullable: true }) + bio?: string; + + @Field(() => String, { nullable: true }) + avatarUrl?: string; + + @Field(() => String, { nullable: true }) + walletAddress?: string | null; + + @Field() + isEmailVerified: boolean; + + @Field(() => GraphQLISODateTime, { nullable: true }) + lastLoginAt?: Date | null; +} diff --git a/nftopia-backend/src/modules/auction/auction.module.ts b/nftopia-backend/src/modules/auction/auction.module.ts index df65c0fd..161a306c 100644 --- a/nftopia-backend/src/modules/auction/auction.module.ts +++ b/nftopia-backend/src/modules/auction/auction.module.ts @@ -11,5 +11,6 @@ import { NftMetadata } from '../../nft/entities/nft-metadata.entity'; imports: [TypeOrmModule.forFeature([Auction, Bid, StellarNft, NftMetadata])], providers: [AuctionService], controllers: [AuctionController], + exports: [AuctionService], }) export class AuctionModule {} diff --git a/nftopia-backend/src/modules/listing/listing.module.ts b/nftopia-backend/src/modules/listing/listing.module.ts index 366a47cb..ca2082d5 100644 --- a/nftopia-backend/src/modules/listing/listing.module.ts +++ b/nftopia-backend/src/modules/listing/listing.module.ts @@ -10,5 +10,6 @@ import { NftMetadata } from '../../nft/entities/nft-metadata.entity'; imports: [TypeOrmModule.forFeature([Listing, StellarNft, NftMetadata])], providers: [ListingService], controllers: [ListingController], + exports: [ListingService], }) export class ListingModule {}