-
Notifications
You must be signed in to change notification settings - Fork 76
escrow indexer queries #186
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| #!/bin/sh | ||
| command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } | ||
| git lfs post-checkout "$@" | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,3 @@ | ||||||||||||||||
| #!/bin/sh | ||||||||||||||||
| command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } | ||||||||||||||||
| git lfs post-commit "$@" | ||||||||||||||||
|
Comment on lines
+1
to
+3
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Load Husky shim before running the hook The Husky shim is absent here as well, so Huskyβs native skip/disable features wonβt work and the hook will always run. Add the shim first thing in the script to re-enable expected behavior. #!/bin/sh
+. "$(dirname -- "$0")/_/husky.sh"
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
git lfs post-commit "$@"π Committable suggestion
Suggested change
π€ Prompt for AI Agents |
||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,3 @@ | ||||||||||||||||||||||
| #!/bin/sh | ||||||||||||||||||||||
| command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } | ||||||||||||||||||||||
| git lfs post-merge "$@" | ||||||||||||||||||||||
|
Comment on lines
+1
to
+3
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Load Husky shim before running the hook Same issue here: without sourcing #!/bin/sh
+. "$(dirname -- "$0")/_/husky.sh"
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
git lfs post-merge "$@"π Committable suggestion
Suggested change
π€ Prompt for AI Agents |
||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,3 @@ | ||||||||||||||||
| #!/bin/sh | ||||||||||||||||
| command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } | ||||||||||||||||
| git lfs pre-push "$@" | ||||||||||||||||
|
Comment on lines
+1
to
+3
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Load Husky shim before running the hook Please source #!/bin/sh
+. "$(dirname -- "$0")/_/husky.sh"
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
git lfs pre-push "$@"π Committable suggestion
Suggested change
π€ Prompt for AI Agents |
||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -15,7 +15,7 @@ import { UsersModule } from './modules/users/users.module'; | |||||
| import { NotificationsModule } from './modules/notifications/notifications.module'; | ||||||
| import { OrdersModule } from './modules/orders/orders.module'; | ||||||
| import { BuyerRequestsModule } from './modules/buyer-requests/buyer-requests.module'; | ||||||
| import { OffersModule } from './modules/offers/offers.module'; | ||||||
| import { OffersModule } from './src/modules/offers/offers.module'; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix the OffersModule import path
-import { OffersModule } from './src/modules/offers/offers.module';
+import { OffersModule } from './modules/offers/offers.module';π Committable suggestion
Suggested change
π€ Prompt for AI Agents |
||||||
| import { SupabaseModule } from './modules/supabase/supabase.module'; | ||||||
|
|
||||||
| // Entities | ||||||
|
|
@@ -36,6 +36,9 @@ import { CouponUsage } from './modules/coupons/entities/coupon-usage.entity'; | |||||
| import { BuyerRequest } from './modules/buyer-requests/entities/buyer-request.entity'; | ||||||
| import { Offer } from './modules/offers/entities/offer.entity'; | ||||||
| import { OfferAttachment } from './modules/offers/entities/offer-attachment.entity'; | ||||||
| import { Escrow } from './modules/escrows/entities/escrow.entity'; | ||||||
| import { Milestone } from './modules/escrows/entities/milestone.entity'; | ||||||
| import { EscrowsModule } from './modules/escrows/escrows.module'; | ||||||
|
|
||||||
| @Module({ | ||||||
| imports: [ | ||||||
|
|
@@ -63,6 +66,8 @@ import { OfferAttachment } from './modules/offers/entities/offer-attachment.enti | |||||
| BuyerRequest, | ||||||
| Offer, | ||||||
| OfferAttachment, | ||||||
| Escrow, | ||||||
| Milestone, | ||||||
| ], | ||||||
| synchronize: process.env.NODE_ENV !== 'production', | ||||||
| logging: process.env.NODE_ENV === 'development', | ||||||
|
|
@@ -81,6 +86,7 @@ import { OfferAttachment } from './modules/offers/entities/offer-attachment.enti | |||||
| BuyerRequestsModule, | ||||||
| OffersModule, | ||||||
| SupabaseModule, | ||||||
| EscrowsModule, | ||||||
| ], | ||||||
| }) | ||||||
| export class AppModule {} | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { Controller, Patch, Param, UseGuards, Request, Body, Get, Query, Post } from '@nestjs/common'; | ||
| import { EscrowService } from '../services/escrow.service'; | ||
| import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; | ||
| import { RolesGuard } from '../../auth/guards/roles.guard'; | ||
| import { Roles } from '../../auth/decorators/roles.decorator'; | ||
| import { Role } from '@/types/role'; | ||
| import { AuthRequest } from '@/modules/wishlist/common/types/auth-request.type'; | ||
| import { UpdateMilestoneStatusDto } from '../dto/update-milestone-status.dto'; | ||
| import { MilestoneStatus } from '../entities/milestone.entity'; | ||
| import { GetEscrowsQueryDto } from '../dto/get-escrows-query.dto'; | ||
| import { MultipleEscrowBalancesDto } from '../dto/multiple-balances.dto'; | ||
|
|
||
| @Controller('escrows') | ||
| export class EscrowController { | ||
| constructor(private readonly escrowService: EscrowService) {} | ||
|
|
||
| @Patch(':escrowId/milestones/:milestoneId/approve') | ||
| @UseGuards(JwtAuthGuard, RolesGuard) | ||
| @Roles(Role.BUYER) | ||
| approve( | ||
| @Param('escrowId') escrowId: string, | ||
| @Param('milestoneId') milestoneId: string, | ||
| @Request() req: AuthRequest | ||
| ) { | ||
| return this.escrowService.approveMilestone(escrowId, milestoneId, Number(req.user.id)); | ||
| } | ||
|
|
||
| @Patch(':escrowId/milestones/:milestoneId/status') | ||
| @UseGuards(JwtAuthGuard, RolesGuard) | ||
| @Roles(Role.SELLER) | ||
| changeStatus( | ||
| @Param('escrowId') escrowId: string, | ||
| @Param('milestoneId') milestoneId: string, | ||
| @Body() body: UpdateMilestoneStatusDto, | ||
| @Request() req: AuthRequest | ||
| ) { | ||
| return this.escrowService.changeMilestoneStatus( | ||
| escrowId, | ||
| milestoneId, | ||
| Number(req.user.id), | ||
| body.status as MilestoneStatus | ||
| ); | ||
| } | ||
|
|
||
| // GET /escrows?role=buyer|seller (defaults to both roles for signer) | ||
| @Get() | ||
| @UseGuards(JwtAuthGuard) | ||
| list(@Query() query: GetEscrowsQueryDto, @Request() req: AuthRequest) { | ||
| const userId = Number(req.user.id); | ||
| if (query.role) { | ||
| return this.escrowService.getEscrowsByRole(userId, query.role); | ||
| } | ||
| return this.escrowService.getEscrowsBySigner(userId); | ||
| } | ||
|
|
||
| // POST /escrows/balances { ids: [...] } | ||
| @Post('balances') | ||
| @UseGuards(JwtAuthGuard) | ||
| balances(@Body() body: MultipleEscrowBalancesDto) { | ||
| return this.escrowService.getMultipleEscrowBalances(body.ids); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { IsOptional, IsString } from 'class-validator'; | ||
|
|
||
| export class ApproveMilestoneDto { | ||
| @IsOptional() | ||
| @IsString() | ||
| type?: string; // placeholder if future variations required | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { IsOptional, IsIn } from 'class-validator'; | ||
|
|
||
| export class GetEscrowsQueryDto { | ||
| @IsOptional() | ||
| @IsIn(['buyer', 'seller']) | ||
| role?: 'buyer' | 'seller'; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator'; | ||
|
|
||
| export class MultipleEscrowBalancesDto { | ||
| @IsArray() | ||
| @ArrayNotEmpty() | ||
| @IsUUID('4', { each: true }) | ||
| ids: string[]; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import { IsEnum } from 'class-validator'; | ||
| import { MilestoneStatus } from '../entities/milestone.entity'; | ||
|
|
||
| // Seller-changeable statuses (not including APPROVED which is buyer action) | ||
| export class UpdateMilestoneStatusDto { | ||
| @IsEnum(MilestoneStatus, { message: 'Invalid milestone status' }) | ||
| status: MilestoneStatus; // Expect READY | IN_PROGRESS | DELIVERED | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,53 @@ | ||||||
| import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, CreateDateColumn, UpdateDateColumn, JoinColumn, Check } from 'typeorm'; | ||||||
| import { Offer } from '../../offers/entities/offer.entity'; | ||||||
| import { User } from '../../users/entities/user.entity'; | ||||||
| import { Milestone } from './milestone.entity'; | ||||||
|
|
||||||
| export enum EscrowStatus { | ||||||
| PENDING = 'pending', // Has milestones not yet approved | ||||||
| IN_PROGRESS = 'in_progress', // At least one milestone approved but not all | ||||||
| COMPLETED = 'completed', // All milestones approved | ||||||
| } | ||||||
|
|
||||||
| @Entity('escrows') | ||||||
| @Check('"totalAmount" >= 0') | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix the CHECK constraint column reference. The CHECK clause targets -@Check('"totalAmount" >= 0')
+@Check('"total_amount" >= 0')π Committable suggestion
Suggested change
π€ Prompt for AI Agents |
||||||
| export class Escrow { | ||||||
| @PrimaryGeneratedColumn('uuid') | ||||||
| id: string; | ||||||
|
|
||||||
| @Column({ type: 'uuid', name: 'offer_id' }) | ||||||
| offerId: string; | ||||||
|
|
||||||
| @ManyToOne(() => Offer, { nullable: false }) | ||||||
| @JoinColumn({ name: 'offer_id' }) | ||||||
| offer: Offer; | ||||||
|
|
||||||
| @Column({ name: 'buyer_id' }) | ||||||
| buyerId: number; | ||||||
|
|
||||||
| @ManyToOne(() => User, { nullable: false }) | ||||||
| @JoinColumn({ name: 'buyer_id' }) | ||||||
| buyer: User; | ||||||
|
|
||||||
| @Column({ name: 'seller_id' }) | ||||||
| sellerId: number; | ||||||
|
|
||||||
| @ManyToOne(() => User, { nullable: false }) | ||||||
| @JoinColumn({ name: 'seller_id' }) | ||||||
| seller: User; | ||||||
|
|
||||||
| @Column({ type: 'decimal', precision: 12, scale: 2, name: 'total_amount' }) | ||||||
| totalAmount: number; | ||||||
|
|
||||||
| @Column({ type: 'enum', enum: EscrowStatus, default: EscrowStatus.PENDING }) | ||||||
| status: EscrowStatus; | ||||||
|
|
||||||
| @OneToMany(() => Milestone, (m) => m.escrow, { cascade: true }) | ||||||
| milestones: Milestone[]; | ||||||
|
|
||||||
| @CreateDateColumn({ name: 'created_at' }) | ||||||
| createdAt: Date; | ||||||
|
|
||||||
| @UpdateDateColumn({ name: 'updated_at' }) | ||||||
| updatedAt: Date; | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, CreateDateColumn, UpdateDateColumn, JoinColumn, Check } from 'typeorm'; | ||
| import { Escrow } from './escrow.entity'; | ||
|
|
||
| export enum MilestoneStatus { | ||
| PENDING = 'pending', | ||
| APPROVED = 'approved', | ||
| READY = 'ready', // Seller marked as ready to start | ||
| IN_PROGRESS = 'in_progress', // Work is in progress | ||
| DELIVERED = 'delivered', // Seller delivered work for buyer approval | ||
| } | ||
|
|
||
| @Entity('escrow_milestones') | ||
| @Check('"amount" >= 0') | ||
| export class Milestone { | ||
| @PrimaryGeneratedColumn('uuid') | ||
| id: string; | ||
|
|
||
| @Column({ type: 'uuid', name: 'escrow_id' }) | ||
| escrowId: string; | ||
|
|
||
| @ManyToOne(() => Escrow, (e) => e.milestones, { onDelete: 'CASCADE' }) | ||
| @JoinColumn({ name: 'escrow_id' }) | ||
| escrow: Escrow; | ||
|
|
||
| @Column({ type: 'int' }) | ||
| sequence: number; // order of milestone | ||
|
|
||
| @Column({ length: 120 }) | ||
| title: string; | ||
|
|
||
| @Column({ type: 'text', nullable: true }) | ||
| description?: string; | ||
|
|
||
| @Column({ type: 'decimal', precision: 12, scale: 2 }) | ||
| amount: number; | ||
|
|
||
| @Column({ type: 'enum', enum: MilestoneStatus, default: MilestoneStatus.PENDING }) | ||
| status: MilestoneStatus; | ||
|
|
||
| @Column({ type: 'timestamp', name: 'approved_at', nullable: true }) | ||
| approvedAt?: Date; | ||
|
|
||
| @CreateDateColumn({ name: 'created_at' }) | ||
| createdAt: Date; | ||
|
|
||
| @UpdateDateColumn({ name: 'updated_at' }) | ||
| updatedAt: Date; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import { Module } from '@nestjs/common'; | ||
| import { TypeOrmModule } from '@nestjs/typeorm'; | ||
| import { Escrow } from './entities/escrow.entity'; | ||
| import { Milestone } from './entities/milestone.entity'; | ||
| import { EscrowService } from './services/escrow.service'; | ||
| import { EscrowController } from './controllers/escrow.controller'; | ||
|
|
||
| @Module({ | ||
| imports: [TypeOrmModule.forFeature([Escrow, Milestone])], | ||
| controllers: [EscrowController], | ||
| providers: [EscrowService], | ||
| exports: [EscrowService], | ||
| }) | ||
| export class EscrowsModule {} |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,24 @@ | ||||||||||||||||||||||||||||
| import { MigrationInterface, QueryRunner } from 'typeorm'; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| export class ExtendMilestoneStatusEnum1752190000000 implements MigrationInterface { | ||||||||||||||||||||||||||||
| name = 'ExtendMilestoneStatusEnum1752190000000'; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| public async up(queryRunner: QueryRunner): Promise<void> { | ||||||||||||||||||||||||||||
| // Postgres enum alteration strategy: rename old type, create new, alter column, drop old | ||||||||||||||||||||||||||||
| await queryRunner.query(`ALTER TYPE "public"."escrow_milestones_status_enum" RENAME TO "escrow_milestones_status_enum_old"`); | ||||||||||||||||||||||||||||
| await queryRunner.query(`CREATE TYPE "public"."escrow_milestones_status_enum" AS ENUM('pending','approved','ready','in_progress','delivered')`); | ||||||||||||||||||||||||||||
| await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" DROP DEFAULT`); | ||||||||||||||||||||||||||||
| await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" TYPE "public"."escrow_milestones_status_enum" USING "status"::text::"public"."escrow_milestones_status_enum"`); | ||||||||||||||||||||||||||||
| await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" SET DEFAULT 'pending'`); | ||||||||||||||||||||||||||||
| await queryRunner.query(`DROP TYPE "public"."escrow_milestones_status_enum_old"`); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| public async down(queryRunner: QueryRunner): Promise<void> { | ||||||||||||||||||||||||||||
| await queryRunner.query(`ALTER TYPE "public"."escrow_milestones_status_enum" RENAME TO "escrow_milestones_status_enum_old"`); | ||||||||||||||||||||||||||||
| await queryRunner.query(`CREATE TYPE "public"."escrow_milestones_status_enum" AS ENUM('pending','approved')`); | ||||||||||||||||||||||||||||
| await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" DROP DEFAULT`); | ||||||||||||||||||||||||||||
| await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" TYPE "public"."escrow_milestones_status_enum" USING "status"::text::"public"."escrow_milestones_status_enum"`); | ||||||||||||||||||||||||||||
| await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" SET DEFAULT 'pending'`); | ||||||||||||||||||||||||||||
|
Comment on lines
+17
to
+21
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ensure down migration copes with the new statuses As soon as any row is saved with Apply this diff inside the public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TYPE "public"."escrow_milestones_status_enum" RENAME TO "escrow_milestones_status_enum_old"`);
+ await queryRunner.query(`UPDATE "escrow_milestones" SET "status" = 'pending' WHERE "status"::text NOT IN ('pending','approved')`);
await queryRunner.query(`CREATE TYPE "public"."escrow_milestones_status_enum" AS ENUM('pending','approved')`);
await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "escrow_milestones" ALTER COLUMN "status" TYPE "public"."escrow_milestones_status_enum" USING "status"::text::"public"."escrow_milestones_status_enum"`);π Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||
| await queryRunner.query(`DROP TYPE "public"."escrow_milestones_status_enum_old"`); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| import { Test, TestingModule } from '@nestjs/testing'; | ||
| import { EscrowService } from './escrow.service'; | ||
| import { getRepositoryToken } from '@nestjs/typeorm'; | ||
| import { Repository, DataSource } from 'typeorm'; | ||
| import { Escrow } from '../entities/escrow.entity'; | ||
| import { Milestone, MilestoneStatus } from '../entities/milestone.entity'; | ||
| import { ForbiddenException, NotFoundException, BadRequestException } from '@nestjs/common'; | ||
|
|
||
| // Simple in-memory mocks | ||
| class MockRepo<T extends { id?: any }> { | ||
| private entities: T[] = []; | ||
| findOne = jest.fn(async (options: any) => { | ||
| if (options.where?.id) return this.entities.find(e => e.id === options.where.id) || null; | ||
| if (options.where?.id && options.relations) return this.entities.find(e => e.id === options.where.id) || null; | ||
| return null; | ||
| }); | ||
| find = jest.fn(async (options: any) => this.entities.filter(e => (options.where?.escrowId ? (e as any).escrowId === options.where.escrowId : true))); | ||
| save = jest.fn(async (entity: T) => { | ||
| const existingIndex = this.entities.findIndex(e => e.id === entity.id); | ||
| if (existingIndex >= 0) this.entities[existingIndex] = entity; | ||
| else this.entities.push(entity); | ||
| return entity; | ||
| }); | ||
| seed(data: T[]) { this.entities = data; } | ||
| } | ||
|
|
||
| const mockTransaction = (cb: any) => cb({ | ||
| findOne: (entity: any, opts: any) => entity === Milestone ? milestoneRepo.findOne(opts) : escrowRepo.findOne(opts), | ||
| find: (entity: any, opts: any) => milestoneRepo.find(opts), | ||
| save: (entity: any) => Array.isArray(entity) ? Promise.all(entity.map(e => (e instanceof Milestone ? milestoneRepo.save(e) : escrowRepo.save(e)))) : (entity instanceof Milestone ? milestoneRepo.save(entity) : escrowRepo.save(entity)) | ||
| }); | ||
|
|
||
| let escrowRepo: MockRepo<Escrow>; | ||
| let milestoneRepo: MockRepo<Milestone>; | ||
|
|
||
| describe('EscrowService - changeMilestoneStatus', () => { | ||
| let service: EscrowService; | ||
|
|
||
| beforeEach(async () => { | ||
| escrowRepo = new MockRepo<Escrow>(); | ||
| milestoneRepo = new MockRepo<Milestone>(); | ||
|
|
||
| const module: TestingModule = await Test.createTestingModule({ | ||
| providers: [ | ||
| EscrowService, | ||
| { provide: getRepositoryToken(Escrow), useValue: escrowRepo }, | ||
| { provide: getRepositoryToken(Milestone), useValue: milestoneRepo }, | ||
| { provide: DataSource, useValue: { transaction: jest.fn(mockTransaction) } }, | ||
| ], | ||
| }).compile(); | ||
|
|
||
| service = module.get<EscrowService>(EscrowService); | ||
|
|
||
| // Seed data | ||
| escrowRepo.seed([{ id: 'escrow1', sellerId: 10, buyerId: 20 } as any]); | ||
| milestoneRepo.seed([ | ||
| { id: 'm1', escrowId: 'escrow1', status: MilestoneStatus.PENDING } as any, | ||
| ]); | ||
| }); | ||
|
|
||
| it('should change status from pending to ready by seller', async () => { | ||
| const result = await service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.READY); | ||
| expect(result.status).toBe(MilestoneStatus.READY); | ||
| }); | ||
|
|
||
| it('should block non-seller from changing status', async () => { | ||
| await expect( | ||
| service.changeMilestoneStatus('escrow1', 'm1', 999, MilestoneStatus.READY) | ||
| ).rejects.toBeInstanceOf(ForbiddenException); | ||
| }); | ||
|
|
||
| it('should block backwards transition', async () => { | ||
| await service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.READY); | ||
| await service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.IN_PROGRESS); | ||
| await expect( | ||
| service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.READY) | ||
| ).rejects.toBeInstanceOf(BadRequestException); | ||
| }); | ||
|
|
||
| it('should be idempotent if same status provided', async () => { | ||
| await service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.READY); | ||
| const result = await service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.READY); | ||
| expect(result.status).toBe(MilestoneStatus.READY); | ||
| }); | ||
|
|
||
| it('should not allow change after approval', async () => { | ||
| // Simulate approved milestone | ||
| milestoneRepo.seed([{ id: 'm1', escrowId: 'escrow1', status: MilestoneStatus.APPROVED } as any]); | ||
| await expect( | ||
| service.changeMilestoneStatus('escrow1', 'm1', 10, MilestoneStatus.DELIVERED) | ||
| ).rejects.toBeInstanceOf(BadRequestException); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Load Husky shim before running the hook
Each Husky hook must source
husky.sh; otherwise the standard skip logic (HUSKY=0,HUSKY_SKIP_HOOKS), ENV tweaks, and debug features never run. That means this script will execute even in CI environments that intentionally disable hooks, causing avoidable failures whengit-lfsisnβt installed. Please restore the husky shim (same applies to the sibling hooks in this PR).#!/bin/sh +. "$(dirname -- "$0")/_/husky.sh" command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } git lfs post-checkout "$@"π Committable suggestion
π€ Prompt for AI Agents