diff --git a/api/apps/api/src/migrations/api/1638891599444-AddScenarioLocksTable.ts b/api/apps/api/src/migrations/api/1638891599444-AddScenarioLocksTable.ts new file mode 100644 index 0000000000..c4db3cce09 --- /dev/null +++ b/api/apps/api/src/migrations/api/1638891599444-AddScenarioLocksTable.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddScenarioLocksTable1638891599444 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "scenario_locks" ( + scenario_id uuid NOT NULL, + user_id uuid NOT NULL, + created_at TIMESTAMP DEFAULT now() + );`, + ); + + await queryRunner.query( + `ALTER TABLE "scenario_locks" ADD CONSTRAINT scenario_locks_fk FOREIGN KEY (scenario_id) REFERENCES scenarios(id) ON DELETE CASCADE;`, + ); + + await queryRunner.query( + `ALTER TABLE "scenario_locks" ADD CONSTRAINT scenario_locks_fk_1 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;`, + ); + + await queryRunner.query( + `ALTER TABLE "scenario_locks" ADD CONSTRAINT scenario_locks_pk PRIMARY KEY (scenario_id, user_id);`, + ); + + await queryRunner.query( + `ALTER TABLE "scenario_locks" ADD CONSTRAINT scenario_locks_un UNIQUE (scenario_id);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "scenario_locks" DROP CONSTRAINT scenario_locks_un;`, + ); + + await queryRunner.query( + `ALTER TABLE "scenario_locks" DROP CONSTRAINT scenario_locks_pk;`, + ); + + await queryRunner.query( + `ALTER TABLE "scenario_locks" DROP CONSTRAINT scenario_locks_fk_1;`, + ); + + await queryRunner.query( + `ALTER TABLE "scenario_locks" DROP CONSTRAINT scenario_locks_fk;`, + ); + + await queryRunner.query(`DROP TABLE "scenario_locks";`); + } +} diff --git a/api/apps/api/src/modules/scenarios/locks/lock.service.spec.ts b/api/apps/api/src/modules/scenarios/locks/lock.service.spec.ts new file mode 100644 index 0000000000..d9057e6016 --- /dev/null +++ b/api/apps/api/src/modules/scenarios/locks/lock.service.spec.ts @@ -0,0 +1,129 @@ +import { Test } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { EntityManager } from 'typeorm'; +import { Either, left, right } from 'fp-ts/Either'; + +import { FixtureType } from '@marxan/utils/tests/fixture-type'; +import { AcquireFailure, lockedScenario, LockService } from './lock.service'; +import { ScenarioLockEntity } from './scenario.lock.entity'; + +let fixtures: FixtureType; + +beforeEach(async () => { + fixtures = await getFixtures(); +}); + +it(`should be able to grab lock if lock is available`, async () => { + await fixtures.GivenScenarioIsNotLocked(); + const lock = await fixtures.WhenAcquiringALock(); + await fixtures.ThenScenarioIsLocked(lock); +}); + +it(`should not be able to grab lock if lock is not available`, async () => { + await fixtures.GivenScenarioIsLocked(); + const lock = await fixtures.WhenAcquiringALock(); + await fixtures.ThenALockedScenarioErrorIsReturned(lock); +}); + +it(`should be able to release the lock if lock exists`, async () => { + await fixtures.GivenScenarioIsLocked(); + await fixtures.WhenTheLockIsReleased(); + await fixtures.ThenScenarioIsNotLocked(); +}); + +it(`should not be able to release the lock if lock does not exist`, async () => { + await fixtures.GivenScenarioIsNotLocked(); + await fixtures.WhenTheLockIsReleased(); + await fixtures.ThenScenarioIsNotLocked(); +}); + +it(`isLocked should return true if a lock exists`, async () => { + await fixtures.GivenScenarioIsLocked(); + const result = await fixtures.WhenCheckingIfAScenarioIsLocked(); + await fixtures.ThenIsLockedReturnsTrue(result); +}); + +it(`isLocked should return false if lock no lock exists`, async () => { + await fixtures.GivenScenarioIsNotLocked(); + const result = await fixtures.WhenCheckingIfAScenarioIsLocked(); + await fixtures.ThenIsLockedReturnsFalse(result); +}); + +async function getFixtures() { + const USER_ID = 'user-id'; + const SCENARIO_ID = 'scenario-id'; + + const mockEntityManager = { + save: jest.fn(), + }; + + const sandbox = await Test.createTestingModule({ + providers: [ + LockService, + { + provide: getRepositoryToken(ScenarioLockEntity), + useValue: { + manager: { + transaction: (fn: (em: Partial) => Promise) => + fn(mockEntityManager), + }, + count: jest.fn(), + delete: jest.fn(), + }, + }, + ], + }) + .compile() + .catch((error) => { + console.log(error); + throw error; + }); + + const sut = sandbox.get(LockService); + const locksRepoMock = sandbox.get(getRepositoryToken(ScenarioLockEntity)); + + return { + GivenScenarioIsNotLocked: async () => { + locksRepoMock.count.mockImplementationOnce(async () => 0); + }, + GivenScenarioIsLocked: async () => { + locksRepoMock.count.mockImplementationOnce(async () => 1); + }, + + WhenAcquiringALock: async () => sut.acquireLock(SCENARIO_ID, USER_ID), + WhenTheLockIsReleased: async () => sut.releaseLock(SCENARIO_ID), + WhenCheckingIfAScenarioIsLocked: async () => sut.isLocked(SCENARIO_ID), + + ThenScenarioIsLocked: async (result: Either) => { + expect(result).toStrictEqual(right(void 0)); + expect(mockEntityManager.save).toHaveBeenCalledWith({ + scenarioId: SCENARIO_ID, + userId: USER_ID, + createdAt: expect.any(Date), + }); + }, + ThenALockedScenarioErrorIsReturned: async ( + result: Either, + ) => { + expect(result).toStrictEqual(left(lockedScenario)); + expect(mockEntityManager.save).not.toHaveBeenCalled(); + }, + ThenIsLockedReturnsTrue: async (isLockedResult: boolean) => { + expect(isLockedResult).toEqual(true); + expect(locksRepoMock.count).toHaveBeenCalledWith({ + where: { scenarioId: SCENARIO_ID }, + }); + }, + ThenIsLockedReturnsFalse: async (isLockedResult: boolean) => { + expect(isLockedResult).toEqual(false); + expect(locksRepoMock.count).toHaveBeenCalledWith({ + where: { scenarioId: SCENARIO_ID }, + }); + }, + ThenScenarioIsNotLocked: async () => { + expect(locksRepoMock.delete).toHaveBeenCalledWith({ + scenarioId: SCENARIO_ID, + }); + }, + }; +} diff --git a/api/apps/api/src/modules/scenarios/locks/lock.service.ts b/api/apps/api/src/modules/scenarios/locks/lock.service.ts new file mode 100644 index 0000000000..e84e3cbf48 --- /dev/null +++ b/api/apps/api/src/modules/scenarios/locks/lock.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Either, left, right } from 'fp-ts/lib/Either'; + +import { ScenarioLockEntity } from '@marxan-api/modules/scenarios/locks/scenario.lock.entity'; + +export const unknownError = Symbol(`unknown error`); +export const lockedScenario = Symbol(`scenario is already locked`); + +export type AcquireFailure = typeof unknownError | typeof lockedScenario; + +@Injectable() +export class LockService { + constructor( + @InjectRepository(ScenarioLockEntity) + private readonly locksRepo: Repository, + ) {} + + async acquireLock( + scenarioId: string, + userId: string, + ): Promise> { + return this.locksRepo.manager.transaction(async (entityManager) => { + if (await this.isLocked(scenarioId)) { + return left(lockedScenario); + } + + await entityManager.save({ + scenarioId, + userId, + createdAt: new Date(), + }); + + return right(void 0); + }); + } + + async releaseLock(scenarioId: string): Promise { + await this.locksRepo.delete({ scenarioId }); + } + + async isLocked(scenarioId: string): Promise { + return (await this.locksRepo.count({ where: { scenarioId } })) > 0; + } +} diff --git a/api/apps/api/src/modules/scenarios/locks/scenario.lock.entity.ts b/api/apps/api/src/modules/scenarios/locks/scenario.lock.entity.ts new file mode 100644 index 0000000000..60f02387b7 --- /dev/null +++ b/api/apps/api/src/modules/scenarios/locks/scenario.lock.entity.ts @@ -0,0 +1,24 @@ +import { CreateDateColumn, Entity, PrimaryColumn } from 'typeorm'; + +@Entity(`scenario_locks`) +export class ScenarioLockEntity { + @PrimaryColumn({ + type: `uuid`, + name: `user_id`, + }) + userId!: string; + + @PrimaryColumn({ + type: `uuid`, + name: `scenario_id`, + }) + scenarioId!: string; + + @CreateDateColumn({ + name: 'created_at', + type: 'timestamp', + nullable: false, + default: 'now()', + }) + createdAt!: Date; +}