From f3d0035cf0f030e151083b6a52b6fd233c70d3d4 Mon Sep 17 00:00:00 2001 From: AlexKalll Date: Mon, 7 Sep 2026 18:25:05 +0300 Subject: [PATCH 1/5] fix(seed): prepare complete demo user profiles --- src/database/seeds/1744707251495-TestUsers.ts | 437 ++++++++++++++---- 1 file changed, 343 insertions(+), 94 deletions(-) diff --git a/src/database/seeds/1744707251495-TestUsers.ts b/src/database/seeds/1744707251495-TestUsers.ts index aa3cfd6..51e35b5 100644 --- a/src/database/seeds/1744707251495-TestUsers.ts +++ b/src/database/seeds/1744707251495-TestUsers.ts @@ -1,14 +1,306 @@ -import { Seeder, SeederFactoryManager } from 'typeorm-extension'; -import { DataSource } from 'typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { Seeder } from 'typeorm-extension'; import bcrypt from 'bcrypt'; +import { + Country, + Dialect, + Language, + Region, + Zone, +} from '../../base_data/entities'; +import { Role } from '../../auth/entities/Role.entity'; +import { User } from '../../auth/entities/User.entity'; +import { UserScore } from '../../auth/entities/UserScore.entity'; +import { Wallet } from '../../finance/entities/Wallet.entity'; + +const DEMO_PASSWORD = '12345678'; +const AMHARIC_LANGUAGE = { name: 'Amharic', code: 'am' }; +const AMHARIC_DIALECT = 'Addis Ababa Amharic'; +const ETHIOPIA = { name: 'Ethiopia', code: 'ETH', continent: 'Africa' }; +const ADDIS_ABABA = 'Addis Ababa'; +const BOLE = 'Bole'; + +type DemoUserDefinition = { + email: string; + first_name: string; + middle_name: string; + last_name: string; + phone_number: string; + national_id: string; + role: string; + birth_date: string; + gender: 'Male' | 'Female'; +}; + +export const DEMO_USERS: DemoUserDefinition[] = [ + { + email: 'super@gmail.com', + first_name: 'SuperAdmin', + middle_name: 'Demo', + last_name: 'One', + phone_number: '+251911000001', + national_id: 'E2E-SUPER-001', + role: 'SuperAdmin', + birth_date: '1985-01-15', + gender: 'Male', + }, + { + email: 'super1@gmail.com', + first_name: 'SuperAdmin', + middle_name: 'Demo', + last_name: 'Two', + phone_number: '+251911000002', + national_id: 'E2E-SUPER-002', + role: 'SuperAdmin', + birth_date: '1986-02-15', + gender: 'Female', + }, + { + email: 'faci@gmail.com', + first_name: 'Facilitator', + middle_name: 'Demo', + last_name: 'One', + phone_number: '+251911000003', + national_id: 'E2E-FACI-001', + role: 'Facilitator', + birth_date: '1988-03-15', + gender: 'Male', + }, + { + email: 'rev@gmail.com', + first_name: 'Reviewer', + middle_name: 'Demo', + last_name: 'One', + phone_number: '+251911000004', + national_id: 'E2E-REVIEWER-001', + role: 'Reviewer', + birth_date: '1990-04-15', + gender: 'Female', + }, + { + email: 'rev2@gmail.com', + first_name: 'Reviewer', + middle_name: 'Demo', + last_name: 'Two', + phone_number: '+251911000005', + national_id: 'E2E-REVIEWER-002', + role: 'Reviewer', + birth_date: '1991-05-15', + gender: 'Male', + }, + { + email: 'cont@gmail.com', + first_name: 'Contributor', + middle_name: 'Demo', + last_name: 'One', + phone_number: '+251123456789', + national_id: 'E2E-CONT-001', + role: 'Contributor', + birth_date: '1995-06-15', + gender: 'Male', + }, + { + email: 'cont1@gmail.com', + first_name: 'Contributor', + middle_name: 'Demo', + last_name: 'Two', + phone_number: '+251234567890', + national_id: 'E2E-CONT-002', + role: 'Contributor', + birth_date: '1996-07-15', + gender: 'Female', + }, + { + email: 'proj@gmail.com', + first_name: 'ProjectManager', + middle_name: 'Demo', + last_name: 'One', + phone_number: '+251911000006', + national_id: 'E2E-PM-001', + role: 'ProjectManager', + birth_date: '1987-08-15', + gender: 'Male', + }, + { + email: 'proj1@gmail.com', + first_name: 'ProjectManager', + middle_name: 'Demo', + last_name: 'Two', + phone_number: '+251911000007', + national_id: 'E2E-PM-002', + role: 'ProjectManager', + birth_date: '1989-09-15', + gender: 'Female', + }, + { + email: 'faci1@gmail.com', + first_name: 'Facilitator', + middle_name: 'Demo', + last_name: 'Two', + phone_number: '+251911000008', + national_id: 'E2E-FACI-002', + role: 'Facilitator', + birth_date: '1992-10-15', + gender: 'Female', + }, +]; + +async function ensureLanguage(repository: Repository) { + let language = await repository.findOne({ + where: { name: AMHARIC_LANGUAGE.name }, + withDeleted: true, + }); + if (!language) { + language = await repository.save(repository.create(AMHARIC_LANGUAGE)); + } else { + if (language.deletedAt) await repository.restore(language.id); + await repository.update(language.id, AMHARIC_LANGUAGE); + } + return (await repository.findOne({ where: { id: language.id } }))!; +} + +async function ensureCountry(repository: Repository) { + let country = await repository.findOne({ + where: { name: ETHIOPIA.name }, + withDeleted: true, + }); + if (!country) { + country = await repository.save(repository.create(ETHIOPIA)); + } else { + if (country.deletedAt) await repository.restore(country.id); + await repository.update(country.id, ETHIOPIA); + } + return (await repository.findOne({ where: { id: country.id } }))!; +} + +async function ensureRegion( + repository: Repository, + country_id: string, +) { + let region = await repository.findOne({ + where: { name: ADDIS_ABABA }, + withDeleted: true, + }); + if (!region) { + region = await repository.save( + repository.create({ name: ADDIS_ABABA, country_id }), + ); + } else { + if (region.deletedAt) await repository.restore(region.id); + await repository.update(region.id, { country_id }); + } + return (await repository.findOne({ where: { id: region.id } }))!; +} + +async function ensureZone(repository: Repository, region_id: string) { + let zone = await repository.findOne({ + where: { name: BOLE }, + withDeleted: true, + }); + if (!zone) { + zone = await repository.save(repository.create({ name: BOLE, region_id })); + } else { + if (zone.deletedAt) await repository.restore(zone.id); + await repository.update(zone.id, { region_id }); + } + return (await repository.findOne({ where: { id: zone.id } }))!; +} + +async function ensureDialect( + repository: Repository, + language_id: string, +) { + let dialect = await repository.findOne({ + where: { name: AMHARIC_DIALECT }, + withDeleted: true, + }); + if (!dialect) { + dialect = await repository.save( + repository.create({ + name: AMHARIC_DIALECT, + description: 'Demo dialect for Amharic end-to-end tests', + language_id, + }), + ); + } else { + if (dialect.deletedAt) await repository.restore(dialect.id); + await repository.update(dialect.id, { + description: 'Demo dialect for Amharic end-to-end tests', + language_id, + }); + } + return (await repository.findOne({ where: { id: dialect.id } }))!; +} + +async function ensureDemoUser( + repository: Repository, + definition: DemoUserDefinition, + role_id: string, + language_id: string, + dialect_id: string, + region_id: string, + zone_id: string, + password: string, +) { + const profile: Partial = { + first_name: definition.first_name, + middle_name: definition.middle_name, + last_name: definition.last_name, + phone_number: definition.phone_number, + national_id: definition.national_id, + password, + birth_date: new Date(definition.birth_date), + gender: definition.gender, + is_active: true, + role_id, + language_id, + dialect_id, + region_id, + zone_id, + city: ADDIS_ABABA, + woreda: BOLE, + sectors: ['language-data'], + }; + const existing = await repository.findOne({ + where: { email: definition.email }, + }); + if (existing) { + await repository.update(existing.id, profile); + return { + user: (await repository.findOne({ where: { id: existing.id } }))!, + created: false, + }; + } + const user = await repository.save( + repository.create({ ...profile, email: definition.email }), + ); + return { user, created: true }; +} + +async function ensureWalletAndScore(dataSource: DataSource, user_id: string) { + const walletRepository = dataSource.getRepository(Wallet); + const wallet = await walletRepository.findOne({ where: { user_id } }); + if (!wallet) { + await walletRepository.save( + walletRepository.create({ user_id, balance: 0 }), + ); + } + + const scoreRepository = dataSource.getRepository(UserScore); + let score = await scoreRepository.findOne({ where: { user_id } }); + if (!score) { + score = await scoreRepository.save( + scoreRepository.create({ user_id, score: 0 }), + ); + } + // Preserve earned balances and scores when preparing another manual test. + await dataSource.getRepository(User).update(user_id, { score }); +} export default class TestUsers1744707251495 implements Seeder { track = false; - public async run( - dataSource: DataSource, - factoryManager: SeederFactoryManager, - ): Promise { + public async run(dataSource: DataSource): Promise { if (process.env.NODE_ENV === 'production') { throw new Error('Demo user seed cannot run in production'); } @@ -20,96 +312,53 @@ export default class TestUsers1744707251495 implements Seeder { return; } - const hashedPassword = await bcrypt.hash('12345678', 10); - - const roles = await dataSource.query(`SELECT id, name FROM role`); - const getRoleId = (name: string) => - roles.find((r: any) => r.name === name)?.id; - - const users = [ - { - email: 'super@gmail.com', - first_name: 'SuperAdmin 0', - role: 'SuperAdmin', - }, - { - email: 'super1@gmail.com', - first_name: 'SuperAdmin 1', - role: 'SuperAdmin', - }, - { - email: 'faci@gmail.com', - first_name: 'Facilitator 0', - role: 'Facilitator', - }, - { email: 'rev@gmail.com', first_name: 'Reviewer 0', role: 'Reviewer' }, - { email: 'rev2@gmail.com', first_name: 'Reviewer 1', role: 'Reviewer' }, - { - email: 'cont@gmail.com', - first_name: 'Contributor 0', - role: 'Contributor', - }, - { - email: 'cont1@gmail.com', - first_name: 'Contributor 1', - role: 'Contributor', - }, - { - email: 'proj@gmail.com', - first_name: 'ProjectManager 0', - role: 'ProjectManager', - }, - { - email: 'proj1@gmail.com', - first_name: 'ProjectManager 1', - role: 'ProjectManager', - }, - { - email: 'faci1@gmail.com', - first_name: 'Facilitator 1', - role: 'Facilitator', - }, - ]; - - let userCount = 0; - for (const u of users) { - const exists = await dataSource.query( - `SELECT id FROM "users" WHERE email = $1`, - [u.email], - ); - if (exists.length > 0) continue; - - const roleId = getRoleId(u.role); - if (!roleId) throw new Error(`Role ${u.role} not found`); - - const result = await dataSource.query( - `INSERT INTO "users" (first_name, middle_name, last_name, email, password, role_id, is_active, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, $6, true, NOW(), NOW()) - RETURNING id`, - [ - u.first_name, - u.first_name, - u.first_name, - u.email, - hashedPassword, - roleId, - ], + const language = await ensureLanguage(dataSource.getRepository(Language)); + const country = await ensureCountry(dataSource.getRepository(Country)); + const region = await ensureRegion( + dataSource.getRepository(Region), + country.id, + ); + const zone = await ensureZone(dataSource.getRepository(Zone), region.id); + const dialect = await ensureDialect( + dataSource.getRepository(Dialect), + language.id, + ); + + const roleRepository = dataSource.getRepository(Role); + const roles = await roleRepository.find({ + select: { id: true, name: true }, + }); + const roleIds = new Map(roles.map((role) => [role.name, role.id])); + const userRepository = dataSource.getRepository(User); + const hashedPassword = await bcrypt.hash(DEMO_PASSWORD, 10); + const seededUsers = new Map(); + let createdCount = 0; + let updatedCount = 0; + + for (const definition of DEMO_USERS) { + const role_id = roleIds.get(definition.role); + if (!role_id) throw new Error(`Role ${definition.role} not found`); + const result = await ensureDemoUser( + userRepository, + definition, + role_id, + language.id, + dialect.id, + region.id, + zone.id, + hashedPassword, ); - const userId = result[0].id; - - if (u.role === 'Contributor' || u.role === 'Reviewer') { - await dataSource.query( - `INSERT INTO task_distribution.user_score (user_id, score) VALUES ($1, 0) ON CONFLICT DO NOTHING`, - [userId], - ); - await dataSource.query( - `INSERT INTO wallet (user_id, balance, created_date, updated_date) - VALUES ($1, 0, NOW(), NOW()) ON CONFLICT DO NOTHING`, - [userId], - ); + seededUsers.set(definition.email, result.user); + if (result.created) createdCount++; + else updatedCount++; + + if (definition.role === 'Contributor' || definition.role === 'Reviewer') { + await ensureWalletAndScore(dataSource, result.user.id); } - userCount++; } - console.log(`Demo users seed completed. ${userCount} users created.`); + + console.log( + `Demo users seed completed. ${createdCount} users created, ${updatedCount} users updated. ${seededUsers.size} users have complete Amharic profiles.`, + ); } } From 59ccb6450068ad165470fa93e040c73be8d27c64 Mon Sep 17 00:00:00 2001 From: AlexKalll Date: Mon, 7 Sep 2026 18:26:17 +0300 Subject: [PATCH 2/5] fix: validate submissions and preserve progress on rejected retries --- src/base_data/dto/RejectionType.dto.spec.ts | 98 ++++++ src/base_data/dto/RejectionType.dto.ts | 27 +- src/task_distribution/dto/DataSet.dto.spec.ts | 53 ++++ src/task_distribution/dto/DataSet.dto.ts | 24 +- .../service/TaskSubmission.service.spec.ts | 281 ++++++++++++++++++ .../service/TaskSubmission.service.ts | 47 ++- 6 files changed, 512 insertions(+), 18 deletions(-) create mode 100644 src/base_data/dto/RejectionType.dto.spec.ts create mode 100644 src/task_distribution/dto/DataSet.dto.spec.ts create mode 100644 src/task_distribution/service/TaskSubmission.service.spec.ts diff --git a/src/base_data/dto/RejectionType.dto.spec.ts b/src/base_data/dto/RejectionType.dto.spec.ts new file mode 100644 index 0000000..4481e7f --- /dev/null +++ b/src/base_data/dto/RejectionType.dto.spec.ts @@ -0,0 +1,98 @@ +import { ArgumentMetadata, HttpException } from '@nestjs/common'; +import { ZodValidationPipe } from 'nestjs-zod'; +import { CustomValidationPipe } from 'src/utils/CustomValidationPipe'; +import { + CreateRejectionTypeDto, + UpdateRejectionTypeDto, + createRejectionTypeSchema, +} from './RejectionType.dto'; + +describe('Annotation/rejection reference-data validation', () => { + describe.each([ + { + operation: 'create', + metatype: CreateRejectionTypeDto, + type: 'body' as const, + }, + { + operation: 'update', + metatype: UpdateRejectionTypeDto, + type: 'body' as const, + }, + { + operation: 'query', + metatype: UpdateRejectionTypeDto, + type: 'query' as const, + }, + ])('$operation', ({ operation, metatype, type }) => { + const metadata: ArgumentMetadata = { metatype, type }; + const transform = async (payload: unknown) => { + const validated: unknown = await new CustomValidationPipe().transform( + payload, + metadata, + ); + return new ZodValidationPipe().transform(validated, metadata) as unknown; + }; + + it('preserves known fields through global validation then the route Zod pipe', async () => { + const payload = { + name: 'Quality', + description: 'Reference data for submission review', + }; + await expect(transform(payload)).resolves.toEqual(payload); + }); + + it('allows an omitted description', async () => { + await expect(transform({ name: 'Quality' })).resolves.toEqual({ + name: 'Quality', + }); + }); + + it('allows an empty description', async () => { + await expect( + transform({ name: 'Quality', description: '' }), + ).resolves.toEqual({ name: 'Quality', description: '' }); + }); + + it.each([ + { name: '' }, + { name: ' \n\t' }, + { name: null }, + { name: 123 }, + { name: ['Quality'] }, + { name: 'Quality', description: null }, + { name: 'Quality', description: 123 }, + { name: 'Quality', unexpected: true }, + { name: 'Quality', created_by: 'client-supplied-user' }, + ])( + 'rejects invalid or unknown fields in %j at the global pipe', + async (payload) => { + await expect( + new CustomValidationPipe().transform(payload, metadata), + ).rejects.toBeInstanceOf(HttpException); + }, + ); + + it('requires name only for create', async () => { + for (const payload of [{}, { description: 'Updated description' }]) { + if (operation === 'create') { + await expect(transform(payload)).rejects.toBeInstanceOf( + HttpException, + ); + } else { + await expect(transform(payload)).resolves.toEqual(payload); + } + } + }); + }); + + it('keeps the exported create schema consistent with required nonblank names', () => { + expect(createRejectionTypeSchema.safeParse({}).success).toBe(false); + expect(createRejectionTypeSchema.safeParse({ name: ' ' }).success).toBe( + false, + ); + expect( + createRejectionTypeSchema.safeParse({ name: 'Quality' }).success, + ).toBe(true); + }); +}); diff --git a/src/base_data/dto/RejectionType.dto.ts b/src/base_data/dto/RejectionType.dto.ts index 49005b1..7a92508 100644 --- a/src/base_data/dto/RejectionType.dto.ts +++ b/src/base_data/dto/RejectionType.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsString } from 'class-validator'; +import { IsString, Matches, ValidateIf } from 'class-validator'; import { createZodDto } from 'nestjs-zod'; import { PaginationDto } from 'src/common/dto/Pagination.dto'; import { z } from 'zod'; @@ -16,13 +16,30 @@ export class CreateFlagTypeDto extends PaginationDto { description?: string; } export const createRejectionTypeSchema = z.object({ - name: z.string().min(1).optional(), + name: z.string().regex(/\S/, 'name must contain non-whitespace text'), description: z.string().optional(), }); export class CreateRejectionTypeDto extends createZodDto( createRejectionTypeSchema, -) {} +) { + @IsString() + @Matches(/\S/, { message: 'name must contain non-whitespace text' }) + name: string; + + @ValidateIf((_object, value: unknown) => value !== undefined) + @IsString() + description?: string; +} export class UpdateRejectionTypeDto extends createZodDto( - createRejectionTypeSchema, -) {} + createRejectionTypeSchema.partial(), +) { + @ValidateIf((_object, value: unknown) => value !== undefined) + @IsString() + @Matches(/\S/, { message: 'name must contain non-whitespace text' }) + name?: string; + + @ValidateIf((_object, value: unknown) => value !== undefined) + @IsString() + description?: string; +} diff --git a/src/task_distribution/dto/DataSet.dto.spec.ts b/src/task_distribution/dto/DataSet.dto.spec.ts new file mode 100644 index 0000000..b200946 --- /dev/null +++ b/src/task_distribution/dto/DataSet.dto.spec.ts @@ -0,0 +1,53 @@ +import { HttpException } from '@nestjs/common'; +import { CustomValidationPipe } from 'src/utils/CustomValidationPipe'; +import { AttemptsDto, CreateMultipleDataSetDto } from './DataSet.dto'; + +describe('CreateMultipleDataSetDto', () => { + const attempt = { + micro_task_id: '3a6c87c2-bb7a-4b67-9605-4f548e17e75f', + text_data_set: 'A transcription', + }; + const transform = (value: unknown) => + new CustomValidationPipe().transform(value, { + type: 'body', + metatype: CreateMultipleDataSetDto, + }); + + it.each([true, false])( + 'accepts nested attempts and is_test=%s', + async (is_test) => { + const result = await transform({ attempts: [attempt], is_test }); + expect(result).toEqual({ attempts: [attempt], is_test }); + expect(result.attempts[0]).toBeInstanceOf(AttemptsDto); + }, + ); + + it('defaults an omitted is_test to false', async () => { + expect(await transform({ attempts: [attempt] })).toMatchObject({ + is_test: false, + }); + }); + + it.each([ + {}, + { attempts: [] }, + { attempts: null }, + { attempts: attempt }, + { attempts: [null] }, + { attempts: ['text'] }, + { attempts: [{}] }, + { attempts: [{ ...attempt, micro_task_id: '' }] }, + { attempts: [{ ...attempt, micro_task_id: 'not-a-uuid' }] }, + { attempts: [{ micro_task_id: attempt.micro_task_id }] }, + { attempts: [{ ...attempt, text_data_set: '' }] }, + { attempts: [{ ...attempt, text_data_set: ' \n\t' }] }, + { attempts: [{ ...attempt, text_data_set: 123 }] }, + { attempts: [{ ...attempt, extra: true }] }, + { attempts: [attempt], batch: 1 }, + { attempts: [attempt], is_test: 'false' }, + { attempts: [attempt], is_test: 0 }, + { attempts: [attempt], is_test: null }, + ])('rejects invalid payload %j', async (payload) => { + await expect(transform(payload)).rejects.toBeInstanceOf(HttpException); + }); +}); diff --git a/src/task_distribution/dto/DataSet.dto.ts b/src/task_distribution/dto/DataSet.dto.ts index 6016b4f..c46dd86 100644 --- a/src/task_distribution/dto/DataSet.dto.ts +++ b/src/task_distribution/dto/DataSet.dto.ts @@ -1,16 +1,34 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsUUID } from 'class-validator'; +import { Type } from 'class-transformer'; +import { + ArrayNotEmpty, + IsArray, + IsBoolean, + IsNotEmpty, + IsString, + IsUUID, + Matches, + ValidateNested, +} from 'class-validator'; export class AttemptsDto { @ApiProperty() @IsUUID() micro_task_id: string; @ApiProperty() + @IsString() + @IsNotEmpty() + @Matches(/\S/, { message: 'text_data_set must contain non-whitespace text' }) text_data_set: string; } export class CreateMultipleDataSetDto { @ApiProperty({ type: [AttemptsDto] }) + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => AttemptsDto) attempts: AttemptsDto[]; - @ApiProperty() - is_test: boolean; + @ApiProperty({ required: false, default: false }) + @IsBoolean() + is_test: boolean = false; } diff --git a/src/task_distribution/service/TaskSubmission.service.spec.ts b/src/task_distribution/service/TaskSubmission.service.spec.ts new file mode 100644 index 0000000..417ab43 --- /dev/null +++ b/src/task_distribution/service/TaskSubmission.service.spec.ts @@ -0,0 +1,281 @@ +jest.mock('./ContributorMicroTask.service', () => ({ + ContributorMicroTaskService: class {}, +})); +jest.mock('src/project/service/Task.service', () => ({ + TaskService: class {}, +})); +jest.mock('src/auth/service/User.service', () => ({ UserService: class {} })); +jest.mock('src/data_set/service/MicroTask.service', () => ({ + MicroTaskService: class {}, +})); +jest.mock('src/project/service/UserTask.service', () => ({ + UserTaskService: class {}, +})); +jest.mock('src/cache/CacheService.service', () => ({ CacheService: class {} })); +jest.mock('src/data_set/service/RejectionReason.service', () => ({ + RejectionReasonService: class {}, +})); +jest.mock('src/common/service/File.service', () => ({ FileService: class {} })); +jest.mock('src/base_data/service/DataSetAnnotation.service', () => ({ + DataSetAnnotationService: class {}, +})); +jest.mock('./ReviewerTasks.service', () => ({ ReviewerTaskService: class {} })); + +import { TaskSubmissionService } from './TaskSubmission.service'; +import { DataSetService } from 'src/data_set/service/DataSet.service'; +import { DataSetStatus } from 'src/utils/constants/DataSetStatus.constant'; +import { taskTypes } from 'src/utils/constants/Task.constant'; +import { ContributorMicroTasksConstantStatus } from 'src/utils/constants/ContributorMicroTasks.constant'; + +describe.each(['text', 'audio'] as const)('%s submissions', (kind) => { + let service: TaskSubmissionService; + let history: { id: string; micro_task_id: string; status: string }[]; + let assignment: { + id: string; + micro_task_ids: string[]; + current_batch: number; + batch: number; + total_micro_tasks: number; + }; + let dataSets: { + findAll: jest.Mock; + validateSubmission: jest.Mock; + createMultipleTextDataSet: jest.Mock; + createMultipleAudioDataSet: jest.Mock; + }; + let contributorTasks: { findOne: jest.Mock; update: jest.Mock }; + let runner: { + connect: jest.Mock; + startTransaction: jest.Mock; + commitTransaction: jest.Mock; + rollbackTransaction: jest.Mock; + release: jest.Mock; + }; + let task: { + taskType: { task_type: string }; + taskRequirement: { max_retry_per_task: number }; + }; + + beforeEach(() => { + history = []; + assignment = { + id: 'assignment', + micro_task_ids: ['mt-1', 'mt-2', 'mt-3'], + current_batch: 0, + batch: 3, + total_micro_tasks: 3, + }; + task = { + taskType: { + task_type: + kind === 'text' ? taskTypes.TEXT_TO_TEXT : taskTypes.TEXT_TO_AUDIO, + }, + taskRequirement: { max_retry_per_task: 1 }, + }; + runner = { + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + release: jest.fn(), + }; + dataSets = { + // Honor the projection so missing status/micro_task_id regressions fail. + findAll: jest + .fn() + .mockImplementation(async ({ select }) => + history.map((row) => + Object.fromEntries( + Object.keys(select).map((key) => [key, row[key]]), + ), + ), + ), + validateSubmission: jest.fn(DataSetService.prototype.validateSubmission), + createMultipleTextDataSet: jest.fn(), + createMultipleAudioDataSet: jest.fn(), + }; + contributorTasks = { + findOne: jest.fn().mockImplementation(async () => assignment), + update: jest.fn(), + }; + service = new TaskSubmissionService( + dataSets as any, + contributorTasks as any, + { + findOne: jest.fn().mockResolvedValue(task), + updateOrCreateUserToPending: jest.fn(), + } as any, + { createQueryRunner: () => runner } as any, + { + findOne: jest.fn().mockResolvedValue({ + dialect_id: 'dialect', + language_id: 'language', + }), + } as any, + { + findAll: jest + .fn() + .mockImplementation(async ({ where }) => + where.id.value.map((id: string) => ({ id, is_test: false })), + ), + } as any, + { findOneOrCreate: jest.fn() } as any, + { clearContributorTaskCache: jest.fn() } as any, + ); + }); + + const submit = (ids: string[]) => + kind === 'text' + ? service.submitMultipleTextDatasets( + 'user', + ids.map((micro_task_id) => ({ + micro_task_id, + text_data_set: 'text', + })), + 'task', + ) + : service.submitMultipleAudioDatasets( + 'user', + ids.map((micro_task_id) => ({ + micro_task_id, + file_path: 'audio.wav', + audio_duration: 1, + })), + 'task', + ); + + it('counts a partial fresh request, not the configured batch size', async () => { + await submit(['mt-1']); + expect(contributorTasks.update).toHaveBeenCalledWith( + 'assignment', + expect.objectContaining({ + current_batch: 1, + status: ContributorMicroTasksConstantStatus.IN_PROGRESS, + }), + runner, + ); + expect(runner.commitTransaction).toHaveBeenCalled(); + }); + + it('marks the assignment completed after the final fresh submissions', async () => { + assignment.current_batch = 1; + await submit(['mt-2', 'mt-3']); + expect(contributorTasks.update).toHaveBeenCalledWith( + 'assignment', + expect.objectContaining({ + current_batch: 3, + status: ContributorMicroTasksConstantStatus.COMPLETED, + }), + runner, + ); + }); + + it.each([1, 3])( + 'preserves progress and deadline on a rejected retry at progress %s', + async (progress) => { + assignment.current_batch = progress; + history.push({ + id: 'old', + micro_task_id: 'mt-1', + status: DataSetStatus.REJECTED, + }); + await submit(['mt-1']); + expect(contributorTasks.update).not.toHaveBeenCalled(); + expect( + dataSets[ + kind === 'text' + ? 'createMultipleTextDataSet' + : 'createMultipleAudioDataSet' + ], + ).toHaveBeenCalled(); + expect(runner.commitTransaction).toHaveBeenCalled(); + }, + ); + + it('validates retry limits separately for each microtask', async () => { + assignment.current_batch = 2; + history.push( + ...['mt-1', 'mt-2'].map((micro_task_id) => ({ + id: micro_task_id, + micro_task_id, + status: DataSetStatus.REJECTED, + })), + ); + await submit(['mt-1', 'mt-2']); + expect(dataSets.validateSubmission).toHaveBeenNthCalledWith( + 1, + [history[0]], + 'user', + 1, + ); + expect(dataSets.validateSubmission).toHaveBeenNthCalledWith( + 2, + [history[1]], + 'user', + 1, + ); + expect(contributorTasks.update).not.toHaveBeenCalled(); + }); + + it('counts only fresh microtasks in a mixed retry request', async () => { + assignment.current_batch = 1; + history.push({ + id: 'old', + micro_task_id: 'mt-1', + status: DataSetStatus.REJECTED, + }); + await submit(['mt-1', 'mt-2']); + expect(contributorTasks.update).toHaveBeenCalledWith( + 'assignment', + expect.objectContaining({ current_batch: 2 }), + runner, + ); + }); + + it.each([DataSetStatus.PENDING, DataSetStatus.APPROVED])( + 'rejects an existing %s attempt before writing any requested datasets', + async (status) => { + history.push({ id: 'old', micro_task_id: 'mt-1', status }); + await expect(submit(['mt-1', 'mt-2'])).rejects.toThrow( + 'You already have contributed', + ); + expect(runner.startTransaction).not.toHaveBeenCalled(); + expect(dataSets.createMultipleTextDataSet).not.toHaveBeenCalled(); + expect(dataSets.createMultipleAudioDataSet).not.toHaveBeenCalled(); + }, + ); + + it('rejects retries beyond the per-microtask limit', async () => { + history.push( + ...['old-1', 'old-2'].map((id) => ({ + id, + micro_task_id: 'mt-1', + status: DataSetStatus.REJECTED, + })), + ); + await expect(submit(['mt-1'])).rejects.toThrow( + 'Maximum retry amount reached', + ); + expect(runner.startTransaction).not.toHaveBeenCalled(); + }); + + it('rejects duplicate microtask IDs within a request', async () => { + await expect(submit(['mt-1', 'mt-1'])).rejects.toThrow( + 'only be submitted once per request', + ); + expect(runner.startTransaction).not.toHaveBeenCalled(); + }); + + it('rolls back without updating progress when dataset creation fails', async () => { + dataSets[ + kind === 'text' + ? 'createMultipleTextDataSet' + : 'createMultipleAudioDataSet' + ].mockRejectedValue(new Error('save failed')); + await expect(submit(['mt-1'])).rejects.toThrow('save failed'); + expect(contributorTasks.update).not.toHaveBeenCalled(); + expect(runner.rollbackTransaction).toHaveBeenCalled(); + expect(runner.commitTransaction).not.toHaveBeenCalled(); + expect(runner.release).toHaveBeenCalled(); + }); +}); diff --git a/src/task_distribution/service/TaskSubmission.service.ts b/src/task_distribution/service/TaskSubmission.service.ts index 00fd9ce..06457a8 100644 --- a/src/task_distribution/service/TaskSubmission.service.ts +++ b/src/task_distribution/service/TaskSubmission.service.ts @@ -72,7 +72,7 @@ export class TaskSubmissionService { ); const contributorSubmittedDataSets = await this.dataSetService.findAll({ where: { micro_task_id: In(micro_task_ids), contributor_id: user_id }, - select: { id: true }, + select: { id: true, micro_task_id: true, status: true }, }); const task_type = task.taskType.task_type || ''; if ( @@ -93,7 +93,9 @@ export class TaskSubmissionService { ); } return this.dataSetService.validateSubmission( - contributorSubmittedDataSets, + contributorSubmittedDataSets.filter( + (dataSet) => dataSet.micro_task_id === item.micro_task_id, + ), user_id, task.taskRequirement.max_retry_per_task, ); @@ -217,8 +219,13 @@ export class TaskSubmissionService { // 'You have not submitted all the expected micro tasks for this batch', // ); // } - const nextTaskIds = datasets.filter((d) => - contributorMicroTasks.micro_task_ids.includes(d.micro_task_id), + // Retries create a new dataset, but do not complete another microtask. + const nextTaskIds = datasets.filter( + (d) => + contributorMicroTasks.micro_task_ids.includes(d.micro_task_id) && + !contributorSubmittedDataSets.some( + (previous) => previous.micro_task_id === d.micro_task_id, + ), ); const current_batch = contributorMicroTasks.current_batch; // if (current_batch >= contributorMicroTasks.total_micro_tasks) { @@ -228,10 +235,10 @@ export class TaskSubmissionService { // } if (current_batch < contributorMicroTasks.total_micro_tasks) { const nextBatch = - contributorMicroTasks.current_batch + contributorMicroTasks.batch; + contributorMicroTasks.current_batch + nextTaskIds.length; const totalDatasets = contributorMicroTasks.total_micro_tasks; const batch = Math.min(totalDatasets, nextBatch); - if (nextTaskIds) { + if (nextTaskIds.length > 0) { const status = batch >= contributorMicroTasks.total_micro_tasks ? ContributorMicroTasksConstantStatus.COMPLETED @@ -323,6 +330,21 @@ export class TaskSubmissionService { micro_task_ids, is_test, ); + const contributorSubmittedDataSets = await this.dataSetService.findAll({ + where: { micro_task_id: In(micro_task_ids), contributor_id: user_id }, + select: { id: true, micro_task_id: true, status: true }, + }); + await Promise.all( + datasets.map((item) => + this.dataSetService.validateSubmission( + contributorSubmittedDataSets.filter( + (dataSet) => dataSet.micro_task_id === item.micro_task_id, + ), + user_id, + task.taskRequirement.max_retry_per_task, + ), + ), + ); await this.cacheService.clearContributorTaskCache(user_id, task_id); if (is_test) { const test_microTasks: MicroTask[] = @@ -431,8 +453,13 @@ export class TaskSubmissionService { where: { contributor_id: user_id, task_id: task_id }, }); if (contributorMicroTasks) { - const nextTaskIds = datasets.filter((d) => - contributorMicroTasks.micro_task_ids.includes(d.micro_task_id), + // Retries create a new dataset, but do not complete another microtask. + const nextTaskIds = datasets.filter( + (d) => + contributorMicroTasks.micro_task_ids.includes(d.micro_task_id) && + !contributorSubmittedDataSets.some( + (previous) => previous.micro_task_id === d.micro_task_id, + ), ); const current_batch = contributorMicroTasks.current_batch; // if (current_batch >= contributorMicroTasks.total_micro_tasks) { @@ -443,10 +470,10 @@ export class TaskSubmissionService { // } if (current_batch < contributorMicroTasks.total_micro_tasks) { const nextBatch = - contributorMicroTasks.current_batch + contributorMicroTasks.batch; + contributorMicroTasks.current_batch + nextTaskIds.length; const totalDatasets = contributorMicroTasks.total_micro_tasks; const batch = Math.min(totalDatasets, nextBatch); - if (nextTaskIds) { + if (nextTaskIds.length > 0) { const status = batch >= contributorMicroTasks.total_micro_tasks ? ContributorMicroTasksConstantStatus.COMPLETED From 1142fb5aa2852fc39a661d1b15e9b1add03488c1 Mon Sep 17 00:00:00 2001 From: AlexKalll Date: Mon, 7 Sep 2026 18:30:15 +0300 Subject: [PATCH 3/5] fix(finance): accept valid decimal withdrawals and test payout safety --- src/finance/service/Wallet.service.spec.ts | 236 ++++++++++++++++++++- src/finance/service/Wallet.service.ts | 2 +- 2 files changed, 236 insertions(+), 2 deletions(-) diff --git a/src/finance/service/Wallet.service.spec.ts b/src/finance/service/Wallet.service.spec.ts index 267b578..e9e6625 100644 --- a/src/finance/service/Wallet.service.spec.ts +++ b/src/finance/service/Wallet.service.spec.ts @@ -1,4 +1,10 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, Logger } from '@nestjs/common'; +import { DataSource, QueryRunner, Repository } from 'typeorm'; +import { PaginationService } from 'src/common/service/pagination.service'; +import { Transaction } from '../entities/Transaction.entity'; +import { Wallet } from '../entities/Wallet.entity'; +import SantimpaySdk from './SantimPay.service'; +import { TransactionService } from './Transaction.service'; import { WalletService } from './Wallet.service'; describe('WalletService.reserveWithdrawal', () => { @@ -67,3 +73,231 @@ describe('WalletService.reserveWithdrawal', () => { ); }); }); + +describe('WalletService withdrawal lifecycle (mock provider, no database)', () => { + const createService = (balance = '100.0000') => { + const wallet = { id: 'wallet-id', user_id: 'user-id', balance }; + let transaction: Transaction | null = null; + // Return snapshots so stale reconciliation reads cannot mutate persisted state. + const manager = { + findOne: jest.fn(async (entity: unknown) => + entity === Wallet ? { ...wallet } : transaction && { ...transaction }, + ), + findOneOrFail: jest.fn(async (entity: unknown) => { + if (entity === Wallet) return { ...wallet }; + if (!transaction) throw new Error('Transaction not found'); + return { ...transaction }; + }), + save: jest.fn(async (entity: Wallet | Transaction) => { + if (entity.id === wallet.id) Object.assign(wallet, entity); + else transaction = { ...entity } as Transaction; + return { ...entity }; + }), + }; + const provider = { + sendToCustomer: jest.fn().mockResolvedValue({ + transactionId: 'provider-id', + status: 'pending', + }), + checkTransactionStatus: jest + .fn() + .mockResolvedValue({ status: 'success' }), + }; + const transactionService = { + create: jest.fn(async (data: Partial) => { + transaction = { id: 'transaction-id', ...data } as Transaction; + return { ...transaction }; + }), + findOne: jest.fn(async () => transaction && { ...transaction }), + }; + const dataSource = { + transaction: jest.fn(async (run: (value: typeof manager) => unknown) => + run(manager), + ), + }; + const service = new WalletService( + {} as Repository, + provider as unknown as SantimpaySdk, + transactionService as unknown as TransactionService, + {} as PaginationService, + dataSource as unknown as DataSource, + ); + const reserve = (amount = 25) => + service.reserveWithdrawal( + 'user-id', + amount, + '+251911234567', + 'Telebirr', + { manager } as unknown as QueryRunner, + ); + return { service, reserve, wallet, manager, provider, transactionService }; + }; + + afterEach(() => jest.restoreAllMocks()); + + it.each([0.29, 1.1, 19.99, 0.01, 100])( + 'accepts a legitimate two-decimal withdrawal of %s', + async (amount) => { + const { reserve, wallet } = createService(); + await expect(reserve(amount)).resolves.toMatchObject({ + amount: -amount, + status: 'Reserved', + }); + expect(Number(wallet.balance)).toBeCloseTo(100 - amount, 8); + }, + ); + + it.each([0, -1, NaN, Infinity, -Infinity, 0.001, 1.001, 0.2900001])( + 'rejects invalid amount %s without touching persistence or the provider', + async (amount) => { + const { reserve, manager, provider, transactionService } = + createService(); + await expect(reserve(amount)).rejects.toBeInstanceOf(BadRequestException); + expect(manager.findOne).not.toHaveBeenCalled(); + expect(manager.save).not.toHaveBeenCalled(); + expect(transactionService.create).not.toHaveBeenCalled(); + expect(provider.sendToCustomer).not.toHaveBeenCalled(); + }, + ); + + it('rejects insufficient balance without a debit or provider call', async () => { + const { reserve, wallet, manager, provider, transactionService } = + createService('24.9999'); + await expect(reserve()).rejects.toThrow('Insufficient Balance'); + expect(wallet.balance).toBe('24.9999'); + expect(manager.save).not.toHaveBeenCalled(); + expect(transactionService.create).not.toHaveBeenCalled(); + expect(provider.sendToCustomer).not.toHaveBeenCalled(); + }); + + it('rejects a missing wallet without creating a debit', async () => { + const { reserve, manager, transactionService, provider } = createService(); + manager.findOne.mockResolvedValueOnce(null); + await expect(reserve()).rejects.toThrow('Insufficient Balance'); + expect(manager.save).not.toHaveBeenCalled(); + expect(transactionService.create).not.toHaveBeenCalled(); + expect(provider.sendToCustomer).not.toHaveBeenCalled(); + }); + + it('reserves, submits once, and settles without a second debit', async () => { + const { reserve, service, wallet, provider, manager, transactionService } = + createService(); + const reservation = await reserve(); + expect(provider.sendToCustomer).not.toHaveBeenCalled(); + expect(wallet.balance).toBe(75); + expect(await service.submitWithdrawal(reservation.id)).toMatchObject({ + status: 'Submitted', + provider_reference: 'provider-id', + }); + expect(provider.sendToCustomer).toHaveBeenCalledWith( + reservation.id, + 25, + 'Withdrawal', + '+251911234567', + 'Telebirr', + ); + await expect(service.submitWithdrawal(reservation.id)).rejects.toThrow( + 'Withdrawal cannot be submitted', + ); + expect(await service.reconcileWithdrawal(reservation.id)).toMatchObject({ + status: 'Settled', + amount: -25, + provider_status: 'success', + }); + await service.reconcileWithdrawal(reservation.id); + expect(provider.checkTransactionStatus).toHaveBeenCalledTimes(1); + expect(provider.checkTransactionStatus).toHaveBeenCalledWith('provider-id'); + expect(provider.sendToCustomer).toHaveBeenCalledTimes(1); + expect(transactionService.create).toHaveBeenCalledTimes(1); + expect(wallet.balance).toBe(75); + expect( + manager.save.mock.calls.filter(([row]) => row.id === wallet.id), + ).toHaveLength(1); + expect(manager.findOne).toHaveBeenCalledWith(Transaction, { + where: { id: reservation.id }, + lock: { mode: 'pessimistic_write' }, + }); + }); + + it.each(['failed', 'rejected', 'cancelled', 'canceled'])( + 'reverses confirmed %s exactly once, including a stale reconciliation read', + async (status) => { + const { + reserve, + service, + wallet, + provider, + manager, + transactionService, + } = createService(); + const reservation = await reserve(); + const submitted = await service.submitWithdrawal(reservation.id); + provider.checkTransactionStatus.mockResolvedValue({ status }); + expect(await service.reconcileWithdrawal(reservation.id)).toMatchObject({ + status: 'Reversed', + provider_status: status, + }); + expect(wallet.balance).toBe(100); + // Simulate a worker that read Submitted before the first reversal committed. + transactionService.findOne.mockResolvedValueOnce(submitted); + expect(await service.reconcileWithdrawal(reservation.id)).toMatchObject({ + status: 'Reversed', + }); + await service.reconcileWithdrawal(reservation.id); + expect(provider.checkTransactionStatus).toHaveBeenCalledTimes(2); + expect(wallet.balance).toBe(100); + expect( + manager.save.mock.calls.filter(([row]) => row.id === wallet.id), + ).toHaveLength(2); + expect(manager.findOneOrFail).toHaveBeenCalledWith(Transaction, { + where: { id: reservation.id }, + lock: { mode: 'pessimistic_write' }, + }); + expect(manager.findOneOrFail).toHaveBeenCalledWith(Wallet, { + where: { user_id: 'user-id' }, + lock: { mode: 'pessimistic_write' }, + }); + }, + ); + + it('keeps an ambiguous submission reserved and reconciles by the local ID', async () => { + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + const { reserve, service, wallet, provider } = createService(); + const reservation = await reserve(); + provider.sendToCustomer.mockRejectedValue(new Error('Connection reset')); + expect(await service.submitWithdrawal(reservation.id)).toMatchObject({ + status: 'Processing', + }); + expect(wallet.balance).toBe(75); + await expect(service.submitWithdrawal(reservation.id)).rejects.toThrow( + 'Withdrawal cannot be submitted', + ); + provider.checkTransactionStatus.mockRejectedValueOnce(new Error('Timeout')); + expect(await service.reconcileWithdrawal(reservation.id)).toMatchObject({ + status: 'Processing', + }); + expect(wallet.balance).toBe(75); + expect(await service.reconcileWithdrawal(reservation.id)).toMatchObject({ + status: 'Settled', + }); + expect(provider.checkTransactionStatus).toHaveBeenCalledWith( + reservation.id, + ); + expect(provider.sendToCustomer).toHaveBeenCalledTimes(1); + expect(wallet.balance).toBe(75); + }); + + it.each(['pending', 'unknown'])( + 'keeps funds reserved for a nonterminal provider status of %s', + async (status) => { + const { reserve, service, wallet, provider } = createService(); + const reservation = await reserve(); + await service.submitWithdrawal(reservation.id); + provider.checkTransactionStatus.mockResolvedValue({ status }); + expect(await service.reconcileWithdrawal(reservation.id)).toMatchObject({ + status: 'Submitted', + }); + expect(wallet.balance).toBe(75); + }, + ); +}); diff --git a/src/finance/service/Wallet.service.ts b/src/finance/service/Wallet.service.ts index 0f00e38..2796f6a 100644 --- a/src/finance/service/Wallet.service.ts +++ b/src/finance/service/Wallet.service.ts @@ -113,7 +113,7 @@ export class WalletService { if ( !Number.isFinite(amount) || amount <= 0 || - Math.round(amount * 100) !== amount * 100 + Math.round(amount * 100) / 100 !== amount ) { throw new BadRequestException( 'Amount must be a positive value with at most two decimal places', From 671a787a02eb2c88701a89832ce2a51cbed021d2 Mon Sep 17 00:00:00 2001 From: AlexKalll Date: Mon, 7 Sep 2026 18:30:58 +0300 Subject: [PATCH 4/5] docs: explain demo seeding and record workflow fixes --- CHANGES.md | 19 +++++++++++++++++++ README.md | 41 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 29bbe98..bda1ebd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -195,6 +195,25 @@ record. - Added an `adminer` service to `docker-compose.yaml` (port `5555:8080`) for local database management; waits on the `postgres` healthcheck and joins the private application network. - Simply it added adminer container to docker-compose for DB inspection +## 2026-09-06 - Complete demo user profiles + +### Seed and test data + +- Expanded the existing test-user seed with complete Amharic profiles, Ethiopian location data, unique phone numbers, and active states for new and existing demo users. +- Ensure contributor/reviewer wallets and linked scores exist without resetting earned balances or scores on reruns. Projects, tasks, assignments, and submissions remain manual UI operations. + +## 2026-09-07 - End-to-end workflow corrections + +### Validation and submissions + +- Added strict nested validation for text submission attempts and compatible validation metadata for annotation/rejection reference-data DTOs. +- Validate prior submissions per microtask with their statuses so rejected work can be retried without accepting duplicate pending/approved work. +- Advance contributor progress by newly submitted microtasks rather than configured batch size; retries do not increment progress or renew deadlines. Added text/audio regression coverage. + +### Finance + +- Corrected two-decimal withdrawal validation to accept legitimate floating-point amounts such as 0.29. +- Added mocked-provider coverage for reservation, settlement, ambiguous transport errors, insufficient balance, and idempotent failure reversal. Real payouts remain disabled during E2E verification. ## How to record future changes diff --git a/README.md b/README.md index 652287b..ad98f6f 100644 --- a/README.md +++ b/README.md @@ -415,14 +415,45 @@ All routes are served under `/api`. For exact request and response shapes, use Swagger at `/doc`. -## Test Account +## Test Accounts -```text -Super Admin -username: guest@gmail.com -password: guest@1234 +Run these commands from the backend directory against a **local/development database only**. +In `.env`, enable the demo users: + +```env +NODE_ENV=development +ENABLE_DEMO_USERS_SEED=true +``` + +**Native (without Docker):** with dependencies installed and `DATABASE_URL` pointing to a database reachable from your host: + +```bash +npm run migration:run +npm run seed ``` +**Docker:** rebuild to include the latest seed, then run the compiled scripts: + +```bash +docker compose up -d --build +docker compose exec -T app npm run migration:run:prod +docker compose exec -T -e NODE_ENV=development -e ENABLE_DEMO_USERS_SEED=true app npm run seed:prod +``` + +All demo accounts use password `12345678`: + +| Role | Accounts | +| --- | --- | +| SuperAdmin | `super@gmail.com`, `super1@gmail.com` | +| ProjectManager | `proj@gmail.com`, `proj1@gmail.com` | +| Facilitator | `faci@gmail.com`, `faci1@gmail.com` | +| Reviewer | `rev@gmail.com`, `rev2@gmail.com` | +| Contributor (mobile) | `cont@gmail.com`, `cont1@gmail.com` | + +For mobile login, enter `123456789` or `234567890` in the phone field; the app adds `+251`. +The seed creates or updates demo profiles, but leaves project/task creation and assignment to the UI. +Never enable demo seeding against a production database. + ## Operational Notes - `NODE_ENV=production` cannot use `CORS_ORIGIN=*`; configure a concrete origin or comma-separated origin list. From 605f0b372e8eb828076bfdb49e3547e7966a35f6 Mon Sep 17 00:00:00 2001 From: AlexKalll Date: Wed, 9 Sep 2026 11:34:54 +0300 Subject: [PATCH 5/5] fix(task-submission): set contributor role for test submissions --- CHANGES.md | 9 ++++ .../service/TaskSubmission.service.spec.ts | 47 ++++++++++++++++--- .../service/TaskSubmission.service.ts | 7 ++- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index bda1ebd..6deb7e8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -215,6 +215,15 @@ record. - Corrected two-decimal withdrawal validation to accept legitimate floating-point amounts such as 0.29. - Added mocked-provider coverage for reservation, settlement, ambiguous transport errors, insufficient balance, and idempotent failure reversal. Real payouts remain disabled during E2E verification. +## 2026-09-09 - Test submission benchmark fix + +### Validation and submissions + +- Added the required `Contributor` role and `Pending` status when a contributor + submits a test text dataset, preventing the `UserTask` insert from failing on + its non-null role column. +- Added focused regression coverage for the test text submission path. + ## How to record future changes When making non-trivial modifications, add a short entry under a new dated diff --git a/src/task_distribution/service/TaskSubmission.service.spec.ts b/src/task_distribution/service/TaskSubmission.service.spec.ts index 417ab43..9773cd2 100644 --- a/src/task_distribution/service/TaskSubmission.service.spec.ts +++ b/src/task_distribution/service/TaskSubmission.service.spec.ts @@ -24,7 +24,7 @@ jest.mock('./ReviewerTasks.service', () => ({ ReviewerTaskService: class {} })); import { TaskSubmissionService } from './TaskSubmission.service'; import { DataSetService } from 'src/data_set/service/DataSet.service'; import { DataSetStatus } from 'src/utils/constants/DataSetStatus.constant'; -import { taskTypes } from 'src/utils/constants/Task.constant'; +import { taskTypes, UserTaskStatus } from 'src/utils/constants/Task.constant'; import { ContributorMicroTasksConstantStatus } from 'src/utils/constants/ContributorMicroTasks.constant'; describe.each(['text', 'audio'] as const)('%s submissions', (kind) => { @@ -44,6 +44,11 @@ describe.each(['text', 'audio'] as const)('%s submissions', (kind) => { createMultipleAudioDataSet: jest.Mock; }; let contributorTasks: { findOne: jest.Mock; update: jest.Mock }; + let taskService: { + findOne: jest.Mock; + updateOrCreateUserToPending: jest.Mock; + }; + let microTasks: { id: string; is_test: boolean }[]; let runner: { connect: jest.Mock; startTransaction: jest.Mock; @@ -65,6 +70,10 @@ describe.each(['text', 'audio'] as const)('%s submissions', (kind) => { batch: 3, total_micro_tasks: 3, }; + microTasks = assignment.micro_task_ids.map((id) => ({ + id, + is_test: false, + })); task = { taskType: { task_type: @@ -98,13 +107,14 @@ describe.each(['text', 'audio'] as const)('%s submissions', (kind) => { findOne: jest.fn().mockImplementation(async () => assignment), update: jest.fn(), }; + taskService = { + findOne: jest.fn().mockResolvedValue(task), + updateOrCreateUserToPending: jest.fn(), + }; service = new TaskSubmissionService( dataSets as any, contributorTasks as any, - { - findOne: jest.fn().mockResolvedValue(task), - updateOrCreateUserToPending: jest.fn(), - } as any, + taskService as any, { createQueryRunner: () => runner } as any, { findOne: jest.fn().mockResolvedValue({ @@ -116,7 +126,9 @@ describe.each(['text', 'audio'] as const)('%s submissions', (kind) => { findAll: jest .fn() .mockImplementation(async ({ where }) => - where.id.value.map((id: string) => ({ id, is_test: false })), + where.id.value.map((id: string) => + microTasks.find((microTask) => microTask.id === id), + ), ), } as any, { findOneOrCreate: jest.fn() } as any, @@ -157,6 +169,29 @@ describe.each(['text', 'audio'] as const)('%s submissions', (kind) => { expect(runner.commitTransaction).toHaveBeenCalled(); }); + it('creates a pending contributor task for a text test submission', async () => { + if (kind !== 'text') return; + + microTasks[0].is_test = true; + await service.submitMultipleTextDatasets( + 'user', + [{ micro_task_id: 'mt-1', text_data_set: 'test response' }], + 'task', + true, + ); + + expect(taskService.updateOrCreateUserToPending).toHaveBeenCalledWith( + { + task_id: 'task', + user_id: 'user', + role: 'Contributor', + status: UserTaskStatus.PENDING, + }, + runner, + ); + expect(runner.commitTransaction).toHaveBeenCalled(); + }); + it('marks the assignment completed after the final fresh submissions', async () => { assignment.current_batch = 1; await submit(['mt-2', 'mt-3']); diff --git a/src/task_distribution/service/TaskSubmission.service.ts b/src/task_distribution/service/TaskSubmission.service.ts index 06457a8..85ad0df 100644 --- a/src/task_distribution/service/TaskSubmission.service.ts +++ b/src/task_distribution/service/TaskSubmission.service.ts @@ -133,7 +133,12 @@ export class TaskSubmissionService { queryRunner, ); await this.taskService.updateOrCreateUserToPending( - { task_id: task_id, user_id: user_id }, + { + task_id: task_id, + user_id: user_id, + role: 'Contributor', + status: UserTaskStatus.PENDING, + }, queryRunner, ); // await this.userScoreService.updateScore(