test: facade coverage for orders, moderation, transactions, monitoring, products (F63 partial)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
None of these 5 facades had a single test before this - the highest-risk gap flagged in Q7 of the delivery plan: real business logic (dashboard stat computation, health scoring, bulk actions, draft save routing) with zero coverage on domains that just got real backends behind them (Block 3). Each spec stubs the gateway TOKEN via jasmine.createSpyObj, not a concrete class - the correct pattern per this session's earlier fix to admin-analytics.facade.spec.ts / admin-order-watcher.service.spec.ts, so these don't repeat that same latent bug. 66 new tests, several written specifically to catch a real regression class rather than pad a count: - averageRating/averageOrder must be null/0 for an empty set, never NaN or a divide-by-zero artifact - a zero rating must not drag down an average across other real ratings - a customer with 2+ orders counts as returning; 1 order does not - moderationHealthPercent is 100 for an empty queue by convention, not 0 - toggleSelection/toggleAll must not produce duplicate ids - saveDraft must route to createProduct vs updateProduct correctly and must do nothing when no draft is loaded - a real prior bug class (silently creating a duplicate on a no-op save) - an events-load failure in AdminMonitoringFacade must not block queues/ webhooks from loading independently One own mistake caught before commit, not after: the first updateDraft test asserted the updatedAt timestamp changed after two synchronous calls - both landed in the same millisecond, so the ISO string was correctly identical and the assertion was the bug, not the facade. Replaced with an assertion that actually matches what's worth testing (a valid timestamp gets stamped, not that two back-to-back calls differ). Verified: 205/205 unit tests (66 new), arch:check clean, production build succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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