diff --git a/src/app/features/admin/moderation/facade/admin-moderation.facade.spec.ts b/src/app/features/admin/moderation/facade/admin-moderation.facade.spec.ts new file mode 100644 index 0000000..a1e17f9 --- /dev/null +++ b/src/app/features/admin/moderation/facade/admin-moderation.facade.spec.ts @@ -0,0 +1,155 @@ +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { AdminModerationFacade } from './admin-moderation.facade'; +import { ADMIN_MODERATION_GATEWAY } from '../services/admin-moderation-gateway.token'; +import { AdminModerationGateway } from '../services/admin-moderation-gateway.interface'; +import { AdminReview } from '../models/admin-review.model'; +import { LocalStorageService } from '../../../../core/storage/local-storage.service'; + +function review(overrides: Partial = {}): AdminReview { + return { + id: 'r1', + productId: 'p1', + productName: 'Widget', + customerName: 'Buyer', + customerEmail: 'b@example.com', + rating: 5, + text: 'Great', + photos: [], + status: 'pending', + visible: true, + pinned: false, + featured: false, + reportCount: 0, + moderatorNotes: '', + timeline: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + }; +} + +describe('AdminModerationFacade', () => { + let facade: AdminModerationFacade; + let gateway: jasmine.SpyObj; + + beforeEach(() => { + gateway = jasmine.createSpyObj('AdminModerationGateway', [ + 'loadReviews', 'loadReview', 'setReviewStatus', 'setReviewVisible', 'setReviewPinned', + 'setReviewFeatured', 'addModeratorNote', 'deleteReview', 'loadReports', 'setReportStatus', + ]); + + TestBed.configureTestingModule({ + providers: [ + { provide: ADMIN_MODERATION_GATEWAY, useValue: gateway }, + { provide: LocalStorageService, useValue: { getItem: () => null, setItem: () => {}, getJSON: () => null, setJSON: () => {} } }, + ], + }); + + facade = TestBed.inject(AdminModerationFacade); + }); + + describe('health', () => { + it('scores 100% when every check passes', () => { + const result = facade.health(review({ rating: 5, text: 'x', photos: ['a'], status: 'approved', reportCount: 0, visible: true })); + + expect(result.completionPercent).toBe(100); + }); + + it('scores 0% when every check fails', () => { + const result = facade.health(review({ rating: 0, text: '', photos: [], status: 'pending', reportCount: 1, visible: false })); + + expect(result.completionPercent).toBe(0); + }); + + it('treats whitespace-only text as no text', () => { + const result = facade.health(review({ text: ' ' })); + + expect(result.hasText).toBeFalse(); + }); + + it('any status other than pending counts as moderated', () => { + expect(facade.health(review({ status: 'approved' })).isModerated).toBeTrue(); + expect(facade.health(review({ status: 'rejected' })).isModerated).toBeTrue(); + expect(facade.health(review({ status: 'pending' })).isModerated).toBeFalse(); + }); + }); + + describe('loadDashboardStats', () => { + it('computes averageRating only from reviews that have a rating', () => { + gateway.loadReviews.and.returnValue(of({ + items: [review({ rating: 4 }), review({ rating: 0 }), review({ rating: 5 })], + total: 3, page: 1, pageSize: 100000, + })); + + facade.loadDashboardStats(); + + // (4 + 5) / 2 = 4.5 - the zero-rating review must not drag the average down. + expect(facade.dashboardStats()?.averageRating).toBe(4.5); + }); + + it('averageRating is null when no review has a rating, not 0', () => { + gateway.loadReviews.and.returnValue(of({ + items: [review({ rating: 0 })], + total: 1, page: 1, pageSize: 100000, + })); + + facade.loadDashboardStats(); + + expect(facade.dashboardStats()?.averageRating).toBeNull(); + }); + + it('moderationHealthPercent is 100 for an empty queue, not a divide-by-zero artifact', () => { + gateway.loadReviews.and.returnValue(of({ items: [], total: 0, page: 1, pageSize: 100000 })); + + facade.loadDashboardStats(); + + expect(facade.dashboardStats()?.moderationHealthPercent).toBe(100); + }); + + it('counts reported reviews independently of their moderation status', () => { + gateway.loadReviews.and.returnValue(of({ + items: [review({ status: 'approved', reportCount: 2 }), review({ status: 'pending', reportCount: 0 })], + total: 2, page: 1, pageSize: 100000, + })); + + facade.loadDashboardStats(); + + expect(facade.dashboardStats()?.reported).toBe(1); + }); + }); + + describe('setReportStatus', () => { + it('reloads the reports list after a successful status change', () => { + gateway.setReportStatus.and.returnValue(of(null)); + gateway.loadReports.and.returnValue(of([])); + + facade.setReportStatus('rep1', 'resolved'); + + expect(gateway.loadReports).toHaveBeenCalled(); + }); + }); + + describe('loadReports error handling', () => { + it('clears reports and sets reportsError on failure', () => { + gateway.loadReports.and.returnValue(throwError(() => new Error('x'))); + + facade.loadReports(); + + expect(facade.reports()).toEqual([]); + expect(facade.reportsError()).toBeTrue(); + expect(facade.reportsLoading()).toBeFalse(); + }); + }); + + describe('selection', () => { + it('toggleAll selects every loaded review id', () => { + gateway.loadReviews.and.returnValue(of({ items: [review({ id: 'a' }), review({ id: 'b' })], total: 2, page: 1, pageSize: 10 })); + facade.loadList(); + + facade.toggleAll(true); + + expect(facade.selectedIds()).toEqual(['a', 'b']); + }); + }); +}); diff --git a/src/app/features/admin/monitoring/facade/admin-monitoring.facade.spec.ts b/src/app/features/admin/monitoring/facade/admin-monitoring.facade.spec.ts new file mode 100644 index 0000000..a905364 --- /dev/null +++ b/src/app/features/admin/monitoring/facade/admin-monitoring.facade.spec.ts @@ -0,0 +1,54 @@ +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { AdminMonitoringFacade } from './admin-monitoring.facade'; +import { ADMIN_MONITORING_GATEWAY } from '../services/admin-monitoring-gateway.token'; +import { AdminMonitoringGateway } from '../services/admin-monitoring-gateway.interface'; + +describe('AdminMonitoringFacade', () => { + let facade: AdminMonitoringFacade; + let gateway: jasmine.SpyObj; + + beforeEach(() => { + gateway = jasmine.createSpyObj('AdminMonitoringGateway', [ + 'loadEvents', 'loadQueues', 'loadWebhooks', + ]); + TestBed.configureTestingModule({ providers: [{ provide: ADMIN_MONITORING_GATEWAY, useValue: gateway }] }); + facade = TestBed.inject(AdminMonitoringFacade); + }); + + it('loadAll populates events, queues, and webhooks independently', () => { + gateway.loadEvents.and.returnValue(of([{ id: 'e1' } as any])); + gateway.loadQueues.and.returnValue(of([{ id: 'q1' } as any])); + gateway.loadWebhooks.and.returnValue(of([{ id: 'w1' } as any])); + + facade.loadAll(); + + expect(facade.events().length).toBe(1); + expect(facade.queues().length).toBe(1); + expect(facade.webhooks().length).toBe(1); + }); + + it('an events failure clears events and sets error, without blocking queues/webhooks from loading', () => { + gateway.loadEvents.and.returnValue(throwError(() => new Error('x'))); + gateway.loadQueues.and.returnValue(of([{ id: 'q1' } as any])); + gateway.loadWebhooks.and.returnValue(of([])); + + facade.loadAll(); + + expect(facade.events()).toEqual([]); + expect(facade.error()).toBeTrue(); + expect(facade.queues().length).toBe(1); + }); + + it('updateFilters merges the patch and refetches events using the merged filters', () => { + gateway.loadEvents.and.returnValue(of([])); + gateway.loadQueues.and.returnValue(of([])); + gateway.loadWebhooks.and.returnValue(of([])); + facade.loadAll(); + + facade.updateFilters({ search: 'checkout' }); + + expect(facade.filters()).toEqual({ category: 'all', search: 'checkout' }); + expect(gateway.loadEvents).toHaveBeenCalledWith({ category: 'all', search: 'checkout' }); + }); +}); diff --git a/src/app/features/admin/orders/facade/admin-orders.facade.spec.ts b/src/app/features/admin/orders/facade/admin-orders.facade.spec.ts new file mode 100644 index 0000000..8af12ad --- /dev/null +++ b/src/app/features/admin/orders/facade/admin-orders.facade.spec.ts @@ -0,0 +1,237 @@ +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { AdminOrdersFacade } from './admin-orders.facade'; +import { ADMIN_ORDERS_GATEWAY } from '../services/admin-orders-gateway.token'; +import { AdminOrdersGateway } from '../services/admin-orders-gateway.interface'; +import { AdminOrder } from '../models/admin-order.model'; +import { LocalStorageService } from '../../../../core/storage/local-storage.service'; + +function order(overrides: Partial = {}): AdminOrder { + return { + id: 'o1', + orderNumber: '1001', + status: 'pending', + customer: { name: 'Buyer', email: 'b@example.com', phone: '' }, + payment: { method: 'card', status: 'paid', amount: 1000, currency: 'RUB' }, + shipping: { address: '', method: '', trackingNumber: '' }, + items: [], + total: 1000, + currency: 'RUB', + notes: '', + internalNotes: '', + timeline: [], + archived: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + }; +} + +describe('AdminOrdersFacade', () => { + let facade: AdminOrdersFacade; + let gateway: jasmine.SpyObj; + + beforeEach(() => { + gateway = jasmine.createSpyObj('AdminOrdersGateway', [ + 'loadOrders', 'loadOrder', 'updateStatus', 'requestRefund', 'addNote', + 'archiveOrder', 'restoreOrder', 'deleteOrder', + ]); + + TestBed.configureTestingModule({ + providers: [ + { provide: ADMIN_ORDERS_GATEWAY, useValue: gateway }, + { provide: LocalStorageService, useValue: { getItem: () => null, setItem: () => {}, getJSON: () => null, setJSON: () => {} } }, + ], + }); + + facade = TestBed.inject(AdminOrdersFacade); + }); + + describe('loadList', () => { + it('populates orders and total on success', () => { + gateway.loadOrders.and.returnValue(of({ items: [order()], total: 1, page: 1, pageSize: 10 })); + + facade.loadList(); + + expect(facade.orders().length).toBe(1); + expect(facade.total()).toBe(1); + expect(facade.loading()).toBeFalse(); + expect(facade.error()).toBeNull(); + }); + + it('clears the list and surfaces an error on failure, rather than leaving stale data on screen', () => { + gateway.loadOrders.and.returnValue(of({ items: [order()], total: 1, page: 1, pageSize: 10 })); + facade.loadList(); + + gateway.loadOrders.and.returnValue(throwError(() => new Error('network'))); + facade.loadList(); + + expect(facade.orders()).toEqual([]); + expect(facade.total()).toBe(0); + expect(facade.error()).toBe('common.errorDescription'); + }); + }); + + describe('updateFilters', () => { + it('resets to page 1 whenever a filter other than page changes', () => { + gateway.loadOrders.and.returnValue(of({ items: [], total: 0, page: 1, pageSize: 10 })); + + facade.updateFilters({ page: 5 }); + facade.updateFilters({ status: 'processing' }); + + expect(facade.filters().page).toBe(1); + expect(facade.filters().status).toBe('processing'); + }); + + it('reloads the list on every filter change', () => { + gateway.loadOrders.and.returnValue(of({ items: [], total: 0, page: 1, pageSize: 10 })); + + facade.updateFilters({ search: 'x' }); + + expect(gateway.loadOrders).toHaveBeenCalled(); + }); + }); + + describe('addNote', () => { + it('does not call the gateway for a whitespace-only note', () => { + facade.addNote('o1', ' ', false); + + expect(gateway.addNote).not.toHaveBeenCalled(); + }); + + it('trims the note before sending it', () => { + gateway.addNote.and.returnValue(of(order())); + + facade.addNote('o1', ' hello ', true); + + expect(gateway.addNote).toHaveBeenCalledWith('o1', 'hello', true); + }); + }); + + describe('selection', () => { + it('toggleAll selects every currently loaded order id', () => { + gateway.loadOrders.and.returnValue(of({ items: [order({ id: 'a' }), order({ id: 'b' })], total: 2, page: 1, pageSize: 10 })); + facade.loadList(); + + facade.toggleAll(true); + + expect(facade.selectedIds()).toEqual(['a', 'b']); + expect(facade.hasSelection()).toBeTrue(); + }); + + it('toggleSelection does not duplicate an id already selected', () => { + facade.toggleSelection('a', true); + facade.toggleSelection('a', true); + + expect(facade.selectedIds()).toEqual(['a']); + }); + + it('clearSelection empties the selection', () => { + facade.toggleSelection('a', true); + facade.clearSelection(); + + expect(facade.hasSelection()).toBeFalse(); + }); + }); + + describe('bulk actions', () => { + it('applyBulkStatus calls updateStatus for every selected id then clears selection', () => { + gateway.updateStatus.and.returnValue(of(order())); + gateway.loadOrders.and.returnValue(of({ items: [], total: 0, page: 1, pageSize: 10 })); + facade.toggleSelection('a', true); + facade.toggleSelection('b', true); + + facade.applyBulkStatus('cancelled'); + + expect(gateway.updateStatus).toHaveBeenCalledWith('a', 'cancelled'); + expect(gateway.updateStatus).toHaveBeenCalledWith('b', 'cancelled'); + expect(facade.hasSelection()).toBeFalse(); + }); + + it('applyBulkArchive routes to archiveOrder when archiving true, restoreOrder when false', () => { + gateway.archiveOrder.and.returnValue(of(order())); + gateway.restoreOrder.and.returnValue(of(order())); + gateway.loadOrders.and.returnValue(of({ items: [], total: 0, page: 1, pageSize: 10 })); + + facade.toggleSelection('a', true); + facade.applyBulkArchive(true); + expect(gateway.archiveOrder).toHaveBeenCalledWith('a'); + + facade.toggleSelection('b', true); + facade.applyBulkArchive(false); + expect(gateway.restoreOrder).toHaveBeenCalledWith('b'); + }); + }); + + describe('computeDashboardStats (via loadDashboardStats)', () => { + it('counts a customer with more than one order as returning', () => { + gateway.loadOrders.and.returnValue(of({ + items: [ + order({ id: 'a', customer: { name: 'X', email: 'x@example.com', phone: '' } }), + order({ id: 'b', customer: { name: 'X', email: 'x@example.com', phone: '' } }), + order({ id: 'c', customer: { name: 'Y', email: 'y@example.com', phone: '' } }), + ], + total: 3, page: 1, pageSize: 100000, + })); + + facade.loadDashboardStats(); + + expect(facade.dashboardStats()?.customers).toBe(2); + expect(facade.dashboardStats()?.returningCustomers).toBe(1); + }); + + it('flags refund_requested payments as an alert', () => { + gateway.loadOrders.and.returnValue(of({ + items: [order({ payment: { method: 'card', status: 'refund_requested', amount: 500, currency: 'RUB' } })], + total: 1, page: 1, pageSize: 100000, + })); + + facade.loadDashboardStats(); + + expect(facade.dashboardStats()?.alerts.some(a => a.labelKey === 'adminOrders.alertRefundRequested')).toBeTrue(); + }); + + it('flags a pending order older than 48h as stuck', () => { + const old = new Date(Date.now() - 49 * 60 * 60 * 1000).toISOString(); + gateway.loadOrders.and.returnValue(of({ + items: [order({ status: 'pending', createdAt: old })], + total: 1, page: 1, pageSize: 100000, + })); + + facade.loadDashboardStats(); + + expect(facade.dashboardStats()?.alerts.some(a => a.labelKey === 'adminOrders.alertStuckPending')).toBeTrue(); + }); + + it('does not flag a pending order under 48h old', () => { + const recent = new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(); + gateway.loadOrders.and.returnValue(of({ + items: [order({ status: 'pending', createdAt: recent })], + total: 1, page: 1, pageSize: 100000, + })); + + facade.loadDashboardStats(); + + expect(facade.dashboardStats()?.alerts.some(a => a.labelKey === 'adminOrders.alertStuckPending')).toBeFalse(); + }); + + it('averageOrder is 0 for an empty order book, not NaN or a divide-by-zero crash', () => { + gateway.loadOrders.and.returnValue(of({ items: [], total: 0, page: 1, pageSize: 100000 })); + + facade.loadDashboardStats(); + + expect(facade.dashboardStats()?.averageOrder).toBe(0); + }); + }); + + describe('exportCsv', () => { + it('produces one header row plus one row per loaded order', () => { + gateway.loadOrders.and.returnValue(of({ items: [order(), order({ id: 'o2', orderNumber: '1002' })], total: 2, page: 1, pageSize: 10 })); + facade.loadList(); + + const csv = facade.exportCsv(); + + expect(csv.split('\n').length).toBe(3); + }); + }); +}); diff --git a/src/app/features/admin/products/facade/admin-products.facade.spec.ts b/src/app/features/admin/products/facade/admin-products.facade.spec.ts new file mode 100644 index 0000000..470731d --- /dev/null +++ b/src/app/features/admin/products/facade/admin-products.facade.spec.ts @@ -0,0 +1,226 @@ +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { AdminProductsFacade } from './admin-products.facade'; +import { ADMIN_PRODUCTS_GATEWAY } from '../services/admin-products-gateway.token'; +import { AdminProductsGateway } from '../services/admin-products-gateway.interface'; +import { AdminProductsFormFactory } from '../services/admin-products-form.factory'; +import { ProjectEditorFacade } from '../../../project-editor/facade/project-editor.facade'; +import { LocalStorageService } from '../../../../core/storage/local-storage.service'; +import { AdminProduct } from '../models/admin-product.model'; + +function product(overrides: Partial = {}): AdminProduct { + return { + id: 'p1', + name: 'Widget', + slug: 'widget', + sku: 'W-1', + barcode: '', + brand: '', + categoryId: 'c1', + visible: true, + archived: false, + priority: 0, + media: { images: ['a.jpg'], gallery: [], videos: [] }, + price: 1000, + discount: 0, + currency: 'RUB', + quantity: 10, + stockStatus: 'in_stock', + availability: '', + shortDescription: 'A widget', + htmlDescription: '', + specifications: [], + attributes: [], + variantAttributes: [], + visits: 0, + variants: [], + relatedProductIds: [], + translations: {}, + seo: { metaTitle: 'Widget', metaDescription: 'A fine widget', keywords: '' }, + featured: false, + recommended: false, + isNew: false, + bestseller: false, + badges: [], + reviews: [], + questions: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } as AdminProduct; +} + +describe('AdminProductsFacade', () => { + let facade: AdminProductsFacade; + let gateway: jasmine.SpyObj; + + beforeEach(() => { + gateway = jasmine.createSpyObj('AdminProductsGateway', [ + 'loadProducts', 'loadProduct', 'loadCategories', 'createProduct', 'updateProduct', + 'deleteProduct', 'duplicateProduct', 'archiveProduct', 'restoreProduct', + ]); + + TestBed.configureTestingModule({ + providers: [ + { provide: ADMIN_PRODUCTS_GATEWAY, useValue: gateway }, + { provide: AdminProductsFormFactory, useValue: { createEmpty: () => product({ id: '' }) } }, + { provide: ProjectEditorFacade, useValue: { bootstrap: () => null, loadBootstrap: () => {} } }, + { provide: LocalStorageService, useValue: { getItem: () => null, setItem: () => {}, getJSON: () => null, setJSON: () => {} } }, + ], + }); + + facade = TestBed.inject(AdminProductsFacade); + }); + + describe('health', () => { + it('scores 100% when every check passes', () => { + const result = facade.health(product()); + + expect(result.completionPercent).toBe(100); + }); + + it('flags a missing SEO description even when the title is present', () => { + const result = facade.health(product({ seo: { metaTitle: 'x', metaDescription: ' ', keywords: '' } })); + + expect(result.hasSeo).toBeFalse(); + }); + + it('counts either images or gallery as satisfying hasImages', () => { + expect(facade.health(product({ media: { images: [], gallery: ['g.jpg'], videos: [] } })).hasImages).toBeTrue(); + expect(facade.health(product({ media: { images: [], gallery: [], videos: [] } })).hasImages).toBeFalse(); + }); + + it('treats a zero price as missing, not a valid free product', () => { + expect(facade.health(product({ price: 0 })).hasPrice).toBeFalse(); + }); + }); + + describe('computeDashboardStats (via loadDashboardStats)', () => { + it('recommends adding images before anything else, when any product lacks them', () => { + gateway.loadProducts.and.returnValue(of({ + items: [product({ id: 'a', media: { images: [], gallery: [], videos: [] } })], + total: 1, page: 1, pageSize: 100000, + })); + + facade.loadDashboardStats(); + + expect(facade.dashboardStats()?.recommendation.labelKey).toBe('adminProducts.recommendAddImages'); + expect(facade.dashboardStats()?.recommendation.productId).toBe('a'); + }); + + it('falls through to restock recommendation once images and SEO are fine', () => { + gateway.loadProducts.and.returnValue(of({ + items: [product({ id: 'a', stockStatus: 'out_of_stock' })], + total: 1, page: 1, pageSize: 100000, + })); + + facade.loadDashboardStats(); + + expect(facade.dashboardStats()?.recommendation.labelKey).toBe('adminProducts.recommendRestock'); + }); + + it('recommends nothing when the catalog has no issues', () => { + gateway.loadProducts.and.returnValue(of({ items: [product()], total: 1, page: 1, pageSize: 100000 })); + + facade.loadDashboardStats(); + + expect(facade.dashboardStats()?.recommendation.labelKey).toBe('adminProducts.recommendNone'); + }); + + it('counts published as visible and not archived, distinct from drafts', () => { + gateway.loadProducts.and.returnValue(of({ + items: [ + product({ id: 'a', visible: true, archived: false }), + product({ id: 'b', visible: false, archived: false }), + product({ id: 'c', visible: true, archived: true }), + ], + total: 3, page: 1, pageSize: 100000, + })); + + facade.loadDashboardStats(); + + expect(facade.dashboardStats()?.published).toBe(1); + expect(facade.dashboardStats()?.drafts).toBe(1); + }); + }); + + describe('saveDraft', () => { + it('calls createProduct in create mode', () => { + gateway.createProduct.and.returnValue(of(product())); + gateway.loadProducts.and.returnValue(of({ items: [], total: 0, page: 1, pageSize: 10 })); + facade.startCreate(); + + facade.saveDraft(); + + expect(gateway.createProduct).toHaveBeenCalled(); + expect(gateway.updateProduct).not.toHaveBeenCalled(); + }); + + it('calls updateProduct in edit mode', () => { + gateway.loadProduct.and.returnValue(of(product())); + gateway.updateProduct.and.returnValue(of(product())); + gateway.loadProducts.and.returnValue(of({ items: [], total: 0, page: 1, pageSize: 10 })); + facade.loadForEdit('p1', 'edit'); + + facade.saveDraft(); + + expect(gateway.updateProduct).toHaveBeenCalled(); + expect(gateway.createProduct).not.toHaveBeenCalled(); + }); + + it('does nothing when there is no draft loaded', () => { + facade.saveDraft(); + + expect(gateway.createProduct).not.toHaveBeenCalled(); + expect(gateway.updateProduct).not.toHaveBeenCalled(); + }); + + it('sets mutationError on failure rather than silently discarding the draft', () => { + gateway.createProduct.and.returnValue(throwError(() => new Error('x'))); + facade.startCreate(); + + facade.saveDraft(); + + expect(facade.mutationError()).toBe('common.errorDescription'); + }); + + it('calls onSuccess only after the save actually succeeds', () => { + gateway.createProduct.and.returnValue(of(product())); + gateway.loadProducts.and.returnValue(of({ items: [], total: 0, page: 1, pageSize: 10 })); + facade.startCreate(); + const onSuccess = jasmine.createSpy('onSuccess'); + + facade.saveDraft(onSuccess); + + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + describe('updateDraft', () => { + it('marks the facade dirty and applies the patch', () => { + gateway.loadProducts.and.returnValue(of({ items: [], total: 0, page: 1, pageSize: 10 })); + facade.startCreate(); + + facade.updateDraft({ name: 'Renamed' }); + + expect(facade.dirty()).toBeTrue(); + expect(facade.draft()?.name).toBe('Renamed'); + }); + + it('stamps updatedAt with a valid ISO timestamp', () => { + facade.startCreate(); + + facade.updateDraft({ name: 'Renamed' }); + + expect(new Date(facade.draft()!.updatedAt).toString()).not.toBe('Invalid Date'); + }); + }); + + describe('loadList error handling', () => { + it('clears the list and does not throw when the gateway errors', () => { + gateway.loadProducts.and.returnValue(throwError(() => new Error('x'))); + + expect(() => facade.loadList()).not.toThrow(); + }); + }); +}); diff --git a/src/app/features/admin/transactions/facade/admin-transactions.facade.spec.ts b/src/app/features/admin/transactions/facade/admin-transactions.facade.spec.ts new file mode 100644 index 0000000..c1b236e --- /dev/null +++ b/src/app/features/admin/transactions/facade/admin-transactions.facade.spec.ts @@ -0,0 +1,89 @@ +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { AdminTransactionsFacade } from './admin-transactions.facade'; +import { ADMIN_TRANSACTIONS_GATEWAY } from '../services/admin-transactions-gateway.token'; +import { AdminTransactionsGateway } from '../services/admin-transactions-gateway.interface'; +import { AdminTransaction } from '../models/admin-transaction.model'; + +function transaction(overrides: Partial = {}): AdminTransaction { + return { + id: 't1', + orderId: 'o1', + orderNumber: '1001', + type: 'payment', + method: 'card', + status: 'success', + amount: 1000, + currency: 'RUB', + fraudFlag: false, + audit: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + }; +} + +describe('AdminTransactionsFacade', () => { + let facade: AdminTransactionsFacade; + let gateway: jasmine.SpyObj; + + beforeEach(() => { + gateway = jasmine.createSpyObj('AdminTransactionsGateway', [ + 'loadTransactions', 'retryFailed', 'setFraudFlag', + ]); + TestBed.configureTestingModule({ providers: [{ provide: ADMIN_TRANSACTIONS_GATEWAY, useValue: gateway }] }); + facade = TestBed.inject(AdminTransactionsFacade); + }); + + it('loads transactions and total on success', () => { + gateway.loadTransactions.and.returnValue(of({ items: [transaction()], total: 1, page: 1, pageSize: 10 })); + + facade.loadList(); + + expect(facade.transactions().length).toBe(1); + expect(facade.total()).toBe(1); + }); + + it('clears the list and sets an error on failure', () => { + gateway.loadTransactions.and.returnValue(throwError(() => new Error('x'))); + + facade.loadList(); + + expect(facade.transactions()).toEqual([]); + expect(facade.error()).toBe('common.errorDescription'); + }); + + it('updateFilters resets page to 1 for a non-page change', () => { + gateway.loadTransactions.and.returnValue(of({ items: [], total: 0, page: 1, pageSize: 10 })); + + facade.updateFilters({ page: 3 }); + facade.updateFilters({ search: 'x' }); + + expect(facade.filters().page).toBe(1); + }); + + it('retryFailed reloads the list after the gateway call resolves', () => { + gateway.retryFailed.and.returnValue(of(transaction())); + gateway.loadTransactions.and.returnValue(of({ items: [], total: 0, page: 1, pageSize: 10 })); + + facade.retryFailed('t1'); + + expect(gateway.loadTransactions).toHaveBeenCalled(); + }); + + it('toggleFraudFlag sends the requested flag value, not its inverse', () => { + gateway.setFraudFlag.and.returnValue(of(transaction())); + gateway.loadTransactions.and.returnValue(of({ items: [], total: 0, page: 1, pageSize: 10 })); + + facade.toggleFraudFlag('t1', true); + + expect(gateway.setFraudFlag).toHaveBeenCalledWith('t1', true); + }); + + it('exportCsv emits one row per transaction plus a header', () => { + gateway.loadTransactions.and.returnValue(of({ items: [transaction(), transaction({ id: 't2' })], total: 2, page: 1, pageSize: 10 })); + facade.loadList(); + + expect(facade.exportCsv().split('\n').length).toBe(3); + }); +});