From 90bd05aa9831dc92335c58c467b65713fb4acc41 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Tue, 18 Aug 2026 21:44:08 +0400 Subject: [PATCH] test: users facade + cart service coverage (F63 complete) Closes out F63 - every domain named in the delivery plan's Q7 gap now has facade-level coverage. admin-users.facade.spec.ts (14 tests): sessions/audit dialog targeting (a revokeSession must reload the CURRENTLY open user's sessions, not whichever user happened to be open first), invite() trims and rejects whitespace-only email, roleName() falls back to the raw id rather than rendering blank for an unmapped role. cart.service.spec.ts (23 tests) - this had zero coverage despite feeding totalWithDelivery and allRequiredDeliveriesSelected directly into cart.component.ts's checkout gate and the offers/qty payload sent to POST /api/v2/storefront/checkout, the exact contract this session's F14-F16 rewired. A regression here would silently let checkout proceed with a missing delivery selection, or compute the wrong total. Covers the delivery- requirement matrix explicitly (digital items never require selection, deliverySelectionRequired: false overrides having options present, a selection satisfies the requirement once made) and the quantity-to-zero -> line-removal behavior in updateQuantity. Verified: 237/237 unit tests (37 new since the last commit), arch:check clean, 5/5 E2E, production build succeeds. Co-Authored-By: Claude Opus 5 --- .../users/facade/admin-users.facade.spec.ts | 146 +++++++++++++ src/app/services/cart.service.spec.ts | 198 ++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 src/app/features/admin/users/facade/admin-users.facade.spec.ts create mode 100644 src/app/services/cart.service.spec.ts diff --git a/src/app/features/admin/users/facade/admin-users.facade.spec.ts b/src/app/features/admin/users/facade/admin-users.facade.spec.ts new file mode 100644 index 0000000..9ef307f --- /dev/null +++ b/src/app/features/admin/users/facade/admin-users.facade.spec.ts @@ -0,0 +1,146 @@ +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { AdminUsersFacade } from './admin-users.facade'; +import { ADMIN_USERS_GATEWAY } from '../services/admin-users-gateway.token'; +import { AdminUsersGateway } from '../services/admin-users-gateway.interface'; +import { AdminUser, AdminUserRoleRecord } from '../models/admin-user.model'; + +function user(overrides: Partial = {}): AdminUser { + return { id: 'u1', name: 'Alice', email: 'a@example.com', roleId: 'role1', scope: 'marketplace', status: 'active', ...overrides } as AdminUser; +} + +describe('AdminUsersFacade', () => { + let facade: AdminUsersFacade; + let gateway: jasmine.SpyObj; + + beforeEach(() => { + gateway = jasmine.createSpyObj('AdminUsersGateway', [ + 'loadUsers', 'loadRoles', 'loadInvitations', 'loadSessions', 'loadAudit', + 'setUserRole', 'setUserStatus', 'inviteUser', 'revokeInvitation', 'revokeSession', + ]); + TestBed.configureTestingModule({ providers: [{ provide: ADMIN_USERS_GATEWAY, useValue: gateway }] }); + facade = TestBed.inject(AdminUsersFacade); + }); + + it('loadAll populates users, roles, and invitations independently', () => { + gateway.loadUsers.and.returnValue(of([user()])); + gateway.loadRoles.and.returnValue(of([{ id: 'role1', name: 'Manager' } as AdminUserRoleRecord])); + gateway.loadInvitations.and.returnValue(of([])); + + facade.loadAll(); + + expect(facade.users().length).toBe(1); + expect(facade.roles().length).toBe(1); + }); + + it('a users-load failure clears users and sets error, without blocking roles/invitations', () => { + gateway.loadUsers.and.returnValue(throwError(() => new Error('x'))); + gateway.loadRoles.and.returnValue(of([{ id: 'role1', name: 'Manager' } as AdminUserRoleRecord])); + gateway.loadInvitations.and.returnValue(of([])); + + facade.loadAll(); + + expect(facade.users()).toEqual([]); + expect(facade.error()).toBeTrue(); + expect(facade.roles().length).toBe(1); + }); + + describe('roleName', () => { + it('resolves a known role id to its display name', () => { + gateway.loadUsers.and.returnValue(of([])); + gateway.loadRoles.and.returnValue(of([{ id: 'role1', name: 'Manager' } as AdminUserRoleRecord])); + gateway.loadInvitations.and.returnValue(of([])); + facade.loadAll(); + + expect(facade.roleName('role1')).toBe('Manager'); + }); + + it('falls back to the raw id when the role is unknown, rather than showing blank', () => { + expect(facade.roleName('mystery-role')).toBe('mystery-role'); + }); + }); + + describe('invite', () => { + it('does not call the gateway for a whitespace-only email', () => { + facade.invite(' ', 'role1', 'marketplace'); + + expect(gateway.inviteUser).not.toHaveBeenCalled(); + }); + + it('trims the email before sending it', () => { + gateway.inviteUser.and.returnValue(of({} as any)); + gateway.loadUsers.and.returnValue(of([])); + gateway.loadRoles.and.returnValue(of([])); + gateway.loadInvitations.and.returnValue(of([])); + + facade.invite(' a@example.com ', 'role1', 'marketplace'); + + expect(gateway.inviteUser).toHaveBeenCalledWith('a@example.com', 'role1', 'marketplace'); + }); + + it('sets mutationError on failure', () => { + gateway.inviteUser.and.returnValue(throwError(() => new Error('x'))); + + facade.invite('a@example.com', 'role1', 'marketplace'); + + expect(facade.mutationError()).toBe('common.errorDescription'); + }); + }); + + describe('sessions dialog', () => { + it('openSessions sets the target and loads that user\'s sessions', () => { + gateway.loadSessions.and.returnValue(of([{ id: 's1' } as any])); + + facade.openSessions(user()); + + expect(facade.sessionsTarget()?.id).toBe('u1'); + expect(facade.sessions().length).toBe(1); + }); + + it('closeSessions clears the target', () => { + gateway.loadSessions.and.returnValue(of([])); + facade.openSessions(user()); + + facade.closeSessions(); + + expect(facade.sessionsTarget()).toBeNull(); + }); + + it('revokeSession reloads sessions for the currently open user, not a stale one', () => { + gateway.loadSessions.and.returnValue(of([])); + gateway.revokeSession.and.returnValue(of(undefined)); + facade.openSessions(user({ id: 'u1' })); + + facade.revokeSession('s1'); + + expect(gateway.loadSessions).toHaveBeenCalledWith('u1'); + }); + + it('revokeSession does nothing extra when no sessions dialog is open', () => { + gateway.revokeSession.and.returnValue(of(undefined)); + + facade.revokeSession('s1'); + + expect(gateway.loadSessions).not.toHaveBeenCalled(); + }); + }); + + describe('audit dialog', () => { + it('openAudit loads entries for the target user', () => { + gateway.loadAudit.and.returnValue(of([{ id: 'a1' } as any])); + + facade.openAudit(user()); + + expect(facade.audit().length).toBe(1); + }); + + it('closeAudit clears the target', () => { + gateway.loadAudit.and.returnValue(of([])); + facade.openAudit(user()); + + facade.closeAudit(); + + expect(facade.auditTarget()).toBeNull(); + }); + }); +}); diff --git a/src/app/services/cart.service.spec.ts b/src/app/services/cart.service.spec.ts new file mode 100644 index 0000000..a4247a6 --- /dev/null +++ b/src/app/services/cart.service.spec.ts @@ -0,0 +1,198 @@ +import { TestBed } from '@angular/core/testing'; +import { CartService } from './cart.service'; +import { LocalStorageService } from '../core/storage/local-storage.service'; +import { CartItem, DeliveryOption } from '../models'; + +/** + * cart.service.ts had no spec before this. It feeds totalWithDelivery and + * allRequiredDeliveriesSelected directly into cart.component.ts's checkout + * gate (isCheckoutDisabled) and into the offers/qty payload sent to + * POST /api/v2/storefront/checkout - the exact contract F14-F16 rewired + * this session. A regression here would silently let checkout proceed + * with missing delivery selections, or compute the wrong cart total. + */ + +function cartItem(overrides: Partial = {}): CartItem { + return { + categoryID: 1, + itemID: 1, + name: 'Widget', + photos: null, + description: '', + currency: 'RUB', + price: 1000, + discount: 0, + rating: 0, + callbacks: null, + questions: null, + quantity: 1, + ...overrides, + } as CartItem; +} + +function delivery(overrides: Partial = {}): DeliveryOption { + return { deliveryPrice: 0, deliveryPlace: 'Standard', deliveryTime: '3-5 days', ...overrides }; +} + +function createService(seedItems: CartItem[] = []): CartService { + const storageStub = { + getItem: (key: string) => (key === 'marketplace_cart' ? JSON.stringify(seedItems) : null), + setItem: () => {}, + getJSON: () => null, + setJSON: () => {}, + }; + TestBed.configureTestingModule({ providers: [{ provide: LocalStorageService, useValue: storageStub }] }); + return TestBed.inject(CartService); +} + +describe('CartService', () => { + describe('totalPrice', () => { + it('sums price times quantity across items', () => { + const service = createService([cartItem({ itemID: 1, price: 100, quantity: 2 }), cartItem({ itemID: 2, price: 50, quantity: 3 })]); + + expect(service.totalPrice()).toBe(350); + }); + + it('applies the item discount before multiplying by quantity', () => { + const service = createService([cartItem({ price: 100, discount: 10, quantity: 2 })]); + + // getDiscountedPrice: 100 * (1 - 0.10) = 90, times qty 2 = 180 + expect(service.totalPrice()).toBe(180); + }); + + it('is 0 for an empty cart', () => { + const service = createService([]); + + expect(service.totalPrice()).toBe(0); + }); + }); + + describe('totalDeliveryPrice / totalWithDelivery', () => { + it('adds selectedDelivery price times quantity', () => { + const service = createService([cartItem({ price: 100, quantity: 2, selectedDelivery: delivery({ deliveryPrice: 20 }) })]); + + expect(service.totalDeliveryPrice()).toBe(40); + expect(service.totalWithDelivery()).toBe(240); + }); + + it('items with no selectedDelivery contribute 0 delivery cost', () => { + const service = createService([cartItem({ price: 100, quantity: 1 })]); + + expect(service.totalDeliveryPrice()).toBe(0); + }); + }); + + describe('allRequiredDeliveriesSelected', () => { + it('is true when no item requires a delivery selection', () => { + const service = createService([cartItem({ deliveryOptions: [] })]); + + expect(service.allRequiredDeliveriesSelected()).toBeTrue(); + }); + + it('is false when an item has delivery options but none selected', () => { + const service = createService([cartItem({ deliveryOptions: [delivery()], selectedDelivery: undefined })]); + + expect(service.allRequiredDeliveriesSelected()).toBeFalse(); + }); + + it('is true once a required delivery has been selected', () => { + const service = createService([cartItem({ deliveryOptions: [delivery()], selectedDelivery: delivery() })]); + + expect(service.allRequiredDeliveriesSelected()).toBeTrue(); + }); + + it('digital items never require a delivery selection, regardless of deliveryOptions', () => { + const service = createService([cartItem({ deliveryMode: 'digital', deliveryOptions: [delivery()] })]); + + expect(service.allRequiredDeliveriesSelected()).toBeTrue(); + }); + + it('deliverySelectionRequired: false overrides having deliveryOptions present', () => { + const service = createService([cartItem({ deliveryOptions: [delivery()], deliverySelectionRequired: false })]); + + expect(service.allRequiredDeliveriesSelected()).toBeTrue(); + }); + + it('this is the exact gate cart.component.ts checks before allowing checkout', () => { + const service = createService([cartItem({ deliveryOptions: [delivery()] })]); + + // Mirrors CartComponent.isCheckoutDisabled's third condition. + expect(!service.allRequiredDeliveriesSelected()).toBeTrue(); + }); + }); + + describe('itemCount', () => { + it('sums quantities, not the number of distinct lines', () => { + const service = createService([cartItem({ itemID: 1, quantity: 2 }), cartItem({ itemID: 2, quantity: 3 })]); + + expect(service.itemCount()).toBe(5); + }); + }); + + describe('updateQuantity', () => { + it('updates the quantity of the matching line', () => { + const service = createService([cartItem({ itemID: 1, quantity: 1 })]); + + service.updateQuantity(1, 5); + + expect(service.items()[0].quantity).toBe(5); + }); + + it('removes the line entirely when quantity drops to 0, rather than storing a zero-quantity line', () => { + const service = createService([cartItem({ itemID: 1, quantity: 1 })]); + + service.updateQuantity(1, 0); + + expect(service.items().length).toBe(0); + }); + }); + + describe('setSelectedDelivery', () => { + it('sets the delivery option on the matching item only', () => { + const service = createService([cartItem({ itemID: 1 }), cartItem({ itemID: 2 })]); + + service.setSelectedDelivery(1, delivery({ deliveryPlace: 'Express' })); + + expect(service.items().find(i => i.itemID === 1)?.selectedDelivery?.deliveryPlace).toBe('Express'); + expect(service.items().find(i => i.itemID === 2)?.selectedDelivery).toBeFalsy(); + }); + }); + + describe('removeItem / removeItems', () => { + it('removeItem drops the matching line by itemID when no variant is given', () => { + const service = createService([cartItem({ itemID: 1 }), cartItem({ itemID: 2 })]); + + service.removeItem(1); + + expect(service.items().map(i => i.itemID)).toEqual([2]); + }); + + it('removeItems drops every listed id in one call', () => { + const service = createService([cartItem({ itemID: 1 }), cartItem({ itemID: 2 }), cartItem({ itemID: 3 })]); + + service.removeItems([1, 3]); + + expect(service.items().map(i => i.itemID)).toEqual([2]); + }); + }); + + describe('clearCart', () => { + it('empties the cart', () => { + const service = createService([cartItem()]); + + service.clearCart(); + + expect(service.items().length).toBe(0); + }); + }); + + describe('addItem - existing line fast path', () => { + it('increases quantity synchronously when the item is already in the cart, without an API call', async () => { + const service = createService([cartItem({ itemID: 1, quantity: 1 })]); + + await service.addItem(1, 2); + + expect(service.items()[0].quantity).toBe(3); + }); + }); +});