Skip to content

Commit

Permalink
Scenario locks module implementation (#673)
Browse files Browse the repository at this point in the history
* feat: initial skeleton of lock service

* feat: migration for scenario locks table

* refact: use entitymanager transaction and rename grab to acquire

* feat: use either, left and right for error handling

* feat: address PR feedback

* feat: address PR feedback v2

* refact: use delete instead of remove and reuse service methods

* feat: add nullable and default traits to scenario lock entity

* refact: remove find on releaseLock method
  • Loading branch information
Henrique Pacheco authored Dec 13, 2021
1 parent 136b5e3 commit de1691a
Show file tree
Hide file tree
Showing 4 changed files with 248 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddScenarioLocksTable1638891599444 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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";`);
}
}
129 changes: 129 additions & 0 deletions api/apps/api/src/modules/scenarios/locks/lock.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getFixtures>;

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<EntityManager>) => Promise<void>) =>
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<AcquireFailure, void>) => {
expect(result).toStrictEqual(right(void 0));
expect(mockEntityManager.save).toHaveBeenCalledWith({
scenarioId: SCENARIO_ID,
userId: USER_ID,
createdAt: expect.any(Date),
});
},
ThenALockedScenarioErrorIsReturned: async (
result: Either<AcquireFailure, void>,
) => {
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,
});
},
};
}
46 changes: 46 additions & 0 deletions api/apps/api/src/modules/scenarios/locks/lock.service.ts
Original file line number Diff line number Diff line change
@@ -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<ScenarioLockEntity>,
) {}

async acquireLock(
scenarioId: string,
userId: string,
): Promise<Either<AcquireFailure, void>> {
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<void> {
await this.locksRepo.delete({ scenarioId });
}

async isLocked(scenarioId: string): Promise<boolean> {
return (await this.locksRepo.count({ where: { scenarioId } })) > 0;
}
}
24 changes: 24 additions & 0 deletions api/apps/api/src/modules/scenarios/locks/scenario.lock.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}

0 comments on commit de1691a

Please sign in to comment.