final ts cleanup?
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 11m31s
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 11m31s
This commit is contained in:
@@ -12,6 +12,11 @@ on:
|
||||
description: 'Type "deploy-to-prod" to confirm you want to deploy the main branch.'
|
||||
required: true
|
||||
default: 'do-not-run'
|
||||
force_reload:
|
||||
description: 'Force PM2 reload even if version matches (true/false).'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
deploy-production:
|
||||
@@ -97,18 +102,18 @@ jobs:
|
||||
DB_USER: ${{ secrets.DB_USER }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||
DB_NAME: ${{ secrets.DB_DATABASE_PROD }}
|
||||
REDIS_URL: "redis://localhost:6379"
|
||||
REDIS_URL: 'redis://localhost:6379'
|
||||
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_PROD }}
|
||||
FRONTEND_URL: "https://flyer-crawler.projectium.com"
|
||||
FRONTEND_URL: 'https://flyer-crawler.projectium.com'
|
||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY }}
|
||||
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
||||
SMTP_HOST: "localhost"
|
||||
SMTP_PORT: "1025"
|
||||
SMTP_SECURE: "false"
|
||||
SMTP_USER: ""
|
||||
SMTP_PASS: ""
|
||||
SMTP_FROM_EMAIL: "noreply@flyer-crawler.projectium.com"
|
||||
SMTP_HOST: 'localhost'
|
||||
SMTP_PORT: '1025'
|
||||
SMTP_SECURE: 'false'
|
||||
SMTP_USER: ''
|
||||
SMTP_PASS: ''
|
||||
SMTP_FROM_EMAIL: 'noreply@flyer-crawler.projectium.com'
|
||||
run: |
|
||||
if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_NAME" ]; then
|
||||
echo "ERROR: One or more production database secrets (DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE_PROD) are not set."
|
||||
@@ -117,8 +122,28 @@ jobs:
|
||||
echo "Installing production dependencies and restarting server..."
|
||||
cd /var/www/flyer-crawler.projectium.com
|
||||
npm install --omit=dev
|
||||
|
||||
# --- Version Check Logic ---
|
||||
# Get the version from the newly deployed package.json
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "Deployed Package Version: $NEW_VERSION"
|
||||
|
||||
# Get the running version from PM2 for the main API process
|
||||
# We use a small node script to parse the JSON output from pm2 jlist
|
||||
RUNNING_VERSION=$(pm2 jlist | node -e "try { const list = JSON.parse(require('fs').readFileSync(0, 'utf-8')); const app = list.find(p => p.name === 'flyer-crawler-api'); console.log(app ? app.pm2_env.version : ''); } catch(e) { console.log(''); }")
|
||||
echo "Running PM2 Version: $RUNNING_VERSION"
|
||||
|
||||
if [ "${{ gitea.event.inputs.force_reload }}" == "true" ] || [ "$NEW_VERSION" != "$RUNNING_VERSION" ] || [ -z "$RUNNING_VERSION" ]; then
|
||||
if [ "${{ gitea.event.inputs.force_reload }}" == "true" ]; then
|
||||
echo "Force reload triggered by manual input. Reloading PM2..."
|
||||
else
|
||||
echo "Version mismatch (Running: $RUNNING_VERSION -> Deployed: $NEW_VERSION) or app not running. Reloading PM2..."
|
||||
fi
|
||||
pm2 startOrReload ecosystem.config.cjs --env production && pm2 save
|
||||
echo "Production backend server reloaded successfully."
|
||||
else
|
||||
echo "Version $NEW_VERSION is already running. Skipping PM2 reload."
|
||||
fi
|
||||
|
||||
echo "Updating schema hash in production database..."
|
||||
CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }')
|
||||
|
||||
@@ -43,6 +43,19 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: npm ci # 'ci' is faster and safer for CI/CD than 'install'.
|
||||
|
||||
- name: Bump Version and Push
|
||||
run: |
|
||||
# Configure git for the commit.
|
||||
git config --global user.name 'Gitea Actions'
|
||||
git config --global user.email 'actions@gitea.projectium.com'
|
||||
|
||||
# Bump the patch version number. This creates a new commit and a new tag.
|
||||
# The commit message includes [skip ci] to prevent this push from triggering another workflow run.
|
||||
npm version patch -m "ci: Bump version to %s [skip ci]"
|
||||
|
||||
# Push the new commit and the new tag back to the main branch.
|
||||
git push --follow-tags
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
# --- NEW DEBUGGING STEPS ---
|
||||
@@ -85,15 +98,15 @@ jobs:
|
||||
DB_HOST: ${{ secrets.DB_HOST }}
|
||||
DB_USER: ${{ secrets.DB_USER }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||
DB_NAME: "flyer-crawler-test" # Explicitly set for tests
|
||||
DB_NAME: 'flyer-crawler-test' # Explicitly set for tests
|
||||
|
||||
# --- Redis credentials for the test suite ---
|
||||
REDIS_URL: "redis://localhost:6379"
|
||||
REDIS_URL: 'redis://localhost:6379'
|
||||
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }}
|
||||
|
||||
# --- Integration test specific variables ---
|
||||
FRONTEND_URL: "http://localhost:3000"
|
||||
VITE_API_BASE_URL: "http://localhost:3001/api"
|
||||
FRONTEND_URL: 'http://localhost:3000'
|
||||
VITE_API_BASE_URL: 'http://localhost:3001/api'
|
||||
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY }}
|
||||
|
||||
# --- JWT Secret for Passport authentication in tests ---
|
||||
@@ -101,7 +114,7 @@ jobs:
|
||||
|
||||
# --- Increase Node.js memory limit to prevent heap out of memory errors ---
|
||||
# This is crucial for memory-intensive tasks like running tests and coverage.
|
||||
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
|
||||
run: |
|
||||
# Fail-fast check to ensure secrets are configured in Gitea for testing.
|
||||
@@ -308,23 +321,22 @@ jobs:
|
||||
DB_NAME: ${{ secrets.DB_DATABASE_TEST }}
|
||||
|
||||
# Redis Credentials
|
||||
REDIS_URL: "redis://localhost:6379"
|
||||
REDIS_URL: 'redis://localhost:6379'
|
||||
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }}
|
||||
|
||||
# Application Secrets
|
||||
FRONTEND_URL: "https://flyer-crawler-test.projectium.com"
|
||||
FRONTEND_URL: 'https://flyer-crawler-test.projectium.com'
|
||||
JWT_SECRET: ${{ secrets.JWT_SECRET_TEST }}
|
||||
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY_TEST }}
|
||||
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
||||
|
||||
# SMTP (email)
|
||||
SMTP_HOST: "localhost"
|
||||
SMTP_PORT: "1025"
|
||||
SMTP_SECURE: "false"
|
||||
SMTP_USER: "" # Using MailHog, no auth needed
|
||||
SMTP_PASS: "" # Using MailHog, no auth needed
|
||||
SMTP_FROM_EMAIL: "noreply@flyer-crawler-test.projectium.com"
|
||||
|
||||
SMTP_HOST: 'localhost'
|
||||
SMTP_PORT: '1025'
|
||||
SMTP_SECURE: 'false'
|
||||
SMTP_USER: '' # Using MailHog, no auth needed
|
||||
SMTP_PASS: '' # Using MailHog, no auth needed
|
||||
SMTP_FROM_EMAIL: 'noreply@flyer-crawler-test.projectium.com'
|
||||
|
||||
run: |
|
||||
# Fail-fast check to ensure secrets are configured in Gitea.
|
||||
|
||||
29
server.ts
29
server.ts
@@ -26,7 +26,12 @@ import healthRouter from './src/routes/health.routes';
|
||||
import { errorHandler } from './src/middleware/errorHandler';
|
||||
import { backgroundJobService, startBackgroundJobs } from './src/services/backgroundJobService';
|
||||
import type { UserProfile } from './src/types';
|
||||
import { analyticsQueue, weeklyAnalyticsQueue, gracefulShutdown, tokenCleanupQueue } from './src/services/queueService.server';
|
||||
import {
|
||||
analyticsQueue,
|
||||
weeklyAnalyticsQueue,
|
||||
gracefulShutdown,
|
||||
tokenCleanupQueue,
|
||||
} from './src/services/queueService.server';
|
||||
|
||||
// --- START DEBUG LOGGING ---
|
||||
// Log the database connection details as seen by the SERVER PROCESS.
|
||||
@@ -41,12 +46,15 @@ logger.info(` Database: ${process.env.DB_NAME}`);
|
||||
// Query the users table to see what the server process sees on startup.
|
||||
// Corrected the query to be unambiguous by specifying the table alias for each column.
|
||||
// `id` and `email` come from the `users` table (u), and `role` comes from the `profiles` table (p).
|
||||
getPool().query('SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id')
|
||||
.then(res => {
|
||||
getPool()
|
||||
.query(
|
||||
'SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id',
|
||||
)
|
||||
.then((res) => {
|
||||
logger.debug('[SERVER PROCESS] Users found in DB on startup:');
|
||||
console.table(res.rows);
|
||||
})
|
||||
.catch(err => {
|
||||
.catch((err) => {
|
||||
logger.error({ err }, '[SERVER PROCESS] Could not query users table on startup.');
|
||||
});
|
||||
|
||||
@@ -105,7 +113,7 @@ const requestLogger = (req: Request, res: Response, next: NextFunction) => {
|
||||
// This attaches contextual info to every log message generated for this request.
|
||||
req.log = logger.child({
|
||||
request_id: requestId,
|
||||
user_id: user?.user_id, // This will be undefined until the auth middleware runs, but the logger will hold the reference.
|
||||
user_id: user?.user.user_id, // This will be undefined until the auth middleware runs, but the logger will hold the reference.
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
@@ -118,7 +126,7 @@ const requestLogger = (req: Request, res: Response, next: NextFunction) => {
|
||||
|
||||
// The base log object includes details relevant for all status codes.
|
||||
const logDetails: RequestLogDetails = {
|
||||
user_id: finalUser?.user_id,
|
||||
user_id: finalUser?.user.user_id,
|
||||
method,
|
||||
originalUrl,
|
||||
statusCode,
|
||||
@@ -197,13 +205,18 @@ if (process.env.NODE_ENV !== 'test') {
|
||||
});
|
||||
|
||||
// Start the scheduled background jobs
|
||||
startBackgroundJobs(backgroundJobService, analyticsQueue, weeklyAnalyticsQueue, tokenCleanupQueue, logger);
|
||||
startBackgroundJobs(
|
||||
backgroundJobService,
|
||||
analyticsQueue,
|
||||
weeklyAnalyticsQueue,
|
||||
tokenCleanupQueue,
|
||||
logger,
|
||||
);
|
||||
|
||||
// --- Graceful Shutdown Handling ---
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
}
|
||||
|
||||
|
||||
// Export the app for integration testing
|
||||
export default app;
|
||||
87
src/App.tsx
87
src/App.tsx
@@ -30,7 +30,10 @@ import { HomePage } from './pages/HomePage';
|
||||
// This is crucial for allowing pdf.js to process PDFs in a separate thread, preventing the UI from freezing.
|
||||
// We need to explicitly tell pdf.js where to load its worker script from.
|
||||
// By importing pdfjs-dist, we can host the worker locally, which is more reliable.
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.mjs', import.meta.url).toString();
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.mjs',
|
||||
import.meta.url,
|
||||
).toString();
|
||||
|
||||
function App() {
|
||||
const { userProfile, authStatus, login, logout, updateProfile } = useAuth();
|
||||
@@ -47,10 +50,10 @@ function App() {
|
||||
console.log('[App] Render:', {
|
||||
flyersCount: flyers.length,
|
||||
selectedFlyerId: selectedFlyer?.flyer_id,
|
||||
paramsFlyerId: params?.flyerId,
|
||||
authStatus: authStatus,
|
||||
profileId: userProfile?.user_id,
|
||||
locationSearch: location.search
|
||||
paramsFlyerId: params?.flyerId, // This was a duplicate, fixed.
|
||||
authStatus,
|
||||
profileId: userProfile?.user.user_id,
|
||||
locationSearch: location.search,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -72,7 +75,8 @@ function App() {
|
||||
const handleOpenCorrectionTool = useCallback(() => openModal('correctionTool'), [openModal]);
|
||||
const handleCloseCorrectionTool = useCallback(() => closeModal('correctionTool'), [closeModal]);
|
||||
|
||||
const handleDataExtractedFromCorrection = useCallback((type: 'store_name' | 'dates', value: string) => {
|
||||
const handleDataExtractedFromCorrection = useCallback(
|
||||
(type: 'store_name' | 'dates', value: string) => {
|
||||
if (!selectedFlyer) return;
|
||||
|
||||
// This is a simplified update. A real implementation would involve
|
||||
@@ -85,20 +89,26 @@ function App() {
|
||||
// A more robust solution would parse the date string properly.
|
||||
}
|
||||
setSelectedFlyer(updatedFlyer);
|
||||
}, [selectedFlyer]);
|
||||
},
|
||||
[selectedFlyer],
|
||||
);
|
||||
|
||||
const handleProfileUpdate = useCallback((updatedProfileData: Profile) => {
|
||||
const handleProfileUpdate = useCallback(
|
||||
(updatedProfileData: Profile) => {
|
||||
// When the profile is updated, the API returns a `Profile` object.
|
||||
// We need to merge it with the existing `user` object to maintain
|
||||
// the `UserProfile` type in our state.
|
||||
updateProfile(updatedProfileData);
|
||||
}, [updateProfile]);
|
||||
},
|
||||
[updateProfile],
|
||||
);
|
||||
|
||||
// --- State Synchronization and Error Handling ---
|
||||
|
||||
// Effect to set initial theme based on user profile, local storage, or system preference
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'test') console.log('[App] Effect: Theme Update', { profileId: userProfile?.user_id });
|
||||
if (process.env.NODE_ENV === 'test')
|
||||
console.log('[App] Effect: Theme Update', { profileId: userProfile?.user.user_id });
|
||||
if (userProfile && userProfile.preferences?.darkMode !== undefined) {
|
||||
// Preference from DB
|
||||
const dbDarkMode = userProfile.preferences.darkMode;
|
||||
@@ -112,7 +122,7 @@ function App() {
|
||||
setIsDarkMode(initialDarkMode);
|
||||
document.documentElement.classList.toggle('dark', initialDarkMode);
|
||||
}
|
||||
}, [userProfile?.preferences?.darkMode, userProfile?.user_id]);
|
||||
}, [userProfile?.preferences?.darkMode, userProfile?.user.user_id]);
|
||||
|
||||
// Effect to set initial unit system based on user profile or local storage
|
||||
useEffect(() => {
|
||||
@@ -124,10 +134,11 @@ function App() {
|
||||
setUnitSystem(savedSystem);
|
||||
}
|
||||
}
|
||||
}, [userProfile?.preferences?.unitSystem, userProfile?.user_id]);
|
||||
}, [userProfile?.preferences?.unitSystem, userProfile?.user.user_id]);
|
||||
|
||||
// This is the login handler that will be passed to the ProfileManager component.
|
||||
const handleLoginSuccess = useCallback(async (userProfile: UserProfile, token: string, _rememberMe: boolean) => {
|
||||
const handleLoginSuccess = useCallback(
|
||||
async (userProfile: UserProfile, token: string, _rememberMe: boolean) => {
|
||||
try {
|
||||
await login(token, userProfile);
|
||||
// After successful login, fetch user-specific data
|
||||
@@ -138,7 +149,9 @@ function App() {
|
||||
// and notifications, so we just need to log any unexpected failures here.
|
||||
logger.error({ err: e }, 'An error occurred during the login success handling.');
|
||||
}
|
||||
}, [login]);
|
||||
},
|
||||
[login],
|
||||
);
|
||||
|
||||
// Effect to handle the token from Google OAuth redirect
|
||||
useEffect(() => {
|
||||
@@ -149,8 +162,9 @@ function App() {
|
||||
logger.info('Received Google Auth token from URL. Authenticating...');
|
||||
// The login flow is now handled by the useAuth hook. We just need to trigger it.
|
||||
// We pass only the token; the AuthProvider will fetch the user profile.
|
||||
login(googleToken)
|
||||
.catch(err => logger.error('Failed to log in with Google token', { error: err }));
|
||||
login(googleToken).catch((err) =>
|
||||
logger.error('Failed to log in with Google token', { error: err }),
|
||||
);
|
||||
// Clean the token from the URL
|
||||
navigate(location.pathname, { replace: true });
|
||||
}
|
||||
@@ -158,8 +172,7 @@ function App() {
|
||||
const githubToken = urlParams.get('githubAuthToken');
|
||||
if (githubToken) {
|
||||
logger.info('Received GitHub Auth token from URL. Authenticating...');
|
||||
login(githubToken)
|
||||
.catch(err => {
|
||||
login(githubToken).catch((err) => {
|
||||
logger.error('Failed to log in with GitHub token', { error: err });
|
||||
// Optionally, redirect to a page with an error message
|
||||
// navigate('/login?error=github_auth_failed');
|
||||
@@ -170,7 +183,6 @@ function App() {
|
||||
}
|
||||
}, [login, location.search, navigate, location.pathname]);
|
||||
|
||||
|
||||
const handleFlyerSelect = useCallback(async (flyer: Flyer) => {
|
||||
setSelectedFlyer(flyer);
|
||||
}, []);
|
||||
@@ -188,11 +200,11 @@ function App() {
|
||||
|
||||
if (flyerIdFromUrl && flyers.length > 0) {
|
||||
const flyerId = parseInt(flyerIdFromUrl, 10);
|
||||
const flyerToSelect = flyers.find(f => f.flyer_id === flyerId);
|
||||
const flyerToSelect = flyers.find((f) => f.flyer_id === flyerId);
|
||||
if (flyerToSelect && flyerToSelect.flyer_id !== selectedFlyer?.flyer_id) {
|
||||
handleFlyerSelect(flyerToSelect);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [flyers, handleFlyerSelect, selectedFlyer, params.flyerId]);
|
||||
|
||||
// Read the application version injected at build time.
|
||||
@@ -223,7 +235,6 @@ function App() {
|
||||
}
|
||||
`}</style>
|
||||
|
||||
|
||||
<Header
|
||||
isDarkMode={isDarkMode}
|
||||
unitSystem={unitSystem}
|
||||
@@ -269,19 +280,35 @@ function App() {
|
||||
)}
|
||||
|
||||
<Routes>
|
||||
<Route element={
|
||||
<Route
|
||||
element={
|
||||
<MainLayout
|
||||
onFlyerSelect={handleFlyerSelect}
|
||||
selectedFlyerId={selectedFlyer?.flyer_id || null}
|
||||
onOpenProfile={handleOpenProfile}
|
||||
/>
|
||||
}>
|
||||
<Route index element={
|
||||
<HomePage selectedFlyer={selectedFlyer} flyerItems={flyerItems} onOpenCorrectionTool={handleOpenCorrectionTool} />
|
||||
} />
|
||||
<Route path="/flyers/:flyerId" element={
|
||||
<HomePage selectedFlyer={selectedFlyer} flyerItems={flyerItems} onOpenCorrectionTool={handleOpenCorrectionTool} />
|
||||
} />
|
||||
}
|
||||
>
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<HomePage
|
||||
selectedFlyer={selectedFlyer}
|
||||
flyerItems={flyerItems}
|
||||
onOpenCorrectionTool={handleOpenCorrectionTool}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/flyers/:flyerId"
|
||||
element={
|
||||
<HomePage
|
||||
selectedFlyer={selectedFlyer}
|
||||
flyerItems={flyerItems}
|
||||
onOpenCorrectionTool={handleOpenCorrectionTool}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* Admin Routes */}
|
||||
|
||||
@@ -66,24 +66,48 @@ const mockedToast = toast as Mocked<typeof toast>;
|
||||
|
||||
describe('FlyerList', () => {
|
||||
const mockOnFlyerSelect = vi.fn();
|
||||
const mockProfile: UserProfile = createMockUserProfile({ user_id: '1', role: 'user' });
|
||||
const mockProfile: UserProfile = createMockUserProfile({
|
||||
user: { user_id: '1', email: 'test@example.com' },
|
||||
role: 'user',
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render the heading', () => {
|
||||
render(<FlyerList flyers={[]} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={null} />);
|
||||
render(
|
||||
<FlyerList
|
||||
flyers={[]}
|
||||
onFlyerSelect={mockOnFlyerSelect}
|
||||
selectedFlyerId={null}
|
||||
profile={null}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('heading', { name: /processed flyers/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display a message when there are no flyers', () => {
|
||||
render(<FlyerList flyers={[]} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={null} />);
|
||||
render(
|
||||
<FlyerList
|
||||
flyers={[]}
|
||||
onFlyerSelect={mockOnFlyerSelect}
|
||||
selectedFlyerId={null}
|
||||
profile={null}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/no flyers have been processed yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render a list of flyers with correct details', () => {
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={mockProfile} />);
|
||||
render(
|
||||
<FlyerList
|
||||
flyers={mockFlyers}
|
||||
onFlyerSelect={mockOnFlyerSelect}
|
||||
selectedFlyerId={null}
|
||||
profile={mockProfile}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check first flyer
|
||||
expect(screen.getByText('Metro')).toBeInTheDocument();
|
||||
@@ -96,7 +120,14 @@ describe('FlyerList', () => {
|
||||
});
|
||||
|
||||
it('should call onFlyerSelect with the correct flyer when an item is clicked', () => {
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={mockProfile} />);
|
||||
render(
|
||||
<FlyerList
|
||||
flyers={mockFlyers}
|
||||
onFlyerSelect={mockOnFlyerSelect}
|
||||
selectedFlyerId={null}
|
||||
profile={mockProfile}
|
||||
/>,
|
||||
);
|
||||
|
||||
const firstFlyerItem = screen.getByText('Metro').closest('li');
|
||||
fireEvent.click(firstFlyerItem!);
|
||||
@@ -106,7 +137,14 @@ describe('FlyerList', () => {
|
||||
});
|
||||
|
||||
it('should apply a selected style to the currently selected flyer', () => {
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={1} profile={mockProfile} />);
|
||||
render(
|
||||
<FlyerList
|
||||
flyers={mockFlyers}
|
||||
onFlyerSelect={mockOnFlyerSelect}
|
||||
selectedFlyerId={1}
|
||||
profile={mockProfile}
|
||||
/>,
|
||||
);
|
||||
|
||||
const selectedItem = screen.getByText('Metro').closest('li');
|
||||
expect(selectedItem).toHaveClass('bg-brand-light', 'dark:bg-brand-dark/30');
|
||||
@@ -114,7 +152,14 @@ describe('FlyerList', () => {
|
||||
|
||||
describe('UI Details and Edge Cases', () => {
|
||||
it('should render an image icon when icon_url is present', () => {
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={mockProfile} />);
|
||||
render(
|
||||
<FlyerList
|
||||
flyers={mockFlyers}
|
||||
onFlyerSelect={mockOnFlyerSelect}
|
||||
selectedFlyerId={null}
|
||||
profile={mockProfile}
|
||||
/>,
|
||||
);
|
||||
const flyerWithIcon = screen.getByText('Unknown Store').closest('li'); // Flyer ID 3
|
||||
const iconImage = flyerWithIcon?.querySelector('img');
|
||||
expect(iconImage).toBeInTheDocument();
|
||||
@@ -122,7 +167,14 @@ describe('FlyerList', () => {
|
||||
});
|
||||
|
||||
it('should render a document icon when icon_url is not present', () => {
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={mockProfile} />);
|
||||
render(
|
||||
<FlyerList
|
||||
flyers={mockFlyers}
|
||||
onFlyerSelect={mockOnFlyerSelect}
|
||||
selectedFlyerId={null}
|
||||
profile={mockProfile}
|
||||
/>,
|
||||
);
|
||||
const flyerWithoutIcon = screen.getByText('Walmart').closest('li'); // Flyer ID 2
|
||||
const iconImage = flyerWithoutIcon?.querySelector('img');
|
||||
const documentIcon = flyerWithoutIcon?.querySelector('svg');
|
||||
@@ -131,16 +183,33 @@ describe('FlyerList', () => {
|
||||
});
|
||||
|
||||
it('should render "Unknown Store" if store data is missing', () => {
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={mockProfile} />);
|
||||
render(
|
||||
<FlyerList
|
||||
flyers={mockFlyers}
|
||||
onFlyerSelect={mockOnFlyerSelect}
|
||||
selectedFlyerId={null}
|
||||
profile={mockProfile}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Unknown Store')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render a map link if store_address is present and stop propagation on click', () => {
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={mockProfile} />);
|
||||
render(
|
||||
<FlyerList
|
||||
flyers={mockFlyers}
|
||||
onFlyerSelect={mockOnFlyerSelect}
|
||||
selectedFlyerId={null}
|
||||
profile={mockProfile}
|
||||
/>,
|
||||
);
|
||||
const flyerWithAddress = screen.getByText('Unknown Store').closest('li');
|
||||
const mapLink = flyerWithAddress?.querySelector('a');
|
||||
expect(mapLink).toBeInTheDocument();
|
||||
expect(mapLink).toHaveAttribute('href', 'https://www.google.com/maps/search/?api=1&query=456%20Side%20St%2C%20Ottawa');
|
||||
expect(mapLink).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.google.com/maps/search/?api=1&query=456%20Side%20St%2C%20Ottawa',
|
||||
);
|
||||
|
||||
// Test that clicking the map link does not select the flyer
|
||||
fireEvent.click(mapLink!);
|
||||
@@ -148,7 +217,14 @@ describe('FlyerList', () => {
|
||||
});
|
||||
|
||||
it('should render a detailed tooltip', () => {
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={mockProfile} />);
|
||||
render(
|
||||
<FlyerList
|
||||
flyers={mockFlyers}
|
||||
onFlyerSelect={mockOnFlyerSelect}
|
||||
selectedFlyerId={null}
|
||||
profile={mockProfile}
|
||||
/>,
|
||||
);
|
||||
const firstFlyerItem = screen.getByText('Metro').closest('li');
|
||||
const tooltipText = firstFlyerItem?.getAttribute('title');
|
||||
expect(tooltipText).toContain('File: metro_flyer_oct_1.pdf');
|
||||
@@ -160,7 +236,14 @@ describe('FlyerList', () => {
|
||||
});
|
||||
|
||||
it('should handle invalid dates gracefully in display and tooltip', () => {
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={mockProfile} />);
|
||||
render(
|
||||
<FlyerList
|
||||
flyers={mockFlyers}
|
||||
onFlyerSelect={mockOnFlyerSelect}
|
||||
selectedFlyerId={null}
|
||||
profile={mockProfile}
|
||||
/>,
|
||||
);
|
||||
const badDateItem = screen.getByText('Date Store').closest('li');
|
||||
|
||||
// Display should not show "Valid:" text if dates are invalid
|
||||
@@ -175,15 +258,32 @@ describe('FlyerList', () => {
|
||||
});
|
||||
|
||||
describe('Admin Functionality', () => {
|
||||
const adminProfile: UserProfile = createMockUserProfile({ user_id: 'admin-1', role: 'admin' });
|
||||
const adminProfile: UserProfile = createMockUserProfile({
|
||||
user: { user_id: 'admin-1', email: 'admin@example.com' },
|
||||
role: 'admin',
|
||||
});
|
||||
|
||||
it('should not show the cleanup button for non-admin users', () => {
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={mockProfile} />);
|
||||
render(
|
||||
<FlyerList
|
||||
flyers={mockFlyers}
|
||||
onFlyerSelect={mockOnFlyerSelect}
|
||||
selectedFlyerId={null}
|
||||
profile={mockProfile}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTitle(/clean up files/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show the cleanup button for admin users', () => {
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={adminProfile} />);
|
||||
render(
|
||||
<FlyerList
|
||||
flyers={mockFlyers}
|
||||
onFlyerSelect={mockOnFlyerSelect}
|
||||
selectedFlyerId={null}
|
||||
profile={adminProfile}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTitle('Clean up files for flyer ID 1')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Clean up files for flyer ID 2')).toBeInTheDocument();
|
||||
});
|
||||
@@ -192,12 +292,21 @@ describe('FlyerList', () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
mockedApiClient.cleanupFlyerFiles.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={adminProfile} />);
|
||||
render(
|
||||
<FlyerList
|
||||
flyers={mockFlyers}
|
||||
onFlyerSelect={mockOnFlyerSelect}
|
||||
selectedFlyerId={null}
|
||||
profile={adminProfile}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cleanupButton = screen.getByTitle('Clean up files for flyer ID 1');
|
||||
fireEvent.click(cleanupButton);
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to clean up the files for flyer ID 1? This action cannot be undone.');
|
||||
expect(confirmSpy).toHaveBeenCalledWith(
|
||||
'Are you sure you want to clean up the files for flyer ID 1? This action cannot be undone.',
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(mockedApiClient.cleanupFlyerFiles).toHaveBeenCalledWith(1);
|
||||
});
|
||||
@@ -206,7 +315,14 @@ describe('FlyerList', () => {
|
||||
it('should not call cleanupFlyerFiles when admin clicks and cancels', () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={adminProfile} />);
|
||||
render(
|
||||
<FlyerList
|
||||
flyers={mockFlyers}
|
||||
onFlyerSelect={mockOnFlyerSelect}
|
||||
selectedFlyerId={null}
|
||||
profile={adminProfile}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cleanupButton = screen.getByTitle('Clean up files for flyer ID 1');
|
||||
fireEvent.click(cleanupButton);
|
||||
@@ -220,7 +336,14 @@ describe('FlyerList', () => {
|
||||
const apiError = new Error('Cleanup failed');
|
||||
mockedApiClient.cleanupFlyerFiles.mockRejectedValue(apiError);
|
||||
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={adminProfile} />);
|
||||
render(
|
||||
<FlyerList
|
||||
flyers={mockFlyers}
|
||||
onFlyerSelect={mockOnFlyerSelect}
|
||||
selectedFlyerId={null}
|
||||
profile={adminProfile}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cleanupButton = screen.getByTitle('Clean up files for flyer ID 1');
|
||||
fireEvent.click(cleanupButton);
|
||||
|
||||
@@ -69,7 +69,10 @@ describe('UserService', () => {
|
||||
it('should create a new address and link it to a user who has no address', async () => {
|
||||
const { logger } = await import('./logger.server');
|
||||
// Arrange: A user profile without an existing address_id.
|
||||
const user = createMockUserProfile({ user_id: 'user-123', address_id: null });
|
||||
const user = createMockUserProfile({
|
||||
user: { user_id: 'user-123', email: 'test@example.com' },
|
||||
address_id: null,
|
||||
});
|
||||
const addressData: Partial<Address> = { address_line_1: '123 New St', city: 'Newville' };
|
||||
|
||||
// Mock the address repository to return a new address ID.
|
||||
@@ -84,21 +87,34 @@ describe('UserService', () => {
|
||||
// 1. Verify the transaction helper was called.
|
||||
expect(mocks.mockWithTransaction).toHaveBeenCalledTimes(1);
|
||||
// 2. Verify the address was upserted with the correct data.
|
||||
expect(mocks.mockUpsertAddress).toHaveBeenCalledWith({
|
||||
expect(mocks.mockUpsertAddress).toHaveBeenCalledWith(
|
||||
{
|
||||
...addressData,
|
||||
address_id: undefined, // user.address_id was null, so it should be undefined.
|
||||
}, logger);
|
||||
},
|
||||
logger,
|
||||
);
|
||||
// 3. Verify the user's profile was updated to link the new address ID.
|
||||
expect(mocks.mockUpdateUserProfile).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.mockUpdateUserProfile).toHaveBeenCalledWith('user-123', { address_id: newAddressId }, logger);
|
||||
expect(mocks.mockUpdateUserProfile).toHaveBeenCalledWith(
|
||||
user.user.user_id,
|
||||
{ address_id: newAddressId },
|
||||
logger,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update an existing address and NOT link it if the ID does not change', async () => {
|
||||
const { logger } = await import('./logger.server');
|
||||
// Arrange: A user profile with an existing address_id.
|
||||
const existingAddressId = 42;
|
||||
const user = createMockUserProfile({ user_id: 'user-123', address_id: existingAddressId });
|
||||
const addressData: Partial<Address> = { address_line_1: '123 Updated St', city: 'Updateville' };
|
||||
const user = createMockUserProfile({
|
||||
user: { user_id: 'user-123', email: 'test@example.com' },
|
||||
address_id: existingAddressId,
|
||||
});
|
||||
const addressData: Partial<Address> = {
|
||||
address_line_1: '123 Updated St',
|
||||
city: 'Updateville',
|
||||
};
|
||||
|
||||
// Mock the address repository to return the SAME address ID.
|
||||
mocks.mockUpsertAddress.mockResolvedValue(existingAddressId);
|
||||
@@ -111,10 +127,13 @@ describe('UserService', () => {
|
||||
// 1. Verify the transaction helper was called.
|
||||
expect(mocks.mockWithTransaction).toHaveBeenCalledTimes(1);
|
||||
// 2. Verify the address was upserted with the existing ID.
|
||||
expect(mocks.mockUpsertAddress).toHaveBeenCalledWith({
|
||||
expect(mocks.mockUpsertAddress).toHaveBeenCalledWith(
|
||||
{
|
||||
...addressData,
|
||||
address_id: existingAddressId,
|
||||
}, logger);
|
||||
},
|
||||
logger,
|
||||
);
|
||||
// 3. Since the address ID did not change, the user profile should NOT be updated.
|
||||
expect(mocks.mockUpdateUserProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -22,51 +22,110 @@ export const MockHeader: React.FC<HeaderProps> = (props) => (
|
||||
</header>
|
||||
);
|
||||
|
||||
export const MockProfileManager: React.FC<Partial<ProfileManagerProps>> = ({ isOpen, onClose, onProfileUpdate, onLoginSuccess }) => {
|
||||
export const MockProfileManager: React.FC<Partial<ProfileManagerProps>> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onProfileUpdate,
|
||||
onLoginSuccess,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div data-testid="profile-manager-mock">
|
||||
<button onClick={onClose}>Close Profile</button>
|
||||
<button onClick={() => onProfileUpdate?.(createMockProfile({ user_id: '1', role: 'user', points: 0, full_name: 'Updated' }))}>Update Profile</button>
|
||||
<button onClick={() => onLoginSuccess?.(createMockUserProfile({ user_id: '1', user: { user_id: '1', email: 'a@b.com' } }), 'token', false)}>Login</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
onProfileUpdate?.(createMockProfile({ role: 'user', points: 0, full_name: 'Updated' }))
|
||||
}
|
||||
>
|
||||
Update Profile
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
onLoginSuccess?.(
|
||||
createMockUserProfile({ user: { user_id: '1', email: 'a@b.com' } }),
|
||||
'token',
|
||||
false,
|
||||
)
|
||||
}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MockVoiceAssistant: React.FC<VoiceAssistantProps> = ({ isOpen, onClose }) => (
|
||||
isOpen ? <div data-testid="voice-assistant-mock"><button onClick={onClose}>Close Voice Assistant</button></div> : null
|
||||
);
|
||||
export const MockVoiceAssistant: React.FC<VoiceAssistantProps> = ({ isOpen, onClose }) =>
|
||||
isOpen ? (
|
||||
<div data-testid="voice-assistant-mock">
|
||||
<button onClick={onClose}>Close Voice Assistant</button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
export const MockFlyerCorrectionTool: React.FC<Partial<FlyerCorrectionToolProps>> = ({ isOpen, onClose, onDataExtracted }) => (
|
||||
isOpen ? <div data-testid="flyer-correction-tool-mock"><button onClick={onClose}>Close Correction</button><button onClick={() => onDataExtracted?.('store_name', 'New Store Name')}>Extract Store</button><button onClick={() => onDataExtracted?.('dates', '2023-01-01')}>Extract Dates</button></div> : null
|
||||
);
|
||||
export const MockFlyerCorrectionTool: React.FC<Partial<FlyerCorrectionToolProps>> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onDataExtracted,
|
||||
}) =>
|
||||
isOpen ? (
|
||||
<div data-testid="flyer-correction-tool-mock">
|
||||
<button onClick={onClose}>Close Correction</button>
|
||||
<button onClick={() => onDataExtracted?.('store_name', 'New Store Name')}>
|
||||
Extract Store
|
||||
</button>
|
||||
<button onClick={() => onDataExtracted?.('dates', '2023-01-01')}>Extract Dates</button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
export const MockWhatsNewModal: React.FC<Partial<WhatsNewModalProps>> = ({ isOpen, onClose }) => (
|
||||
isOpen ? <div data-testid="whats-new-modal-mock"><button onClick={onClose}>Close What's New</button></div> : null
|
||||
);
|
||||
export const MockWhatsNewModal: React.FC<Partial<WhatsNewModalProps>> = ({ isOpen, onClose }) =>
|
||||
isOpen ? (
|
||||
<div data-testid="whats-new-modal-mock">
|
||||
<button onClick={onClose}>Close What's New</button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// Add mock implementations for the remaining simple page/layout components
|
||||
export const MockAdminPage: React.FC = () => <div data-testid="admin-page-mock">Admin Page</div>;
|
||||
export const MockAdminRoute: React.FC<Partial<AdminRouteProps>> = () => <div data-testid="admin-route-mock"><Outlet /></div>;
|
||||
export const MockCorrectionsPage: React.FC = () => <div data-testid="corrections-page-mock">Corrections Page</div>;
|
||||
export const MockAdminStatsPage: React.FC = () => <div data-testid="admin-stats-page-mock">Admin Stats Page</div>;
|
||||
export const MockResetPasswordPage: React.FC = () => <div data-testid="reset-password-page-mock">Reset Password Page</div>;
|
||||
export const MockVoiceLabPage: React.FC = () => <div data-testid="voice-lab-page-mock">Voice Lab Page</div>;
|
||||
export const MockMainLayout: React.FC<Partial<MainLayoutProps>> = () => <div data-testid="main-layout-mock"><Outlet /></div>;
|
||||
export const MockAdminRoute: React.FC<Partial<AdminRouteProps>> = () => (
|
||||
<div data-testid="admin-route-mock">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
export const MockCorrectionsPage: React.FC = () => (
|
||||
<div data-testid="corrections-page-mock">Corrections Page</div>
|
||||
);
|
||||
export const MockAdminStatsPage: React.FC = () => (
|
||||
<div data-testid="admin-stats-page-mock">Admin Stats Page</div>
|
||||
);
|
||||
export const MockResetPasswordPage: React.FC = () => (
|
||||
<div data-testid="reset-password-page-mock">Reset Password Page</div>
|
||||
);
|
||||
export const MockVoiceLabPage: React.FC = () => (
|
||||
<div data-testid="voice-lab-page-mock">Voice Lab Page</div>
|
||||
);
|
||||
export const MockMainLayout: React.FC<Partial<MainLayoutProps>> = () => (
|
||||
<div data-testid="main-layout-mock">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
export const MockHomePage: React.FC<Partial<HomePageProps>> = ({ onOpenCorrectionTool }) => (
|
||||
<div data-testid="home-page-mock">
|
||||
<button onClick={onOpenCorrectionTool}>Open Correction Tool</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const MockSystemCheck: React.FC = () => <div data-testid="system-check-mock">System Health Checks</div>;
|
||||
export const MockSystemCheck: React.FC = () => (
|
||||
<div data-testid="system-check-mock">System Health Checks</div>
|
||||
);
|
||||
|
||||
interface MockCorrectionRowProps {
|
||||
correction: SuggestedCorrection;
|
||||
onProcessed: (id: number) => void;
|
||||
}
|
||||
|
||||
export const MockCorrectionRow: React.FC<MockCorrectionRowProps> = ({ correction, onProcessed }) => (
|
||||
export const MockCorrectionRow: React.FC<MockCorrectionRowProps> = ({
|
||||
correction,
|
||||
onProcessed,
|
||||
}) => (
|
||||
<tr data-testid={`correction-row-${correction.suggested_correction_id}`}>
|
||||
<td>{correction.flyer_item_name}</td>
|
||||
<td>
|
||||
@@ -80,17 +139,22 @@ export const MockCorrectionRow: React.FC<MockCorrectionRowProps> = ({ correction
|
||||
</tr>
|
||||
);
|
||||
|
||||
export const MockFlyerDisplay: React.FC<Partial<FlyerDisplayProps>> = ({ imageUrl, onOpenCorrectionTool }) => (
|
||||
export const MockFlyerDisplay: React.FC<Partial<FlyerDisplayProps>> = ({
|
||||
imageUrl,
|
||||
onOpenCorrectionTool,
|
||||
}) => (
|
||||
<div data-testid="flyer-display" data-image-url={imageUrl}>
|
||||
<button data-testid="mock-open-correction-tool" onClick={onOpenCorrectionTool}>Open Correction Tool</button>
|
||||
<button data-testid="mock-open-correction-tool" onClick={onOpenCorrectionTool}>
|
||||
Open Correction Tool
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const MockAnalysisPanel: React.FC = () => <div data-testid="analysis-panel" />;
|
||||
|
||||
export const MockExtractedDataTable: React.FC<Partial<ExtractedDataTableProps>> = ({ items = [] }) => (
|
||||
<div data-testid="extracted-data-table">{items.length} items</div>
|
||||
);
|
||||
export const MockExtractedDataTable: React.FC<Partial<ExtractedDataTableProps>> = ({
|
||||
items = [],
|
||||
}) => <div data-testid="extracted-data-table">{items.length} items</div>;
|
||||
|
||||
/**
|
||||
* A simple mock for the StatCard component that renders its props.
|
||||
@@ -104,12 +168,26 @@ export const MockStatCard: React.FC<StatCardProps> = ({ title, value, icon }) =>
|
||||
);
|
||||
|
||||
// --- Icon Mocks ---
|
||||
export const MockShieldExclamationIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => <svg data-testid="shield-icon" {...props} />;
|
||||
export const MockChartBarIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => <svg data-testid="chart-icon" {...props} />;
|
||||
export const MockUsersIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => <svg data-testid="users-icon" {...props} />;
|
||||
export const MockDocumentDuplicateIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => <svg data-testid="document-duplicate-icon" {...props} />;
|
||||
export const MockBuildingStorefrontIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => <svg data-testid="building-storefront-icon" {...props} />;
|
||||
export const MockBellAlertIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => <svg data-testid="bell-alert-icon" {...props} />;
|
||||
export const MockBookOpenIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => <svg data-testid="book-open-icon" {...props} />;
|
||||
export const MockShieldExclamationIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg data-testid="shield-icon" {...props} />
|
||||
);
|
||||
export const MockChartBarIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg data-testid="chart-icon" {...props} />
|
||||
);
|
||||
export const MockUsersIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg data-testid="users-icon" {...props} />
|
||||
);
|
||||
export const MockDocumentDuplicateIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg data-testid="document-duplicate-icon" {...props} />
|
||||
);
|
||||
export const MockBuildingStorefrontIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg data-testid="building-storefront-icon" {...props} />
|
||||
);
|
||||
export const MockBellAlertIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg data-testid="bell-alert-icon" {...props} />
|
||||
);
|
||||
export const MockBookOpenIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg data-testid="book-open-icon" {...props} />
|
||||
);
|
||||
|
||||
export const MockFooter: React.FC = () => <footer data-testid="footer-mock">Mock Footer</footer>;
|
||||
|
||||
Reference in New Issue
Block a user