From 7de993a765a6d7740ef31457a84ca7ae49c4d1d8 Mon Sep 17 00:00:00 2001 From: Gabriel De Los Rios Date: Sat, 14 Feb 2026 00:28:41 -0300 Subject: [PATCH] refactor: add service for chains settings --- .../chains/chain-add/chain-add.spec.ts | 38 +++------ .../settings/chains/chain-add/chain-add.ts | 43 +++++----- .../chains/chain-edit/chain-edit.spec.ts | 81 +++++++------------ .../settings/chains/chain-edit/chain-edit.ts | 40 +++------ src/app/services/chain-settings.spec.ts | 74 +++++++++++++++++ src/app/services/chain-settings.ts | 48 +++++++++++ 6 files changed, 191 insertions(+), 133 deletions(-) create mode 100644 src/app/services/chain-settings.spec.ts create mode 100644 src/app/services/chain-settings.ts diff --git a/src/app/pages/settings/chains/chain-add/chain-add.spec.ts b/src/app/pages/settings/chains/chain-add/chain-add.spec.ts index 5519202..ae6f5a8 100644 --- a/src/app/pages/settings/chains/chain-add/chain-add.spec.ts +++ b/src/app/pages/settings/chains/chain-add/chain-add.spec.ts @@ -1,46 +1,28 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ChainAdd } from './chain-add'; -import { ChainDAO } from '../../../../dao/ChainDAO'; import { vi } from 'vitest'; import { provideTranslateService } from '@ngx-translate/core'; -import { Router } from '@angular/router'; import { ChainFormGroup } from '../chain-formgroup'; import { FormControl } from '@angular/forms'; import { By } from '@angular/platform-browser'; -import { ImageStorage } from '../../../../services/image-storage'; +import { ChainSettings } from '../../../../services/chain-settings'; +import { Chain } from '../../../../models/Chain'; describe('ChainAdd', () => { let component: ChainAdd; let fixture: ComponentFixture; - let chainDAO: Partial; - let imageStorage: Partial; - let router: Partial; + let chainSettings: Partial; beforeEach(async () => { - chainDAO = { - insert: vi.fn().mockResolvedValue({ - rows: [], - }), - }; - - imageStorage = { - saveImage: vi.fn().mockResolvedValue('mock.jpg'), - }; - - router = { - navigate: vi.fn(), + chainSettings = { + save: vi.fn().mockResolvedValue(undefined), }; await TestBed.configureTestingModule({ imports: [ChainAdd], - providers: [ - provideTranslateService(), - { provide: ChainDAO, useValue: chainDAO }, - { provide: ImageStorage, useValue: imageStorage }, - { provide: Router, useValue: router }, - ], + providers: [provideTranslateService(), { provide: ChainSettings, useValue: chainSettings }], }).compileComponents(); fixture = TestBed.createComponent(ChainAdd); @@ -53,7 +35,6 @@ describe('ChainAdd', () => { }); it('should insert chain and store image', async () => { - const saveSpy = vi.spyOn(component, 'save'); (component).form = new ChainFormGroup({ name: new FormControl('Mock'), image: new FormControl(new File([], 'mock')), @@ -62,8 +43,9 @@ describe('ChainAdd', () => { const actionBtn = fixture.debugElement.query(By.css('app-action-btn')); actionBtn.triggerEventHandler('click'); await fixture.whenStable(); - expect(saveSpy).toHaveBeenCalled(); - expect(imageStorage.saveImage).toHaveBeenCalledOnce(); - expect(chainDAO.insert).toHaveBeenCalledOnce(); + expect(chainSettings.save).toHaveBeenCalledExactlyOnceWith( + new Chain(component.form.controls.name.value, ''), + component.form.controls.image.value, + ); }); }); diff --git a/src/app/pages/settings/chains/chain-add/chain-add.ts b/src/app/pages/settings/chains/chain-add/chain-add.ts index 57aede3..90346a5 100644 --- a/src/app/pages/settings/chains/chain-add/chain-add.ts +++ b/src/app/pages/settings/chains/chain-add/chain-add.ts @@ -2,44 +2,37 @@ import { Component, inject } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; -import { ImageStorage } from '../../../../services/image-storage'; -import { ChainDAO } from '../../../../dao/ChainDAO'; import { Chain } from '../../../../models/Chain'; import { ChainFormGroup } from '../chain-formgroup'; -import { ChainForm } from "../chain-form/chain-form"; -import { Router } from '@angular/router'; -import { ActionBtn } from "../../../../components/action-btn/action-btn"; +import { ChainForm } from '../chain-form/chain-form'; +import { ActionBtn } from '../../../../components/action-btn/action-btn'; import { TranslatePipe } from '@ngx-translate/core'; -import { UpperfirstPipe } from "../../../../pipes/upperfirst-pipe"; +import { UpperfirstPipe } from '../../../../pipes/upperfirst-pipe'; +import { ChainSettings } from '../../../../services/chain-settings'; @Component({ selector: 'app-chain-add', - imports: [MatFormFieldModule, MatInputModule, ReactiveFormsModule, ChainForm, ActionBtn, TranslatePipe, UpperfirstPipe], + imports: [ + ActionBtn, + ChainForm, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule, + TranslatePipe, + UpperfirstPipe, + ], templateUrl: './chain-add.html', styleUrl: './chain-add.scss', }) export class ChainAdd { - private readonly chainDAO = inject(ChainDAO); - private readonly imageStorage = inject(ImageStorage); - private readonly router = inject(Router); - readonly form = new ChainFormGroup + private readonly chainSettings = inject(ChainSettings); + readonly form = new ChainFormGroup(); async save() { const name = this.form.controls.name.value; const img = this.form.controls.image.value; - try { - //TODO: the sqlite bridge can't handle null as param - const chain = new Chain(name, ''); - if (img) { - const imgFileName = await this.imageStorage.saveImage(img); - chain.image = imgFileName; - } - await this.chainDAO.insert(chain); - this.router.navigate(['settings', 'chains']) - } catch (e) { - console.error(e) - //TODO: report problem - } + //TODO: the sqlite bridge can't handle null as param + const chain = new Chain(name, ''); + await this.chainSettings.save(chain, img); } - } diff --git a/src/app/pages/settings/chains/chain-edit/chain-edit.spec.ts b/src/app/pages/settings/chains/chain-edit/chain-edit.spec.ts index 0bc7cc5..0afa365 100644 --- a/src/app/pages/settings/chains/chain-edit/chain-edit.spec.ts +++ b/src/app/pages/settings/chains/chain-edit/chain-edit.spec.ts @@ -1,52 +1,44 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ChainEdit } from './chain-edit'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { BehaviorSubject } from 'rxjs'; import { Chain } from '../../../../models/Chain'; import { provideTranslateService } from '@ngx-translate/core'; -import { ChainDAO } from '../../../../dao/ChainDAO'; import { By } from '@angular/platform-browser'; import { ImageStorage } from '../../../../services/image-storage'; +import { ChainSettings } from '../../../../services/chain-settings'; -const CHAINS_PATH = ['settings', 'chains']; const CHAIN_MOCK = new Chain('Mock', '', 1); describe('ChainEdit', () => { let component: ChainEdit; let fixture: ComponentFixture; let activatedRoute: Partial; - let chainDAO: Partial; + let chainSettings: Partial; let imageStorage: Partial; - let router: Partial; const dataSubject = new BehaviorSubject({ chain: CHAIN_MOCK }); beforeEach(async () => { activatedRoute = { data: dataSubject, }; - chainDAO = { - update: vi.fn().mockResolvedValue({ - rows: [], - }), + chainSettings = { + update: vi.fn(), }; imageStorage = { getImage: vi.fn(), saveImage: vi.fn(), deleteImage: vi.fn(), }; - router = { - navigate: vi.fn(), - }; await TestBed.configureTestingModule({ imports: [ChainEdit], providers: [ provideTranslateService(), { provide: ActivatedRoute, useValue: activatedRoute }, - { provide: ChainDAO, useValue: chainDAO }, + { provide: ChainSettings, useValue: chainSettings }, { provide: ImageStorage, useValue: imageStorage }, - { provide: Router, useValue: router }, ], }).compileComponents(); @@ -59,44 +51,29 @@ describe('ChainEdit', () => { expect(component).toBeTruthy(); }); - describe('on form update', () => { - it('should update chain name after modifying name', async () => { - await fixture.whenStable(); - const chainNameInput = fixture.debugElement.query(By.css('[formControlName="name"]')); - chainNameInput.triggerEventHandler('input', { target: { value: 'name update mock' } }); - fixture.whenStable(); - const actionBtn = fixture.debugElement.query(By.css('app-action-btn')); - actionBtn.triggerEventHandler('click'); - await fixture.whenStable(); - expect(chainDAO.update).toHaveBeenCalledExactlyOnceWith(component['chain'], { - id: CHAIN_MOCK.id, - }); - expect(router.navigate).toHaveBeenCalledExactlyOnceWith(CHAINS_PATH); - }); + it('should call chainSettings update on chain update', async () => { + const CHAIN_NAME_UPDATE_MOCK = 'name update mock'; + await fixture.whenStable(); + const chainNameInput = fixture.debugElement.query(By.css('[formControlName="name"]')); + chainNameInput.triggerEventHandler('input', { target: { value: CHAIN_NAME_UPDATE_MOCK } }); + fixture.whenStable(); + const actionBtn = fixture.debugElement.query(By.css('app-action-btn')); + actionBtn.triggerEventHandler('click'); + await fixture.whenStable(); + expect(chainSettings.update).toHaveBeenCalledExactlyOnceWith( + CHAIN_MOCK, + new Chain(CHAIN_NAME_UPDATE_MOCK, component['chain']!.image, component['chain']!.id), + component.form.controls.image.value, + ); + }); - it('should update chain image after modifying image', async () => { - const NEW_IMG_MOCK = new File([], 'new_img_mock.png', { type: 'image/png' }); - const CHAIN_MOCK = new Chain('Mock', 'img_mock.jpeg', 1); - imageStorage.getImage = vi - .fn() - .mockResolvedValue(new File([], CHAIN_MOCK.image!, { type: 'image/jpeg' })); - imageStorage.saveImage = vi.fn().mockResolvedValue(NEW_IMG_MOCK.name); - - dataSubject.next({ chain: CHAIN_MOCK }); - await fixture.whenStable(); - //User's select a new image - component.form.controls.image.patchValue(NEW_IMG_MOCK); - await fixture.whenStable(); - const actionBtn = fixture.debugElement.query(By.css('app-action-btn')); - actionBtn.triggerEventHandler('click'); - await fixture.whenStable(); - expect(imageStorage.getImage).toHaveBeenCalledWith(CHAIN_MOCK.image); - expect(imageStorage.saveImage).toHaveBeenCalledExactlyOnceWith(NEW_IMG_MOCK); - expect(imageStorage.deleteImage).toHaveBeenCalledExactlyOnceWith(CHAIN_MOCK.image); - expect(chainDAO.update).toHaveBeenCalledExactlyOnceWith(component['chain'], { - id: CHAIN_MOCK.id, - }); - expect(router.navigate).toHaveBeenCalledExactlyOnceWith(CHAINS_PATH); - }); + it('should patch form with chain data', async () => { + const IMAGE_FILE_MOCK = new Blob([], { type: 'image/png' }); + imageStorage.getImage = vi.fn().mockResolvedValue(IMAGE_FILE_MOCK); + const CHAIN_MOCK = new Chain('name', 'image.png', 1); + dataSubject.next({ chain: CHAIN_MOCK }); + await fixture.whenStable(); + expect(component.form.controls.name.value).toEqual(CHAIN_MOCK.name); + expect(component.form.controls.image.value?.type).toEqual(IMAGE_FILE_MOCK.type); }); }); diff --git a/src/app/pages/settings/chains/chain-edit/chain-edit.ts b/src/app/pages/settings/chains/chain-edit/chain-edit.ts index 7524d94..0e14dae 100644 --- a/src/app/pages/settings/chains/chain-edit/chain-edit.ts +++ b/src/app/pages/settings/chains/chain-edit/chain-edit.ts @@ -1,14 +1,14 @@ import { ChangeDetectorRef, Component, inject, OnInit } from '@angular/core'; import { ChainFormGroup } from '../chain-formgroup'; import { ChainForm } from '../chain-form/chain-form'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { Observable, take, tap } from 'rxjs'; import { Chain } from '../../../../models/Chain'; import { ImageStorage } from '../../../../services/image-storage'; import { ActionBtn } from '../../../../components/action-btn/action-btn'; -import { ChainDAO } from '../../../../dao/ChainDAO'; import { TranslatePipe } from '@ngx-translate/core'; -import { UpperfirstPipe } from "../../../../pipes/upperfirst-pipe"; +import { UpperfirstPipe } from '../../../../pipes/upperfirst-pipe'; +import { ChainSettings } from '../../../../services/chain-settings'; @Component({ selector: 'app-chain-edit', @@ -21,9 +21,8 @@ export class ChainEdit implements OnInit { protected readonly activatedRoute = inject(ActivatedRoute); private readonly cd = inject(ChangeDetectorRef); private chain?: Chain; - private readonly chainDAO = inject(ChainDAO); private readonly imageStorage = inject(ImageStorage); - private readonly router = inject(Router); + private readonly chainSettings = inject(ChainSettings); ngOnInit() { (>this.activatedRoute.data) @@ -36,9 +35,9 @@ export class ChainEdit implements OnInit { } get disabled() { - return (this.form.invalid || this.form.pristine) + return this.form.invalid || this.form.pristine; } - + async patchForm(chain: Chain) { try { this.form.controls.name.patchValue(chain.name); @@ -51,31 +50,16 @@ export class ChainEdit implements OnInit { } } catch (e) { console.error(e); - //TODO: reportar error + //TODO: reportar error } } async update() { - try { - if (this.chain) { - this.chain.name = this.form.controls.name.value; - const formImgFile = this.form.controls.image.value; - let imgName = this.chain.name; - if (formImgFile) { - if (formImgFile.name !== imgName) { - imgName = await this.imageStorage.saveImage(formImgFile); - if (this.chain.image) { - this.imageStorage.deleteImage(this.chain.image); - } - this.chain.image = imgName; - } - } - await this.chainDAO.update(this.chain, { id: this.chain.id }); - this.router.navigate(['settings', 'chains']); - } - } catch (e) { - console.error(e); - //TODO: reportar error + if (this.chain) { + const updatedName = this.form.controls.name.value; + const updatedImg = this.form.controls.image.value; + const updatedChain = new Chain(updatedName, '', this.chain.id); + await this.chainSettings.update(this.chain, updatedChain, updatedImg); } } } diff --git a/src/app/services/chain-settings.spec.ts b/src/app/services/chain-settings.spec.ts new file mode 100644 index 0000000..27c4da5 --- /dev/null +++ b/src/app/services/chain-settings.spec.ts @@ -0,0 +1,74 @@ +import { TestBed } from '@angular/core/testing'; + +import { ChainSettings } from './chain-settings'; +import { ImageStorage } from './image-storage'; +import { ChainDAO } from '../dao/ChainDAO'; +import { Router } from '@angular/router'; +import { Chain } from '../models/Chain'; + +const CHAIN_SETTINGS_PATH = ['settings', 'chains']; +describe('ChainSettings', () => { + let service: ChainSettings; + + let chainDAO: Partial; + let imageStorage: Partial; + let router: Partial; + + beforeEach(() => { + chainDAO = { + update: vi.fn().mockResolvedValue({ + rows: [], + }), + insert: vi.fn().mockResolvedValue({ + rows: [], + }), + }; + + imageStorage = { + saveImage: vi.fn().mockResolvedValue('mock.jpg'), + deleteImage: vi.fn().mockResolvedValue(undefined), + }; + + router = { + navigate: vi.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: ChainDAO, useValue: chainDAO }, + { provide: ImageStorage, useValue: imageStorage }, + { provide: Router, useValue: router }, + ], + }); + service = TestBed.inject(ChainSettings); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should save chain and store image', async () => { + const CHAIN_IMG_MOCK = new File([], 'image.jpg', { type: 'image/jpeg' }); + const CHAIN_MOCK = new Chain('mock', ''); + imageStorage.saveImage = vi.fn().mockResolvedValue(CHAIN_IMG_MOCK.name); + + await service.save(CHAIN_MOCK, CHAIN_IMG_MOCK); + expect(imageStorage.saveImage).toHaveBeenCalledExactlyOnceWith(CHAIN_IMG_MOCK); + expect(chainDAO.insert).toHaveBeenCalledExactlyOnceWith(CHAIN_MOCK); + expect(router.navigate).toHaveBeenCalledExactlyOnceWith(CHAIN_SETTINGS_PATH); + }); + + it('should update chain and its image', async () => { + const MODIFIED_IMG_MOCK = new File([], 'new_image.jpg', { type: 'image/jpeg' }); + const PREV_CHAIN_MOCK = new Chain('prev chain name', 'prev_img.png'); + const MODIFIED_CHAIN_MOCK = new Chain('new chain name', ''); + await service.update(PREV_CHAIN_MOCK, MODIFIED_CHAIN_MOCK, MODIFIED_IMG_MOCK); + expect(imageStorage.saveImage).toHaveBeenCalledExactlyOnceWith(MODIFIED_IMG_MOCK); + expect(imageStorage.deleteImage).toHaveBeenCalledExactlyOnceWith(PREV_CHAIN_MOCK.image); + expect(MODIFIED_CHAIN_MOCK.image).toEqual('mock.jpg'); + expect(chainDAO.update).toHaveBeenCalledExactlyOnceWith(MODIFIED_CHAIN_MOCK, { + id: MODIFIED_CHAIN_MOCK.id, + }); + expect(router.navigate).toHaveBeenCalledExactlyOnceWith(CHAIN_SETTINGS_PATH); + }); +}); diff --git a/src/app/services/chain-settings.ts b/src/app/services/chain-settings.ts new file mode 100644 index 0000000..3a759fa --- /dev/null +++ b/src/app/services/chain-settings.ts @@ -0,0 +1,48 @@ +import { inject, Injectable } from '@angular/core'; +import { ChainDAO } from '../dao/ChainDAO'; +import { ImageStorage } from './image-storage'; +import { Router } from '@angular/router'; +import { Chain } from '../models/Chain'; + +@Injectable({ + providedIn: 'root', +}) +export class ChainSettings { + private readonly chainDAO = inject(ChainDAO); + private readonly imageStorage = inject(ImageStorage); + private readonly router = inject(Router); + + async save(chain: Chain, img: File | null) { + try { + if (img) { + const imgFileName = await this.imageStorage.saveImage(img); + chain.image = imgFileName; + } + await this.chainDAO.insert(chain); + this.router.navigate(['settings', 'chains']); + } catch (e) { + console.error(e); + //TODO: report problem + } + } + + async update(prevChain: Chain, updatedChain: Chain, img: File | null) { + try { + let imgName = prevChain.name; + if (img) { + if (img.name !== imgName) { + imgName = await this.imageStorage.saveImage(img); + if (prevChain.image) { + this.imageStorage.deleteImage(prevChain.image); + } + updatedChain.image = imgName; + } + } + await this.chainDAO.update(updatedChain, { id: updatedChain.id }); + this.router.navigate(['settings', 'chains']); + } catch (e) { + console.error(e); + //TODO: report problem + } + } +}