-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Scenario locks module implementation (#673)
* 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
Showing
4 changed files
with
248 additions
and
0 deletions.
There are no files selected for viewing
49 changes: 49 additions & 0 deletions
49
api/apps/api/src/migrations/api/1638891599444-AddScenarioLocksTable.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
129
api/apps/api/src/modules/scenarios/locks/lock.service.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}); | ||
}, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
24
api/apps/api/src/modules/scenarios/locks/scenario.lock.entity.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |