// src/pages/admin/components/AdminBrandManager.test.tsx import React from 'react'; import { screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import toast from 'react-hot-toast'; import { AdminBrandManager } from './AdminBrandManager'; import * as apiClient from '../../../services/apiClient'; import { createMockBrand } from '../../../tests/utils/mockFactories'; import { renderWithProviders } from '../../../tests/utils/renderWithProviders'; // After mocking, we can get a type-safe mocked version of the module. // This allows us to use .mockResolvedValue, .mockRejectedValue, etc. on the functions. // The apiClient is now mocked globally via src/tests/setup/tests-setup-unit.ts. const mockedApiClient = vi.mocked(apiClient); const mockedToast = vi.mocked(toast, true); const mockBrands = [ createMockBrand({ brand_id: 1, name: 'No Frills', store_name: 'No Frills', logo_url: null }), createMockBrand({ brand_id: 2, name: 'Compliments', store_name: 'Sobeys', logo_url: 'https://example.com/compliments.png', }), ]; describe('AdminBrandManager', () => { beforeEach(() => { vi.clearAllMocks(); }); it('should render a loading state initially', () => { console.log('TEST START: should render a loading state initially'); // Mock a promise that never resolves to keep the component in a loading state. console.log('TEST SETUP: Mocking fetchAllBrands with a non-resolving promise.'); mockedApiClient.fetchAllBrands.mockReturnValue(new Promise(() => {})); console.log('TEST ACTION: Rendering AdminBrandManager component.'); renderWithProviders(); console.log('TEST ASSERTION: Checking for the loading text.'); expect(screen.getByText('Loading brands...')).toBeInTheDocument(); console.log('TEST SUCCESS: Loading text is visible.'); console.log('TEST END: should render a loading state initially'); }); it('should render an error message if fetching brands fails', async () => { console.log('TEST START: should render an error message if fetching brands fails'); const errorMessage = 'Network Error'; console.log(`TEST SETUP: Mocking fetchAllBrands to reject with: ${errorMessage}`); mockedApiClient.fetchAllBrands.mockRejectedValue(new Error('Network Error')); console.log('TEST ACTION: Rendering AdminBrandManager component.'); renderWithProviders(); console.log('TEST ASSERTION: Waiting for error message to be displayed.'); await waitFor(() => { expect(screen.getByText('Failed to load brands: Network Error')).toBeInTheDocument(); console.log('TEST SUCCESS: Error message found in the document.'); }); console.log('TEST END: should render an error message if fetching brands fails'); }); it('should render the list of brands when data is fetched successfully', async () => { console.log('TEST START: should render the list of brands when data is fetched successfully'); // Use mockImplementation to return a new Response object on each call, // preventing "Body has already been read" errors. console.log('TEST SETUP: Mocking fetchAllBrands to resolve with mockBrands.'); mockedApiClient.fetchAllBrands.mockImplementation( async () => new Response(JSON.stringify(mockBrands), { status: 200 }), ); console.log('TEST ACTION: Rendering AdminBrandManager component.'); renderWithProviders(); console.log('TEST ASSERTION: Waiting for brand list to render.'); await waitFor(() => { expect(screen.getByRole('heading', { name: /brand management/i })).toBeInTheDocument(); expect(screen.getByText('No Frills')).toBeInTheDocument(); expect(screen.getByText('(Sobeys)')).toBeInTheDocument(); expect(screen.getByAltText('Compliments logo')).toBeInTheDocument(); expect(screen.getByText('No Logo')).toBeInTheDocument(); console.log('TEST SUCCESS: All brand elements found in the document.'); }); console.log('TEST END: should render the list of brands when data is fetched successfully'); }); it('should handle successful logo upload', async () => { console.log('TEST START: should handle successful logo upload'); console.log('TEST SETUP: Mocking fetchAllBrands and uploadBrandLogo for success.'); mockedApiClient.fetchAllBrands.mockImplementation( async () => new Response(JSON.stringify(mockBrands), { status: 200 }), ); mockedApiClient.uploadBrandLogo.mockImplementation( async () => new Response(JSON.stringify({ logoUrl: 'https://example.com/new-logo.png' }), { status: 200, }), ); mockedToast.loading.mockReturnValue('toast-1'); console.log('TEST ACTION: Rendering AdminBrandManager component.'); renderWithProviders(); console.log('TEST ACTION: Waiting for initial brands to render.'); await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument()); const file = new File(['logo'], 'logo.png', { type: 'image/png' }); // Use the new accessible label to find the correct input. const input = screen.getByLabelText('Upload logo for No Frills'); console.log('TEST ACTION: Firing file change event on input for "No Frills".'); fireEvent.change(input, { target: { files: [file] } }); console.log('TEST ASSERTION: Waiting for upload to complete and UI to update.'); await waitFor(() => { expect(mockedApiClient.uploadBrandLogo).toHaveBeenCalledWith(1, file); expect(mockedToast.loading).toHaveBeenCalledWith('Uploading logo...'); expect(mockedToast.success).toHaveBeenCalledWith('Logo updated successfully!', { id: 'toast-1', }); // Check if the UI updates with the new logo expect(screen.getByAltText('No Frills logo')).toHaveAttribute( 'src', 'https://example.com/new-logo.png', ); console.log('TEST SUCCESS: All assertions for successful upload passed.'); }); console.log('TEST END: should handle successful logo upload'); }); it('should handle failed logo upload with a non-Error object', async () => { console.log('TEST START: should handle failed logo upload with a non-Error object'); mockedApiClient.fetchAllBrands.mockImplementation( async () => new Response(JSON.stringify(mockBrands), { status: 200 }), ); // Reject with a string instead of an Error object to test the fallback error handling mockedApiClient.uploadBrandLogo.mockRejectedValue('A string error'); mockedToast.loading.mockReturnValue('toast-non-error'); renderWithProviders(); await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument()); const file = new File(['logo'], 'logo.png', { type: 'image/png' }); const input = screen.getByLabelText('Upload logo for No Frills'); fireEvent.change(input, { target: { files: [file] } }); await waitFor(() => { // This assertion verifies that the `String(e)` part of the catch block is executed. expect(mockedToast.error).toHaveBeenCalledWith('Upload failed: A string error', { id: 'toast-non-error', }); }); console.log('TEST END: should handle failed logo upload with a non-Error object'); }); it('should handle failed logo upload', async () => { console.log('TEST START: should handle failed logo upload'); console.log('TEST SETUP: Mocking fetchAllBrands for success and uploadBrandLogo for failure.'); mockedApiClient.fetchAllBrands.mockImplementation( async () => new Response(JSON.stringify(mockBrands), { status: 200 }), ); mockedApiClient.uploadBrandLogo.mockRejectedValue(new Error('Upload failed')); mockedToast.loading.mockReturnValue('toast-2'); console.log('TEST ACTION: Rendering AdminBrandManager component.'); renderWithProviders(); console.log('TEST ACTION: Waiting for initial brands to render.'); await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument()); const file = new File(['logo'], 'logo.png', { type: 'image/png' }); const input = screen.getByLabelText('Upload logo for No Frills'); console.log('TEST ACTION: Firing file change event on input for "No Frills".'); fireEvent.change(input, { target: { files: [file] } }); console.log('TEST ASSERTION: Waiting for error toast to be called.'); await waitFor(() => { expect(mockedToast.error).toHaveBeenCalledWith('Upload failed: Upload failed', { id: 'toast-2', }); console.log('TEST SUCCESS: Error toast was called with the correct message.'); }); console.log('TEST END: should handle failed logo upload'); }); it('should show an error toast for invalid file type', async () => { console.log('TEST START: should show an error toast for invalid file type'); console.log('TEST SETUP: Mocking fetchAllBrands to resolve successfully.'); mockedApiClient.fetchAllBrands.mockImplementation( async () => new Response(JSON.stringify(mockBrands), { status: 200 }), ); console.log('TEST ACTION: Rendering AdminBrandManager component.'); renderWithProviders(); console.log('TEST ACTION: Waiting for initial brands to render.'); await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument()); const file = new File(['text'], 'document.txt', { type: 'text/plain' }); const input = screen.getByLabelText('Upload logo for No Frills'); console.log('TEST ACTION: Firing file change event with invalid file type.'); fireEvent.change(input, { target: { files: [file] } }); console.log('TEST ASSERTION: Waiting for validation error toast.'); await waitFor(() => { expect(mockedToast.error).toHaveBeenCalledWith( 'Invalid file type. Please upload a PNG, JPG, WEBP, or SVG.', ); expect(mockedApiClient.uploadBrandLogo).not.toHaveBeenCalled(); console.log('TEST SUCCESS: Validation toast shown and upload API not called.'); }); console.log('TEST END: should show an error toast for invalid file type'); }); it('should show an error toast for oversized file', async () => { console.log('TEST START: should show an error toast for oversized file'); console.log('TEST SETUP: Mocking fetchAllBrands to resolve successfully.'); mockedApiClient.fetchAllBrands.mockImplementation( async () => new Response(JSON.stringify(mockBrands), { status: 200 }), ); console.log('TEST ACTION: Rendering AdminBrandManager component.'); renderWithProviders(); console.log('TEST ACTION: Waiting for initial brands to render.'); await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument()); const file = new File(['a'.repeat(3 * 1024 * 1024)], 'large.png', { type: 'image/png' }); const input = screen.getByLabelText('Upload logo for No Frills'); console.log('TEST ACTION: Firing file change event with oversized file.'); fireEvent.change(input, { target: { files: [file] } }); console.log('TEST ASSERTION: Waiting for size validation error toast.'); await waitFor(() => { expect(mockedToast.error).toHaveBeenCalledWith('File is too large. Maximum size is 2MB.'); expect(mockedApiClient.uploadBrandLogo).not.toHaveBeenCalled(); console.log('TEST SUCCESS: Size validation toast shown and upload API not called.'); }); console.log('TEST END: should show an error toast for oversized file'); }); it('should show an error toast if upload fails with a non-ok response', async () => { console.log('TEST START: should handle non-ok response from upload API'); mockedApiClient.fetchAllBrands.mockImplementation( async () => new Response(JSON.stringify(mockBrands), { status: 200 }), ); // Mock a failed response (e.g., 400 Bad Request) mockedApiClient.uploadBrandLogo.mockResolvedValue( new Response('Invalid image format', { status: 400 }), ); mockedToast.loading.mockReturnValue('toast-3'); renderWithProviders(); await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument()); const file = new File(['logo'], 'logo.png', { type: 'image/png' }); const input = screen.getByLabelText('Upload logo for No Frills'); fireEvent.change(input, { target: { files: [file] } }); await waitFor(() => { expect(mockedToast.error).toHaveBeenCalledWith('Upload failed: Invalid image format', { id: 'toast-3', }); console.log('TEST SUCCESS: Error toast shown for non-ok response.'); }); console.log('TEST END: should handle non-ok response from upload API'); }); it('should show an error toast if no file is selected', async () => { console.log('TEST START: should show an error toast if no file is selected'); console.log('TEST SETUP: Mocking fetchAllBrands to resolve successfully.'); mockedApiClient.fetchAllBrands.mockImplementation( async () => new Response(JSON.stringify(mockBrands), { status: 200 }), ); renderWithProviders(); console.log('TEST ACTION: Waiting for initial brands to render.'); await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument()); const input = screen.getByLabelText('Upload logo for No Frills'); // Simulate canceling the file picker by firing a change event with an empty file list. console.log('TEST ACTION: Firing file change event with an empty file list.'); fireEvent.change(input, { target: { files: [] } }); console.log('TEST ASSERTION: Waiting for the "no file selected" error toast.'); await waitFor(() => { expect(mockedToast.error).toHaveBeenCalledWith('Please select a file to upload.'); console.log('TEST SUCCESS: Error toast shown when no file is selected.'); }); console.log('TEST END: should show an error toast if no file is selected'); }); it('should render an empty table if no brands are found', async () => { mockedApiClient.fetchAllBrands.mockImplementation( async () => new Response(JSON.stringify([]), { status: 200 }), ); renderWithProviders(); await waitFor(() => { expect(screen.getByRole('heading', { name: /brand management/i })).toBeInTheDocument(); // Only the header row should be present expect(screen.getAllByRole('row')).toHaveLength(1); }); }); it('should use status code in error message if response body is empty on upload failure', async () => { mockedApiClient.fetchAllBrands.mockImplementation( async () => new Response(JSON.stringify(mockBrands), { status: 200 }), ); mockedApiClient.uploadBrandLogo.mockImplementation( async () => new Response(null, { status: 500, statusText: 'Internal Server Error' }), ); mockedToast.loading.mockReturnValue('toast-fallback'); renderWithProviders(); await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument()); const file = new File(['logo'], 'logo.png', { type: 'image/png' }); const input = screen.getByLabelText('Upload logo for No Frills'); fireEvent.change(input, { target: { files: [file] } }); await waitFor(() => { expect(mockedToast.error).toHaveBeenCalledWith( 'Upload failed: Upload failed with status 500', { id: 'toast-fallback' }, ); }); }); it('should only update the target brand logo and leave others unchanged', async () => { mockedApiClient.fetchAllBrands.mockImplementation( async () => new Response(JSON.stringify(mockBrands), { status: 200 }), ); mockedApiClient.uploadBrandLogo.mockImplementation( async () => new Response(JSON.stringify({ logoUrl: 'new-logo.png' }), { status: 200 }), ); mockedToast.loading.mockReturnValue('toast-opt'); renderWithProviders(); await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument()); // Brand 1: No Frills (initially null logo) // Brand 2: Compliments (initially has logo) const file = new File(['logo'], 'logo.png', { type: 'image/png' }); const input = screen.getByLabelText('Upload logo for No Frills'); // Brand 1 fireEvent.change(input, { target: { files: [file] } }); await waitFor(() => { // Brand 1 should have new logo expect(screen.getByAltText('No Frills logo')).toHaveAttribute('src', 'new-logo.png'); // Brand 2 should still have original logo expect(screen.getByAltText('Compliments logo')).toHaveAttribute( 'src', 'https://example.com/compliments.png', ); }); }); });