Merge branch 'B2B'
This commit is contained in:
@@ -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> = {}): 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<AdminModerationGateway>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
gateway = jasmine.createSpyObj<AdminModerationGateway>('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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<AdminMonitoringGateway>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
gateway = jasmine.createSpyObj<AdminMonitoringGateway>('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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
237
src/app/features/admin/orders/facade/admin-orders.facade.spec.ts
Normal file
237
src/app/features/admin/orders/facade/admin-orders.facade.spec.ts
Normal file
@@ -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> = {}): 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<AdminOrdersGateway>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
gateway = jasmine.createSpyObj<AdminOrdersGateway>('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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> = {}): 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<AdminProductsGateway>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
gateway = jasmine.createSpyObj<AdminProductsGateway>('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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> = {}): 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<AdminTransactionsGateway>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
gateway = jasmine.createSpyObj<AdminTransactionsGateway>('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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user