From 95f42d6964d171a21cb02ba1bd518db303d539d4 Mon Sep 17 00:00:00 2001 From: Ulises Santana Date: Sat, 9 Dec 2023 18:58:57 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20add=20stop=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/stop.ts | 43 ++++++++++++ .../time-entry.repository.implementation.ts | 4 +- test/commands/start.test.ts | 45 +++++++------ test/commands/stop.test.ts | 59 +++++++++++++++++ test/http.mock.ts | 5 ++ .../data-sources/toggl.api.test.ts | 65 +++++++++---------- ...me-entry.repository.implementation.test.ts | 2 +- 7 files changed, 164 insertions(+), 59 deletions(-) create mode 100644 src/commands/stop.ts create mode 100644 test/commands/stop.test.ts create mode 100644 test/http.mock.ts diff --git a/src/commands/stop.ts b/src/commands/stop.ts new file mode 100644 index 0000000..01b49fe --- /dev/null +++ b/src/commands/stop.ts @@ -0,0 +1,43 @@ +import {Command} from '@oclif/core' +import path from "node:path"; + +import {GetConfigurationUseCase, StopCurrentTimeEntryUseCase} from "../application/cases"; +import {ConfigurationValidator, configFilename} from "../core"; +import {FileSystemDataSource, TogglApi, http} from "../infrastructure/data-sources"; +import {ConfigurationRepositoryImplementation, TimeEntryRepositoryImplementation} from "../infrastructure/repositories"; + +export default class Stop extends Command { + static description = 'Stop running time entry.' + + static examples = [ + '<%= config.bin %> <%= command.id %>', + ] + + public async run(): Promise { + const configurationRepository = new ConfigurationRepositoryImplementation(new FileSystemDataSource(path.join(this.config.configDir, configFilename))) + const config = await new GetConfigurationUseCase(configurationRepository).exec() + const configValidation = ConfigurationValidator.isRequiredConfigAvailable(config) + if (configValidation.error) { + this.error(configValidation.message) + } + + const togglAPI = new TogglApi({ + http, + token: config.apiToken, + workspaceId: config.workspaceId + }) + const timeEntryRepository = new TimeEntryRepositoryImplementation(togglAPI) + const useCase = new StopCurrentTimeEntryUseCase(timeEntryRepository) + + try { + const updatedEntry = await useCase.exec() + if (updatedEntry) { + this.log(`Time entry "${updatedEntry.description}" stopped for "${updatedEntry.project.name}" project.`) + } else { + this.log("There is no time entry running.") + } + } catch (error) { + this.error(`Unexpected error: ${error}`) + } + } +} diff --git a/src/infrastructure/repositories/time-entry.repository.implementation.ts b/src/infrastructure/repositories/time-entry.repository.implementation.ts index 91a7a1a..bacc503 100644 --- a/src/infrastructure/repositories/time-entry.repository.implementation.ts +++ b/src/infrastructure/repositories/time-entry.repository.implementation.ts @@ -56,7 +56,7 @@ export class TimeEntryRepositoryImplementation implements TimeEntryRepository { } async getCurrentEntry(): Promise> { - const [entry] = await this.api.getTimeEntries() + const entry = await this.api.getCurrentEntry() if (!entry) { return null } @@ -106,7 +106,7 @@ export class TimeEntryRepositoryImplementation implements TimeEntryRepository { return null } - const project = await this.api.getProjectById(entry.id) + const project = await this.api.getProjectById(entry.project_id) return TimeEntryRepositoryImplementation.mapToTimeEntry(entry, project) } } diff --git a/test/commands/start.test.ts b/test/commands/start.test.ts index a67654b..0a0d3e5 100644 --- a/test/commands/start.test.ts +++ b/test/commands/start.test.ts @@ -1,16 +1,17 @@ /* eslint-disable camelcase */ import {Config, ux} from '@oclif/core' -import axios from 'axios'; -import MockAdapter from "axios-mock-adapter"; -import {expect} from 'chai' +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; import path from "node:path"; import {SinonSandbox, SinonStub, createSandbox} from 'sinon' import {TogglApi} from "../../src/infrastructure/data-sources"; import {buildTogglProject, buildTogglTimeEntry} from "../builders"; import {configuration} from "../fixtures"; +import httpMock from "../http.mock" -const mock = new MockAdapter(axios); // here uses axios because here we are testing the command, which uses transpiled code. +chai.use(chaiAsPromised); +const {expect} = chai; describe('start command runs', () => { const projects = [buildTogglProject({name: 'Evil Company'}), buildTogglProject({name: 'Good Company'})] @@ -27,13 +28,13 @@ describe('start command runs', () => { afterEach(async () => { sandbox.restore() - mock.reset() + httpMock.reset() }) it('creating entry by passing all arguments and flags', async () => { const [evilProject] = projects const {id, name} = evilProject - mock.onGet(`${TogglApi.baseUrl}/api/v9/workspaces/${configuration.workspaceId}/projects?active=true`) + httpMock.onGet(`${TogglApi.baseUrl}/api/v9/workspaces/${configuration.workspaceId}/projects?active=true`) .reply(200, projects) .onPost(`${TogglApi.baseUrl}/api/v9/workspaces/${configuration.workspaceId}/time_entries`) .reply(200, buildTogglTimeEntry({description: configuration.defaultTimeEntry, project_id: id})) @@ -44,10 +45,13 @@ describe('start command runs', () => { }) it('creating entry using default project', async () => { - const projects = [buildTogglProject({id: configuration.projectId, name: 'Evil Company'}), buildTogglProject({name: 'Good Company'})] + const projects = [buildTogglProject({ + id: configuration.projectId, + name: 'Evil Company' + }), buildTogglProject({name: 'Good Company'})] const [evilProject] = projects const {id, name} = evilProject - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/workspaces/${configuration.workspaceId}/projects/${id}`) .reply(200, evilProject) .onPost(`${TogglApi.baseUrl}/api/v9/workspaces/${configuration.workspaceId}/time_entries`) @@ -61,7 +65,7 @@ describe('start command runs', () => { it('creating entry using default time entry', async () => { const [evilProject] = projects const {id, name} = evilProject - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/workspaces/${configuration.workspaceId}/projects/${id}`) .reply(200, evilProject) .onPost(`${TogglApi.baseUrl}/api/v9/workspaces/${configuration.workspaceId}/time_entries`) @@ -73,10 +77,13 @@ describe('start command runs', () => { }) it('creating entry using default time entry and default project', async () => { - const projects = [buildTogglProject({id: configuration.projectId, name: 'Evil Company'}), buildTogglProject({name: 'Good Company'})] + const projects = [buildTogglProject({ + id: configuration.projectId, + name: 'Evil Company' + }), buildTogglProject({name: 'Good Company'})] const [evilProject] = projects const {id, name} = evilProject - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/workspaces/${configuration.workspaceId}/projects/${id}`) .reply(200, evilProject) .onPost(`${TogglApi.baseUrl}/api/v9/workspaces/${configuration.workspaceId}/time_entries`) @@ -92,23 +99,15 @@ describe('start command runs', () => { const [evilProject] = projects const {id} = evilProject - try { - await config.runCommand("start", ["-p", id.toString()]) - throw new Error('Start command should throw error') - } catch (error) { - expect(`${error}`).to.contains("Missing time entry description argument") - } + await expect(config.runCommand("start", ["-p", id.toString()])) + .to.be.rejectedWith("Missing time entry description argument") }) it('showing error if project is missing and default project id is not defined', async () => { config.configDir = path.join(process.cwd(), 'test/fixtures/min-config') - try { - await config.runCommand("start", ["Doing stuff"]) - throw new Error('Start command should throw error') - } catch (error) { - expect(`${error}`).to.contains("Missing project flag for the time entry.") - } + await expect(config.runCommand("start", ["Doing stuff"])) + .to.be.rejectedWith("Missing project flag for the time entry.") }) }) diff --git a/test/commands/stop.test.ts b/test/commands/stop.test.ts new file mode 100644 index 0000000..74d02b4 --- /dev/null +++ b/test/commands/stop.test.ts @@ -0,0 +1,59 @@ +/* eslint-disable camelcase */ +import {Config, ux} from '@oclif/core' +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import path from "node:path"; +import {SinonSandbox, SinonStub, createSandbox} from 'sinon' + +import {TogglApi} from "../../dist/infrastructure/data-sources"; // here uses dist directory because here we are testing the command, which uses transpiled code. +import {buildTogglProject, buildTogglTimeEntry} from "../builders"; +import {configuration} from "../fixtures"; +import httpMock from "../http.mock"; + +chai.use(chaiAsPromised); +const {expect} = chai; + +describe('stop command runs', () => { + const projects = [buildTogglProject({name: 'Evil Company'}), buildTogglProject({name: 'Good Company'})] + let sandbox: SinonSandbox + let config: Config + let stdoutStub: SinonStub + + beforeEach(async () => { + sandbox = createSandbox() + stdoutStub = sandbox.stub(ux.write, 'stdout') + config = await Config.load({root: process.cwd()}) + config.configDir = path.join(process.cwd(), 'test/fixtures') + }) + + afterEach(async () => { + sandbox.restore() + httpMock.reset() + }) + + it('stopping current time entry', async () => { + const [evilProject] = projects + const {id, name} = evilProject + const entry = buildTogglTimeEntry({project_id: id}) + httpMock.onGet(`${TogglApi.baseUrl}/api/v9/me/time_entries/current`) + .reply(200, entry) + .onGet(`${TogglApi.baseUrl}/api/v9/workspaces/${configuration.workspaceId}/projects/${id}`) + .reply(200, evilProject) + .onPut(`${TogglApi.baseUrl}/api/v9/workspaces/${configuration.workspaceId}/time_entries/${entry.id}`) + .reply(200, entry) + + await config.runCommand("stop") + + expect(stdoutStub.args.flat().join(',')).to.contains(`Time entry "${entry.description}" stopped for "${name}" project.`) + }) + + it('showing there is no entry to stop if there is no current time entry', async () => { + httpMock.onGet(`${TogglApi.baseUrl}/api/v9/me/time_entries/current`) + .reply(200, null) + + await config.runCommand("stop") + + expect(stdoutStub.args.flat().join(',')).to.contains(`There is no time entry running.`) + }) + +}) diff --git a/test/http.mock.ts b/test/http.mock.ts new file mode 100644 index 0000000..8819db0 --- /dev/null +++ b/test/http.mock.ts @@ -0,0 +1,5 @@ +import MockAdapter from "axios-mock-adapter"; + +import {http} from "../dist/infrastructure/data-sources/http"; + +export default new MockAdapter(http); diff --git a/test/infrastructure/data-sources/toggl.api.test.ts b/test/infrastructure/data-sources/toggl.api.test.ts index a94f4b6..8915523 100644 --- a/test/infrastructure/data-sources/toggl.api.test.ts +++ b/test/infrastructure/data-sources/toggl.api.test.ts @@ -1,5 +1,4 @@ /* eslint-disable camelcase */ -import MockAdapter from "axios-mock-adapter"; import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; @@ -7,11 +6,11 @@ chai.use(chaiAsPromised); const {expect} = chai; -import {TogglApi, http} from "../../../src/infrastructure/data-sources"; +import {http} from "../../../dist/infrastructure/data-sources/http"; +import {TogglApi} from "../../../src/infrastructure/data-sources"; import {AuthorizationError, NotFoundError, RequestError, ServerError} from "../../../src/infrastructure/errors"; import {buildTogglProject, buildTogglTimeEntry} from "../../builders"; - -const mock = new MockAdapter(http); +import httpMock from "../../http.mock"; describe('Toggl API should', () => { const workspaceId = 42; @@ -19,14 +18,14 @@ describe('Toggl API should', () => { const projects = [buildTogglProject({name: 'Evil Company'}), buildTogglProject({name: 'Good Company'})] afterEach(() => { - mock.reset() + httpMock.reset() }) describe('create time entry', () => { it('successfully', async () => { const start = new Date() const entry = buildTogglTimeEntry() - mock + httpMock .onPost(`${TogglApi.baseUrl}/api/v9/workspaces/${workspaceId}/time_entries`) .reply(({data}) => [200, data]) @@ -44,7 +43,7 @@ describe('Toggl API should', () => { const expectedError = new AuthorizationError() const start = new Date() const entry = buildTogglTimeEntry() - mock + httpMock .onPost(`${TogglApi.baseUrl}/api/v9/workspaces/${workspaceId}/time_entries`) .reply(403) @@ -54,7 +53,7 @@ describe('Toggl API should', () => { const expectedError = new ServerError() const start = new Date() const entry = buildTogglTimeEntry() - mock + httpMock .onPost(`${TogglApi.baseUrl}/api/v9/workspaces/${workspaceId}/time_entries`) .reply(500) @@ -64,7 +63,7 @@ describe('Toggl API should', () => { const expectedError = new RequestError(420, 'Request failed with status code 420') const start = new Date() const entry = buildTogglTimeEntry() - mock + httpMock .onPost(`${TogglApi.baseUrl}/api/v9/workspaces/${workspaceId}/time_entries`) .reply(420) @@ -76,7 +75,7 @@ describe('Toggl API should', () => { describe('get current entry', () => { it('successfully', async () => { const entry = buildTogglTimeEntry() - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/me/time_entries/current`) .reply(200, entry) @@ -86,7 +85,7 @@ describe('Toggl API should', () => { }) it('handle authorization error', async () => { const expectedError = new AuthorizationError() - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/me/time_entries/current`) .reply(403) @@ -94,7 +93,7 @@ describe('Toggl API should', () => { }) it('handle no current time entry', async () => { const expectedError = new NotFoundError() - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/me/time_entries/current`) .reply(404) @@ -102,7 +101,7 @@ describe('Toggl API should', () => { }) it('handle server internal error', async () => { const expectedError = new ServerError() - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/me/time_entries/current`) .reply(500) @@ -110,7 +109,7 @@ describe('Toggl API should', () => { }) it('handle any other error', async () => { const expectedError = new RequestError(420, 'Request failed with status code 420') - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/me/time_entries/current`) .reply(420) @@ -122,7 +121,7 @@ describe('Toggl API should', () => { describe('get project by id', () => { const [project] = projects it('successfully', async () => { - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/workspaces/${workspaceId}/projects/${project.id}`) .reply(200, project) @@ -132,7 +131,7 @@ describe('Toggl API should', () => { }) it('handle authorization error', async () => { const expectedError = new AuthorizationError() - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/workspaces/${workspaceId}/projects/${project.id}`) .reply(403) @@ -140,7 +139,7 @@ describe('Toggl API should', () => { }) it('handle server internal error', async () => { const expectedError = new ServerError() - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/workspaces/${workspaceId}/projects/${project.id}`) .reply(500) @@ -148,7 +147,7 @@ describe('Toggl API should', () => { }) it('handle any other error', async () => { const expectedError = new RequestError(420, 'Request failed with status code 420') - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/workspaces/${workspaceId}/projects/${project.id}`) .reply(420) @@ -158,7 +157,7 @@ describe('Toggl API should', () => { describe('get all projects', () => { it('successfully', async () => { - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/workspaces/${workspaceId}/projects?active=true`) .reply(200, projects) @@ -168,7 +167,7 @@ describe('Toggl API should', () => { }) it('handle authorization error', async () => { const expectedError = new AuthorizationError() - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/workspaces/${workspaceId}/projects?active=true`) .reply(403) @@ -176,7 +175,7 @@ describe('Toggl API should', () => { }) it('handle server internal error', async () => { const expectedError = new ServerError() - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/workspaces/${workspaceId}/projects?active=true`) .reply(500) @@ -184,7 +183,7 @@ describe('Toggl API should', () => { }) it('handle any other error', async () => { const expectedError = new RequestError(420, 'Request failed with status code 420') - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/workspaces/${workspaceId}/projects?active=true`) .reply(420) @@ -195,7 +194,7 @@ describe('Toggl API should', () => { describe('get time entries', () => { const entries = [buildTogglTimeEntry(), buildTogglTimeEntry()] it('without any date range', async () => { - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/me/time_entries`) .reply(200, entries) @@ -206,7 +205,7 @@ describe('Toggl API should', () => { it('with from date', async () => { const from = new Date('2023-05-15') const to = new Date() - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/me/time_entries?start_date=${formatDate(from)}&end_date=${formatDate(to)}`) .reply(200, entries) @@ -217,7 +216,7 @@ describe('Toggl API should', () => { it('with to date', async () => { const from = new Date(0) const to = new Date('2023-12-01') - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/me/time_entries?start_date=${formatDate(from)}&end_date=${formatDate(to)}`) .reply(200, entries) @@ -228,7 +227,7 @@ describe('Toggl API should', () => { it('with from and to date', async () => { const from = new Date('2023-05-15') const to = new Date('2023-12-01') - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/me/time_entries?start_date=${formatDate(from)}&end_date=${formatDate(to)}`) .reply(200, entries) @@ -238,7 +237,7 @@ describe('Toggl API should', () => { }) it('handle authorization error', async () => { const expectedError = new AuthorizationError() - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/me/time_entries`) .reply(403) @@ -246,7 +245,7 @@ describe('Toggl API should', () => { }) it('handle server internal error', async () => { const expectedError = new ServerError() - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/me/time_entries`) .reply(500) @@ -254,7 +253,7 @@ describe('Toggl API should', () => { }) it('handle any other error', async () => { const expectedError = new RequestError(420, 'Request failed with status code 420') - mock + httpMock .onGet(`${TogglApi.baseUrl}/api/v9/me/time_entries`) .reply(420) @@ -265,7 +264,7 @@ describe('Toggl API should', () => { describe('update time entry', () => { const entry = buildTogglTimeEntry() it('successfully', async () => { - mock + httpMock .onPut(`${TogglApi.baseUrl}/api/v9/workspaces/${workspaceId}/time_entries/${entry.id}`) .reply(({data}) => [200, data]) @@ -275,7 +274,7 @@ describe('Toggl API should', () => { }) it('handle authorization error', async () => { const expectedError = new AuthorizationError() - mock + httpMock .onPut(`${TogglApi.baseUrl}/api/v9/workspaces/${workspaceId}/time_entries/${entry.id}`) .reply(403) @@ -283,7 +282,7 @@ describe('Toggl API should', () => { }) it('handle server internal error', async () => { const expectedError = new ServerError() - mock + httpMock .onPut(`${TogglApi.baseUrl}/api/v9/workspaces/${workspaceId}/time_entries/${entry.id}`) .reply(500) @@ -291,7 +290,7 @@ describe('Toggl API should', () => { }) it('handle any other error', async () => { const expectedError = new RequestError(420, 'Request failed with status code 420') - mock + httpMock .onPut(`${TogglApi.baseUrl}/api/v9/workspaces/${workspaceId}/time_entries/${entry.id}`) .reply(420) diff --git a/test/infrastructure/repositories/time-entry.repository.implementation.test.ts b/test/infrastructure/repositories/time-entry.repository.implementation.test.ts index f22f3b9..e80570b 100644 --- a/test/infrastructure/repositories/time-entry.repository.implementation.test.ts +++ b/test/infrastructure/repositories/time-entry.repository.implementation.test.ts @@ -35,7 +35,7 @@ describe('TimeEntryRepositoryImplementation', () => { it('should retrieve the current time entry when it exists', async () => { const mockProject = buildTogglProject(); const mockEntry = buildTogglTimeEntry({project_id: mockProject.id}); - apiMock.getTimeEntries.resolves([mockEntry]); + apiMock.getCurrentEntry.resolves(mockEntry); apiMock.getProjectById.resolves(mockProject); const result = await repository.getCurrentEntry();