From b2815e7b4ac5bd35bb62c621fa3d8bf675847df9 Mon Sep 17 00:00:00 2001 From: Rene Enriquez Date: Tue, 28 Jul 2020 10:41:51 -0500 Subject: [PATCH 1/3] feat: #405 loading my last entry --- src/app/modules/shared/models/entry.model.ts | 4 +- .../entry-fields/entry-fields.component.ts | 53 +++++++++++-------- .../project-list-hover.component.spec.ts | 15 +++--- .../project-list-hover.component.ts | 2 +- .../time-clock/services/entry.service.spec.ts | 9 ++++ .../time-clock/services/entry.service.ts | 5 ++ .../modules/time-clock/store/entry.actions.ts | 14 +++++ .../modules/time-clock/store/entry.effects.ts | 32 +++++++++-- 8 files changed, 99 insertions(+), 35 deletions(-) diff --git a/src/app/modules/shared/models/entry.model.ts b/src/app/modules/shared/models/entry.model.ts index 2545d6604..711c8be51 100644 --- a/src/app/modules/shared/models/entry.model.ts +++ b/src/app/modules/shared/models/entry.model.ts @@ -1,10 +1,10 @@ export interface Entry { running?: boolean; - id: string; + id?: string; start_date: Date; end_date?: Date; activity_id?: string; - technologies: string[]; + technologies?: string[]; uri?: string; activity_name?: string; description?: string; diff --git a/src/app/modules/time-clock/components/entry-fields/entry-fields.component.ts b/src/app/modules/time-clock/components/entry-fields/entry-fields.component.ts index b119c4a59..f5d1206ed 100644 --- a/src/app/modules/time-clock/components/entry-fields/entry-fields.component.ts +++ b/src/app/modules/time-clock/components/entry-fields/entry-fields.component.ts @@ -1,12 +1,14 @@ -import { getActiveTimeEntry } from './../../store/entry.selectors'; +import { ActivityManagementActionTypes } from './../../../activities-management/store/activity-management.actions'; +import { EntryActionTypes, LoadActiveEntry } from './../../store/entry.actions'; +import { filter } from 'rxjs/operators'; import { Component, OnInit } from '@angular/core'; import { FormBuilder, FormGroup } from '@angular/forms'; -import { select, Store } from '@ngrx/store'; +import { Store, ActionsSubject } from '@ngrx/store'; import { Activity, NewEntry } from '../../../shared/models'; import { ProjectState } from '../../../customer-management/components/projects/components/store/project.reducer'; import { TechnologyState } from '../../../shared/store/technology.reducers'; -import { ActivityState, allActivities, LoadActivities } from '../../../activities-management/store'; +import { ActivityState, LoadActivities } from '../../../activities-management/store'; import * as entryActions from '../../store/entry.actions'; @@ -24,37 +26,44 @@ export class EntryFieldsComponent implements OnInit { activeEntry; newData; - constructor(private formBuilder: FormBuilder, private store: Store) { + constructor(private formBuilder: FormBuilder, private store: Store, private actionsSubject$: ActionsSubject) { this.entryForm = this.formBuilder.group({ description: '', uri: '', - activity_id: '-1', + activity_id: '', }); } ngOnInit(): void { this.store.dispatch(new LoadActivities()); - const activities$ = this.store.pipe(select(allActivities)); - activities$.subscribe((response) => { - this.activities = response; - this.loadActiveEntry(); + + this.actionsSubject$.pipe( + filter((action: any) => (action.type === ActivityManagementActionTypes.LOAD_ACTIVITIES_SUCCESS)) + ).subscribe((action) => { + this.activities = action.payload; + this.store.dispatch(new LoadActiveEntry()); }); - } - loadActiveEntry() { - const activeEntry$ = this.store.pipe(select(getActiveTimeEntry)); - activeEntry$.subscribe((response) => { - if (response) { - this.activeEntry = response; - this.setDataToUpdate(this.activeEntry); - this.newData = { - id: this.activeEntry.id, - project_id: this.activeEntry.project_id, - uri: this.activeEntry.uri, - activity_id: this.activeEntry.activity_id, - }; + this.actionsSubject$.pipe( + filter((action: any) => (action.type === EntryActionTypes.CREATE_ENTRY_SUCCESS)) + ).subscribe((action) => { + if (!action.payload.end_date) { + this.store.dispatch(new LoadActiveEntry()); } }); + + this.actionsSubject$.pipe( + filter((action: any) => ( action.type === EntryActionTypes.LOAD_ACTIVE_ENTRY_SUCCESS )) + ).subscribe((action) => { + this.activeEntry = action.payload; + this.setDataToUpdate(this.activeEntry); + this.newData = { + id: this.activeEntry.id, + project_id: this.activeEntry.project_id, + uri: this.activeEntry.uri, + activity_id: this.activeEntry.activity_id, + }; + }); } get activity_id() { diff --git a/src/app/modules/time-clock/components/project-list-hover/project-list-hover.component.spec.ts b/src/app/modules/time-clock/components/project-list-hover/project-list-hover.component.spec.ts index 44cfea614..16f0ab1eb 100644 --- a/src/app/modules/time-clock/components/project-list-hover/project-list-hover.component.spec.ts +++ b/src/app/modules/time-clock/components/project-list-hover/project-list-hover.component.spec.ts @@ -1,15 +1,15 @@ -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ToastrService, IndividualConfig } from 'ngx-toastr'; +import { SwitchTimeEntry, ClockIn } from './../../store/entry.actions'; import { FormBuilder } from '@angular/forms'; -import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; import { AutocompleteLibModule } from 'angular-ng-autocomplete'; -import { IndividualConfig, ToastrService } from 'ngx-toastr'; import { Subscription } from 'rxjs'; import { ProjectState } from '../../../customer-management/components/projects/components/store/project.reducer'; import { getCustomerProjects } from '../../../customer-management/components/projects/components/store/project.selectors'; import { FilterProjectPipe } from '../../../shared/pipes'; -import { CreateEntry, UpdateEntryRunning } from '../../store/entry.actions'; -import { SwitchTimeEntry } from './../../store/entry.actions'; +import { UpdateEntryRunning } from '../../store/entry.actions'; import { ProjectListHoverComponent } from './project-list-hover.component'; describe('ProjectListHoverComponent', () => { @@ -68,7 +68,7 @@ describe('ProjectListHoverComponent', () => { component.clockIn(1, 'customer', 'project'); - expect(store.dispatch).toHaveBeenCalledWith(jasmine.any(CreateEntry)); + expect(store.dispatch).toHaveBeenCalledWith(jasmine.any(ClockIn)); }); it('dispatch a UpdateEntryRunning action on updateProject', () => { @@ -118,6 +118,7 @@ describe('ProjectListHoverComponent', () => { .toHaveBeenCalledWith({ project_id: 'customer - xyz'}); }); + // TODO Fix this test since it is throwing this error // Expected spy dispatch to have been called with: // [CreateEntry({ payload: Object({ project_id: '1', start_date: '2020-07-27T22:30:26.743Z', timezone_offset: 300 }), diff --git a/src/app/modules/time-clock/components/project-list-hover/project-list-hover.component.ts b/src/app/modules/time-clock/components/project-list-hover/project-list-hover.component.ts index 8b1b14940..a7a68559d 100644 --- a/src/app/modules/time-clock/components/project-list-hover/project-list-hover.component.ts +++ b/src/app/modules/time-clock/components/project-list-hover/project-list-hover.component.ts @@ -83,7 +83,7 @@ export class ProjectListHoverComponent implements OnInit, OnDestroy { start_date: new Date().toISOString(), timezone_offset: new Date().getTimezoneOffset(), }; - this.store.dispatch(new entryActions.CreateEntry(entry)); + this.store.dispatch(new entryActions.ClockIn(entry)); this.projectsForm.setValue( { project_id: `${customerName} - ${name}`, } ); } diff --git a/src/app/modules/time-clock/services/entry.service.spec.ts b/src/app/modules/time-clock/services/entry.service.spec.ts index ccb60fad1..23bb53267 100644 --- a/src/app/modules/time-clock/services/entry.service.spec.ts +++ b/src/app/modules/time-clock/services/entry.service.spec.ts @@ -123,4 +123,13 @@ describe('EntryService', () => { const restartEntryRequest = httpMock.expectOne( `${service.baseUrl}/${entry}/restart`); expect(restartEntryRequest.request.method).toBe('POST'); }); + + it('entries are found by project id with a limit 2 by default', () => { + const projectId = 'project-id'; + + service.findEntriesByProjectId(projectId).subscribe(); + + const restartEntryRequest = httpMock.expectOne( `${service.baseUrl}?limit=2&project_id=${projectId}`); + expect(restartEntryRequest.request.method).toBe('GET'); + }); }); diff --git a/src/app/modules/time-clock/services/entry.service.ts b/src/app/modules/time-clock/services/entry.service.ts index 08502f663..25fcae528 100644 --- a/src/app/modules/time-clock/services/entry.service.ts +++ b/src/app/modules/time-clock/services/entry.service.ts @@ -57,6 +57,11 @@ export class EntryService { return this.http.get(summaryUrl); } + findEntriesByProjectId(projectId: string): Observable { + const findEntriesByProjectURL = `${this.baseUrl}?limit=2&project_id=${projectId}`; + return this.http.get(findEntriesByProjectURL); + } + loadEntriesByTimeRange(range: TimeEntriesTimeRange, userId: string): Observable { const MAX_NUMBER_OF_ENTRIES_FOR_REPORTS = 9999; return this.http.get(this.baseUrl, diff --git a/src/app/modules/time-clock/store/entry.actions.ts b/src/app/modules/time-clock/store/entry.actions.ts index f4ce83269..b1a837eef 100644 --- a/src/app/modules/time-clock/store/entry.actions.ts +++ b/src/app/modules/time-clock/store/entry.actions.ts @@ -13,6 +13,8 @@ export enum EntryActionTypes { LOAD_ENTRIES = '[Entry] LOAD_ENTRIES', LOAD_ENTRIES_SUCCESS = '[Entry] LOAD_ENTRIES_SUCCESS', LOAD_ENTRIES_FAIL = '[Entry] LOAD_ENTRIES_FAIL', + CLOCK_IN = '[Entry] CLOCK_IN', + CLOCK_IN_SUCCESS = '[Entry] CLOCK_IN_SUCCESS', CREATE_ENTRY = '[Entry] CREATE_ENTRY', CREATE_ENTRY_SUCCESS = '[Entry] CREATE_ENTRY_SUCCESS', CREATE_ENTRY_FAIL = '[Entry] CREATE_ENTRY_FAIL', @@ -39,6 +41,16 @@ export enum EntryActionTypes { RESTART_ENTRY_FAIL = '[Entry] RESTART_ENTRY_FAIL', } +export class ClockIn implements Action { + public readonly type = EntryActionTypes.CLOCK_IN; + constructor(readonly payload: NewEntry) {} +} + +export class ClockInSuccess implements Action { + public readonly type = EntryActionTypes.CLOCK_IN_SUCCESS; + constructor() {} +} + export class SwitchTimeEntry implements Action { public readonly type = EntryActionTypes.SWITCH_TIME_ENTRY; constructor(readonly idEntrySwitching: string, readonly idProjectSwitching) {} @@ -250,6 +262,8 @@ export class RestartEntryFail implements Action { } export type EntryActions = + | ClockIn + | ClockInSuccess | LoadEntriesSummary | LoadEntriesSummarySuccess | LoadEntriesSummaryFail diff --git a/src/app/modules/time-clock/store/entry.effects.ts b/src/app/modules/time-clock/store/entry.effects.ts index ecf59ca16..62fbda184 100644 --- a/src/app/modules/time-clock/store/entry.effects.ts +++ b/src/app/modules/time-clock/store/entry.effects.ts @@ -1,3 +1,5 @@ +import { NewEntry } from './../../shared/models/entry.model'; +import { INFO_DELETE_SUCCESSFULLY, INFO_SAVED_SUCCESSFULLY } from './../../shared/messages'; import { Injectable } from '@angular/core'; import { Actions, Effect, ofType } from '@ngrx/effects'; import { Action } from '@ngrx/store'; @@ -5,7 +7,6 @@ import { ToastrService } from 'ngx-toastr'; import { Observable, of } from 'rxjs'; import { catchError, map, mergeMap, switchMap } from 'rxjs/operators'; import { EntryService } from '../services/entry.service'; -import { INFO_DELETE_SUCCESSFULLY, INFO_SAVED_SUCCESSFULLY } from './../../shared/messages'; import * as actions from './entry.actions'; @Injectable() @@ -20,7 +21,7 @@ export class EntryEffects { this.entryService.stopEntryRunning(action.idEntrySwitching).pipe( map((response) => { const stopDateForEntry = new Date(response.end_date); - stopDateForEntry.setSeconds(stopDateForEntry.getSeconds() + 1 ); + stopDateForEntry.setSeconds(stopDateForEntry.getSeconds() + 1); return new actions.CreateEntry({ project_id: action.idProjectSwitching, start_date: stopDateForEntry.toISOString(), @@ -116,6 +117,31 @@ export class EntryEffects { ) ); + @Effect() + clockIn$: Observable = this.actions$.pipe( + ofType(actions.EntryActionTypes.CLOCK_IN), + map((action: actions.CreateEntry) => action.payload), + mergeMap((entry: NewEntry) => + this.entryService.findEntriesByProjectId(entry.project_id).pipe( + map((entriesFound) => { + if (entriesFound && entriesFound.length > 0) { + const dataToUse = entriesFound[0]; + entry = { ...entry }; + entry.description = dataToUse.description; + entry.technologies = dataToUse.technologies ? dataToUse.technologies : []; + entry.uri = dataToUse.uri; + entry.activity_id = dataToUse.activity_id; + } + return new actions.CreateEntry(entry); + }), + catchError((error) => { + this.toastrService.error('We could not clock in you, try again later.'); + return of(new actions.CreateEntryFail('Error')); + }) + ) + ) + ); + @Effect() deleteEntry$: Observable = this.actions$.pipe( ofType(actions.EntryActionTypes.DELETE_ENTRY), @@ -214,7 +240,7 @@ export class EntryEffects { return new actions.RestartEntrySuccess(entryResponse); }), catchError((error) => { - this.toastrService.error( error.error.message, 'This entry could not be restarted'); + this.toastrService.error(error.error.message, 'This entry could not be restarted'); return of(new actions.RestartEntryFail(error)); }) ) From f750bdc8725986b67aa4da15a1cfee540935dc62 Mon Sep 17 00:00:00 2001 From: Rene Enriquez Date: Wed, 29 Jul 2020 01:18:25 -0500 Subject: [PATCH 2/3] fix: #405 adding missing tests --- src/app/modules/shared/models/entry.model.ts | 2 +- .../entry-fields.component.spec.ts | 127 ++++++++++++- .../time-clock/store/entry.effects.spec.ts | 170 ++++++++++++++---- .../modules/time-clock/store/entry.effects.ts | 11 +- 4 files changed, 262 insertions(+), 48 deletions(-) diff --git a/src/app/modules/shared/models/entry.model.ts b/src/app/modules/shared/models/entry.model.ts index 711c8be51..65737d410 100644 --- a/src/app/modules/shared/models/entry.model.ts +++ b/src/app/modules/shared/models/entry.model.ts @@ -11,7 +11,7 @@ export interface Entry { owner_email?: string; project_id?: string; - project_name: string; + project_name?: string; customer_id?: string; customer_name?: string; diff --git a/src/app/modules/time-clock/components/entry-fields/entry-fields.component.spec.ts b/src/app/modules/time-clock/components/entry-fields/entry-fields.component.spec.ts index 04b46a644..25544a4e1 100644 --- a/src/app/modules/time-clock/components/entry-fields/entry-fields.component.spec.ts +++ b/src/app/modules/time-clock/components/entry-fields/entry-fields.component.spec.ts @@ -1,13 +1,15 @@ +import { LoadActiveEntry, EntryActionTypes } from './../../store/entry.actions'; +import { ActivityManagementActionTypes } from './../../../activities-management/store/activity-management.actions'; import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {MockStore, provideMockStore} from '@ngrx/store/testing'; -import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import { FormsModule, ReactiveFormsModule, FormBuilder } from '@angular/forms'; import {TechnologyState} from '../../../shared/store/technology.reducers'; import {allTechnologies} from '../../../shared/store/technology.selectors'; import {EntryFieldsComponent} from './entry-fields.component'; import {ProjectState} from '../../../customer-management/components/projects/components/store/project.reducer'; import {getCustomerProjects} from '../../../customer-management/components/projects/components/store/project.selectors'; -import * as entryActions from '../../store/entry.actions'; +import { ActionsSubject } from '@ngrx/store'; describe('EntryFieldsComponent', () => { type Merged = TechnologyState & ProjectState; @@ -16,6 +18,8 @@ describe('EntryFieldsComponent', () => { let store: MockStore; let mockTechnologySelector; let mockProjectsSelector; + let entryForm; + const actionSub: ActionsSubject = new ActionsSubject(); const state = { projects: { @@ -60,10 +64,11 @@ describe('EntryFieldsComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [EntryFieldsComponent], - providers: [provideMockStore({initialState: state})], + providers: [provideMockStore({initialState: state}), { provide: ActionsSubject, useValue: actionSub }], imports: [FormsModule, ReactiveFormsModule], }).compileComponents(); store = TestBed.inject(MockStore); + entryForm = TestBed.inject(FormBuilder); mockTechnologySelector = store.overrideSelector(allTechnologies, state.technologies); mockProjectsSelector = store.overrideSelector(getCustomerProjects, state.projects); })); @@ -96,12 +101,6 @@ describe('EntryFieldsComponent', () => { expect(component.selectedTechnologies).toEqual([]); }); - it('should dispatch UpdateActiveEntry action #onSubmit', () => { - spyOn(store, 'dispatch'); - component.onSubmit(); - expect(store.dispatch).toHaveBeenCalledWith(new entryActions.UpdateEntryRunning(entry)); - }); - it('when a technology is added, then dispatch UpdateActiveEntry', () => { const addedTechnologies = ['react']; spyOn(store, 'dispatch'); @@ -120,4 +119,114 @@ describe('EntryFieldsComponent', () => { expect(store.dispatch).toHaveBeenCalled(); }); + + it('uses the form to check if is valid or not', () => { + entryForm.valid = false; + + const result = component.entryFormIsValidate(); + + expect(result).toBe(entryForm.valid); + }); + + it('dispatches an action when onSubmit is called', () => { + spyOn(store, 'dispatch'); + + component.onSubmit(); + + expect(store.dispatch).toHaveBeenCalled(); + }); + + it('dispatches an action when onTechnologyRemoved is called', () => { + spyOn(store, 'dispatch'); + + component.onTechnologyRemoved(['foo']); + + expect(store.dispatch).toHaveBeenCalled(); + }); + + + it('sets the technologies on the class when entry has technologies', () => { + const entryData = { ...entry, technologies: ['foo']}; + + component.setDataToUpdate(entryData); + + expect(component.selectedTechnologies).toEqual(entryData.technologies); + }); + + + it('activites are populated using the payload of the action', () => { + const actionSubject = TestBed.inject(ActionsSubject) as ActionsSubject; + const action = { + type: ActivityManagementActionTypes.LOAD_ACTIVITIES_SUCCESS, + payload: [], + }; + + actionSubject.next(action); + + expect(component.activities).toEqual(action.payload); + }); + + it('LoadActiveEntry is dispatchen after LOAD_ACTIVITIES_SUCCESS', () => { + spyOn(store, 'dispatch'); + + const actionSubject = TestBed.inject(ActionsSubject) as ActionsSubject; + const action = { + type: ActivityManagementActionTypes.LOAD_ACTIVITIES_SUCCESS, + payload: [], + }; + + actionSubject.next(action); + + expect(store.dispatch).toHaveBeenCalledWith(new LoadActiveEntry()); + }); + + it('when entry has an end_date null then LoadActiveEntry is dispatched', () => { + spyOn(store, 'dispatch'); + + const actionSubject = TestBed.inject(ActionsSubject) as ActionsSubject; + const action = { + type: EntryActionTypes.CREATE_ENTRY_SUCCESS, + payload: {end_date: null}, + }; + + actionSubject.next(action); + + expect(store.dispatch).toHaveBeenCalledWith(new LoadActiveEntry()); + }); + + it('when entry has an end_date then nothing is dispatched', () => { + spyOn(store, 'dispatch'); + + const actionSubject = TestBed.inject(ActionsSubject) as ActionsSubject; + const action = { + type: EntryActionTypes.CREATE_ENTRY_SUCCESS, + payload: {end_date: new Date()}, + }; + + actionSubject.next(action); + + expect(store.dispatch).toHaveBeenCalledTimes(0); + }); + + it('activeEntry is populated using the payload of LOAD_ACTIVE_ENTRY_SUCCESS', () => { + const actionSubject = TestBed.inject(ActionsSubject) as ActionsSubject; + const action = { + type: EntryActionTypes.LOAD_ACTIVE_ENTRY_SUCCESS, + payload: entry, + }; + + actionSubject.next(action); + + expect(component.activeEntry).toBe(action.payload); + }); + + it('if entryData is null selectedTechnologies is not modified', () => { + const initialTechnologies = ['foo', 'bar']; + component.selectedTechnologies = initialTechnologies; + + component.setDataToUpdate(null); + + expect(component.selectedTechnologies).toBe(initialTechnologies); + }); + }); diff --git a/src/app/modules/time-clock/store/entry.effects.spec.ts b/src/app/modules/time-clock/store/entry.effects.spec.ts index 7fa289872..6fbcbf6e3 100644 --- a/src/app/modules/time-clock/store/entry.effects.spec.ts +++ b/src/app/modules/time-clock/store/entry.effects.spec.ts @@ -1,3 +1,5 @@ +import { NewEntry } from './../../shared/models/entry.model'; +import { Entry } from 'src/app/modules/shared/models'; import { DatePipe } from '@angular/common'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; @@ -9,7 +11,7 @@ import { Observable, of, throwError } from 'rxjs'; import { TimeEntriesTimeRange } from '../models/time-entries-time-range'; import { EntryService } from '../services/entry.service'; import { INFO_SAVED_SUCCESSFULLY } from './../../shared/messages'; -import { EntryActionTypes, SwitchTimeEntry } from './entry.actions'; +import { EntryActionTypes, SwitchTimeEntry, DeleteEntry, CreateEntry } from './entry.actions'; import { EntryEffects } from './entry.effects'; describe('TimeEntryActionEffects', () => { @@ -18,6 +20,7 @@ describe('TimeEntryActionEffects', () => { let effects: EntryEffects; let service: EntryService; let toastrService; + const entry: Entry = { project_id: 'p-id', start_date: new Date(), id: 'id' }; beforeEach(() => { TestBed.configureTestingModule({ @@ -38,21 +41,32 @@ describe('TimeEntryActionEffects', () => { expect(effects).toBeTruthy(); }); - it('stop the active entry and return a CreateEntryAction', async () => { + it('returns StopTimeEntryRunningFail when entry could not be stoped', (done) => { actions$ = of(new SwitchTimeEntry('entry-id', 'project-id')); - const serviceSpy = spyOn(service, 'stopEntryRunning'); - serviceSpy.and.returnValue(of({})); + spyOn(service, 'stopEntryRunning').and.returnValue(throwError('any error')); + + effects.switchEntryRunning$.subscribe(action => { + expect(action.type).toBe(EntryActionTypes.STOP_TIME_ENTRY_RUNNING_FAILED); + done(); + }); + }); + + it('stop the active entry and return a CreateEntryAction', (done) => { + actions$ = of(new SwitchTimeEntry('entry-id', 'project-id')); + spyOn(service, 'stopEntryRunning').and.returnValue(of({ end_date: new Date() })); + spyOn(service, 'createEntry').and.returnValue(of({})); effects.switchEntryRunning$.subscribe(action => { expect(service.stopEntryRunning).toHaveBeenCalledWith('entry-id'); expect(action.type).toBe(EntryActionTypes.CREATE_ENTRY); + done(); }); }); it('returns an action with type LOAD_ENTRIES_SUMMARY_SUCCESS when the service returns a value', () => { - actions$ = of({type: EntryActionTypes.LOAD_ENTRIES_SUMMARY}); + actions$ = of({ type: EntryActionTypes.LOAD_ENTRIES_SUMMARY }); const serviceSpy = spyOn(service, 'summary'); - serviceSpy.and.returnValue(of({day: null, month: null, week: null })); + serviceSpy.and.returnValue(of({ day: null, month: null, week: null })); effects.loadEntriesSummary$.subscribe(action => { expect(action.type).toEqual(EntryActionTypes.LOAD_ENTRIES_SUMMARY_SUCCESS); @@ -60,7 +74,7 @@ describe('TimeEntryActionEffects', () => { }); it('returns an action with type LOAD_ENTRIES_SUMMARY_FAIL when the service fails', () => { - actions$ = of({type: EntryActionTypes.LOAD_ENTRIES_SUMMARY}); + actions$ = of({ type: EntryActionTypes.LOAD_ENTRIES_SUMMARY }); spyOn(service, 'summary').and.returnValue(throwError('any error')); effects.loadEntriesSummary$.subscribe(action => { @@ -69,9 +83,9 @@ describe('TimeEntryActionEffects', () => { }); it('When the service returns a value, then LOAD_ENTRIES_BY_TIME_RANGE_SUCCESS should be triggered', () => { - const timeRange: TimeEntriesTimeRange = {start_date: moment(new Date()), end_date: moment(new Date())}; + const timeRange: TimeEntriesTimeRange = { start_date: moment(new Date()), end_date: moment(new Date()) }; const userId = '*'; - actions$ = of({type: EntryActionTypes.LOAD_ENTRIES_BY_TIME_RANGE, timeRange, userId}); + actions$ = of({ type: EntryActionTypes.LOAD_ENTRIES_BY_TIME_RANGE, timeRange, userId }); const serviceSpy = spyOn(service, 'loadEntriesByTimeRange'); serviceSpy.and.returnValue(of([])); @@ -82,9 +96,9 @@ describe('TimeEntryActionEffects', () => { }); it('When the service fails, then LOAD_ENTRIES_BY_TIME_RANGE_FAIL should be triggered', async () => { - const timeRange: TimeEntriesTimeRange = {start_date: moment(new Date()), end_date: moment(new Date())}; + const timeRange: TimeEntriesTimeRange = { start_date: moment(new Date()), end_date: moment(new Date()) }; const userId = '*'; - actions$ = of({type: EntryActionTypes.LOAD_ENTRIES_BY_TIME_RANGE, timeRange, userId}); + actions$ = of({ type: EntryActionTypes.LOAD_ENTRIES_BY_TIME_RANGE, timeRange, userId }); spyOn(service, 'loadEntriesByTimeRange').and.returnValue(throwError('any error')); effects.loadEntriesByTimeRange$.subscribe(action => { @@ -93,21 +107,30 @@ describe('TimeEntryActionEffects', () => { }); it('returns a LOAD_ACTIVE_ENTRY_SUCCESS when the entry that is running it is in the same day', async () => { - const activeEntry = {id: '123', start_date: new Date()}; - actions$ = of({type: EntryActionTypes.LOAD_ACTIVE_ENTRY, activeEntry}); + actions$ = of({ type: EntryActionTypes.LOAD_ACTIVE_ENTRY }); const serviceSpy = spyOn(service, 'loadActiveEntry'); - serviceSpy.and.returnValue(of(activeEntry)); + serviceSpy.and.returnValue(of(entry)); effects.loadActiveEntry$.subscribe(action => { expect(action.type).toEqual(EntryActionTypes.LOAD_ACTIVE_ENTRY_SUCCESS); }); }); + it('does not return anything if an entry active is not found', async () => { + actions$ = of({ type: EntryActionTypes.LOAD_ACTIVE_ENTRY }); + const serviceSpy = spyOn(service, 'loadActiveEntry'); + serviceSpy.and.returnValue(of([])); + + effects.loadActiveEntry$.subscribe(action => { + expect(action.type).toEqual(EntryActionTypes.LOAD_ACTIVE_ENTRY_FAIL); + }); + }); + it('returns a UPDATE_ENTRY when the entry that is running it is in the past', async () => { const startDateInPast = new Date(); startDateInPast.setDate(startDateInPast.getDate() - 5); - const activeEntry = {id: '123', start_date: startDateInPast}; - actions$ = of({type: EntryActionTypes.LOAD_ACTIVE_ENTRY, activeEntry}); + const activeEntry = { id: '123', start_date: startDateInPast }; + actions$ = of({ type: EntryActionTypes.LOAD_ACTIVE_ENTRY, activeEntry }); const serviceSpy = spyOn(service, 'loadActiveEntry'); serviceSpy.and.returnValue(of(activeEntry)); @@ -117,48 +140,129 @@ describe('TimeEntryActionEffects', () => { }); it('display a success message on UPDATE_ENTRY', async () => { - const activeEntry = {}; - actions$ = of({type: EntryActionTypes.UPDATE_ENTRY, activeEntry}); + actions$ = of({ type: EntryActionTypes.UPDATE_ENTRY, entry }); spyOn(toastrService, 'success'); + spyOn(service, 'updateEntry').and.returnValue(of(entry)); effects.updateEntry$.subscribe(action => { expect(toastrService.success).toHaveBeenCalledWith(INFO_SAVED_SUCCESSFULLY); + expect(action.type).toEqual(EntryActionTypes.UPDATE_ENTRY_SUCCESS); + }); + }); + + it('UPDATE_ENTRY_FAIL when service fails', async () => { + actions$ = of({ type: EntryActionTypes.UPDATE_ENTRY, entry }); + spyOn(service, 'updateEntry').and.returnValue(throwError({ error: { message: 'doh!' } })); + + effects.updateEntry$.subscribe(action => { + expect(action.type).toEqual(EntryActionTypes.UPDATE_ENTRY_FAIL); }); }); it('does not display any message on UPDATE_ENTRY_RUNNING', async () => { - const activeEntry = {}; - actions$ = of({type: EntryActionTypes.UPDATE_ENTRY_RUNNING, activeEntry}); + actions$ = of({ type: EntryActionTypes.UPDATE_ENTRY_RUNNING, entry }); + spyOn(service, 'updateEntry').and.returnValue(of({})); spyOn(toastrService, 'success'); - effects.updateEntry$.subscribe(action => { + effects.updateEntryRunning$.subscribe(action => { expect(toastrService.success).toHaveBeenCalledTimes(0); + expect(action.type).toBe(EntryActionTypes.UPDATE_ENTRY_SUCCESS); }); }); - // it('When the service returns a value, then RESTART_ENTRY_SUCCESS should be triggered', () => { + it('display an error when updating running entry fails', async () => { + actions$ = of({ type: EntryActionTypes.UPDATE_ENTRY_RUNNING, entry }); + spyOn(service, 'updateEntry').and.returnValue(throwError({ error: { message: 'doh!' } })); + spyOn(toastrService, 'error'); - // const entryId = '123'; - // actions$ = of({type: EntryActionTypes.RESTART_ENTRY, entryId}); - // const serviceSpy = spyOn(service, 'restartEntry'); - // serviceSpy.and.returnValue(of({ id: entryId })); + effects.updateEntryRunning$.subscribe(action => { + expect(toastrService.error).toHaveBeenCalled(); + expect(action.type).toBe(EntryActionTypes.UPDATE_ENTRY_FAIL); + }); + }); + + it('When the service returns a value, then RESTART_ENTRY_SUCCESS should be triggered', () => { + actions$ = of({ type: EntryActionTypes.RESTART_ENTRY, entry }); + spyOn(service, 'restartEntry').and.returnValue(of(entry)); - // effects.restartEntry$.subscribe(action => { - // expect(action.type).toEqual(EntryActionTypes.RESTART_ENTRY_SUCCESS); - // }); + effects.restartEntry$.subscribe(action => { + expect(action.type).toEqual(EntryActionTypes.RESTART_ENTRY_SUCCESS); + }); - // }); + }); it('When the service fails, then RESTART_ENTRY_FAIL should be triggered', async () => { - const entryId = '123'; - actions$ = of({type: EntryActionTypes.LOAD_ENTRIES_BY_TIME_RANGE, entryId}); - spyOn(service, 'restartEntry').and.returnValue(throwError('any error')); + actions$ = of({ type: EntryActionTypes.RESTART_ENTRY, entry }); + spyOn(service, 'restartEntry').and.returnValue(throwError({ error: { message: 'doh!' } })); effects.restartEntry$.subscribe(action => { expect(action.type).toEqual(EntryActionTypes.RESTART_ENTRY_FAIL); }); }); + it('data from old entries is used when entries are found for the same project', async () => { + const newEntry: NewEntry = { project_id: 'p-id', start_date: new Date().toISOString() }; + actions$ = of({ type: EntryActionTypes.CLOCK_IN, payload: newEntry }); + spyOn(service, 'findEntriesByProjectId').and.returnValue(of([entry])); + + effects.clockIn$.subscribe(action => { + expect(action.type).toBe(EntryActionTypes.CREATE_ENTRY); + }); + }); + + it('when an existing entry has technologies, those are used to create the new entry', async () => { + const newEntry: NewEntry = { project_id: 'p-id', start_date: new Date().toISOString() }; + actions$ = of({ type: EntryActionTypes.CLOCK_IN, payload: newEntry }); + const oldEntry = { ...entry, technologies: ['foo']}; + spyOn(service, 'findEntriesByProjectId').and.returnValue(of([oldEntry])); + + effects.clockIn$.subscribe( (action: CreateEntry) => { + expect(action.type).toBe(EntryActionTypes.CREATE_ENTRY); + expect(action.payload.technologies).toBe(oldEntry.technologies); + }); + }); + + it('findEntriesByProjectId when clockIn', async () => { + const newEntry: NewEntry = { project_id: 'p-id' }; + actions$ = of({ type: EntryActionTypes.CLOCK_IN, payload: newEntry }); + spyOn(service, 'findEntriesByProjectId').and.returnValue(of([])); + + effects.clockIn$.subscribe(action => { + expect(service.findEntriesByProjectId).toHaveBeenCalledWith('p-id'); + }); + }); + + it('CreateEntryFailError when projects could not be found', async () => { + const newEntry: NewEntry = { project_id: 'p-id' }; + actions$ = of({ type: EntryActionTypes.CLOCK_IN, payload: newEntry }); + spyOn(service, 'findEntriesByProjectId').and.returnValue(throwError({ error: { message: 'doh!' } })); + + effects.clockIn$.subscribe(action => { + expect(action.type).toBe(EntryActionTypes.CREATE_ENTRY_FAIL); + }); + }); + + it('call deleteEntry from service when action type is DELETE_ENTRY', async () => { + actions$ = of( new DeleteEntry('entryId')); + spyOn(toastrService, 'success'); + spyOn(service, 'deleteEntry').and.returnValue(of({})); + + effects.deleteEntry$.subscribe(action => { + expect(action.type).toBe(EntryActionTypes.DELETE_ENTRY_SUCCESS); + }); + }); + + it('action type is DELETE_ENTRY_FAIL When the service fails', async () => { + const entryId = 'entry-id'; + actions$ = of({type: EntryActionTypes.DELETE_ENTRY, entryId}); + spyOn(service, 'deleteEntry').and.returnValue(throwError({ error: { message: 'doh!' } })); + + effects.deleteEntry$.subscribe( action => { + expect(action.type).toEqual(EntryActionTypes.DELETE_ENTRY_FAIL); + }); + }); + + // TODO Implement the remaining unit tests for the other effects. }); diff --git a/src/app/modules/time-clock/store/entry.effects.ts b/src/app/modules/time-clock/store/entry.effects.ts index 62fbda184..b641968de 100644 --- a/src/app/modules/time-clock/store/entry.effects.ts +++ b/src/app/modules/time-clock/store/entry.effects.ts @@ -29,8 +29,8 @@ export class EntryEffects { }); }), catchError((error) => { - this.toastrService.warning(error.error.message); - return of(new actions.StopTimeEntryRunningFail(error.error.message)); + this.toastrService.warning('We could not perform this operation, try again later'); + return of(new actions.StopTimeEntryRunningFail(error)); }) ) ) @@ -71,6 +71,8 @@ export class EntryEffects { endDate.setHours(23, 59, 59); return new actions.UpdateEntry({ id: activeEntry.id, end_date: endDate.toISOString() }); } + } else { + return new actions.LoadActiveEntryFail('No active entry found'); } }), catchError((error) => { @@ -120,8 +122,8 @@ export class EntryEffects { @Effect() clockIn$: Observable = this.actions$.pipe( ofType(actions.EntryActionTypes.CLOCK_IN), - map((action: actions.CreateEntry) => action.payload), - mergeMap((entry: NewEntry) => + map((action: actions.ClockIn) => action.payload), + mergeMap((entry) => this.entryService.findEntriesByProjectId(entry.project_id).pipe( map((entriesFound) => { if (entriesFound && entriesFound.length > 0) { @@ -178,7 +180,6 @@ export class EntryEffects { ) ); - @Effect() updateEntryRunning$: Observable = this.actions$.pipe( ofType(actions.EntryActionTypes.UPDATE_ENTRY_RUNNING), From 8eb604bcd9b12126108f6063eaefac73926ed3a2 Mon Sep 17 00:00:00 2001 From: Rene Enriquez Date: Wed, 29 Jul 2020 01:19:01 -0500 Subject: [PATCH 3/3] fix: #405 adding missing tests --- src/app/modules/time-clock/store/entry.effects.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/modules/time-clock/store/entry.effects.ts b/src/app/modules/time-clock/store/entry.effects.ts index b641968de..37a20a196 100644 --- a/src/app/modules/time-clock/store/entry.effects.ts +++ b/src/app/modules/time-clock/store/entry.effects.ts @@ -1,4 +1,3 @@ -import { NewEntry } from './../../shared/models/entry.model'; import { INFO_DELETE_SUCCESSFULLY, INFO_SAVED_SUCCESSFULLY } from './../../shared/messages'; import { Injectable } from '@angular/core'; import { Actions, Effect, ofType } from '@ngrx/effects';