All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m46s
517 lines
20 KiB
TypeScript
517 lines
20 KiB
TypeScript
// src/tests/integration/admin.integration.test.ts
|
|
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
|
|
import supertest from 'supertest';
|
|
import { getPool } from '../../services/db/connection.db';
|
|
import type { UserProfile } from '../../types';
|
|
import { createAndLoginUser, TEST_EXAMPLE_DOMAIN } from '../utils/testHelpers';
|
|
import { cleanupDb } from '../utils/cleanup';
|
|
import {
|
|
createStoreWithLocation,
|
|
cleanupStoreLocations,
|
|
type CreatedStoreLocation,
|
|
} from '../utils/storeHelpers';
|
|
|
|
/**
|
|
* @vitest-environment node
|
|
*/
|
|
|
|
describe('Admin API Routes Integration Tests', () => {
|
|
let request: ReturnType<typeof supertest>;
|
|
let adminToken: string;
|
|
let adminUser: UserProfile;
|
|
let regularUser: UserProfile;
|
|
let regularUserToken: string;
|
|
const createdUserIds: string[] = [];
|
|
const createdStoreLocations: CreatedStoreLocation[] = [];
|
|
const createdCorrectionIds: number[] = [];
|
|
const createdFlyerIds: number[] = [];
|
|
|
|
beforeAll(async () => {
|
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
|
const app = (await import('../../../server')).default;
|
|
request = supertest(app);
|
|
|
|
// Create a fresh admin user and a regular user for this test suite
|
|
// Using unique emails to prevent test pollution from other integration test files.
|
|
({ user: adminUser, token: adminToken } = await createAndLoginUser({
|
|
email: `admin-integration-${Date.now()}@test.com`,
|
|
role: 'admin',
|
|
fullName: 'Admin Test User',
|
|
request, // Pass supertest request to ensure user is created in the test DB
|
|
}));
|
|
createdUserIds.push(adminUser.user.user_id);
|
|
|
|
({ user: regularUser, token: regularUserToken } = await createAndLoginUser({
|
|
email: `regular-integration-${Date.now()}@test.com`,
|
|
fullName: 'Regular User',
|
|
request, // Pass supertest request
|
|
}));
|
|
createdUserIds.push(regularUser.user.user_id);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
vi.unstubAllEnvs();
|
|
await cleanupDb({
|
|
userIds: createdUserIds,
|
|
suggestedCorrectionIds: createdCorrectionIds,
|
|
flyerIds: createdFlyerIds,
|
|
});
|
|
await cleanupStoreLocations(getPool(), createdStoreLocations);
|
|
});
|
|
|
|
describe('GET /api/admin/stats', () => {
|
|
it('should allow an admin to fetch application stats', async () => {
|
|
const response = await request
|
|
.get('/api/admin/stats')
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
const stats = response.body.data;
|
|
// DEBUG: Log response if it fails expectation
|
|
if (response.status !== 200) {
|
|
console.error('[DEBUG] GET /api/admin/stats failed:', response.status, response.body);
|
|
}
|
|
expect(stats).toBeDefined();
|
|
expect(stats).toHaveProperty('flyerCount');
|
|
expect(stats).toHaveProperty('userCount');
|
|
expect(stats).toHaveProperty('flyerItemCount');
|
|
});
|
|
|
|
it('should forbid a regular user from fetching application stats', async () => {
|
|
const response = await request
|
|
.get('/api/admin/stats')
|
|
.set('Authorization', `Bearer ${regularUserToken}`);
|
|
expect(response.status).toBe(403);
|
|
const errorData = response.body.error;
|
|
expect(errorData.message).toBe('Forbidden: Administrator access required.');
|
|
});
|
|
});
|
|
|
|
describe('GET /api/admin/stats/daily', () => {
|
|
it('should allow an admin to fetch daily stats', async () => {
|
|
const response = await request
|
|
.get('/api/admin/stats/daily')
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
const dailyStats = response.body.data;
|
|
expect(dailyStats).toBeDefined();
|
|
expect(Array.isArray(dailyStats)).toBe(true);
|
|
// We just created users in beforeAll, so we should have data
|
|
expect(dailyStats.length).toBe(30);
|
|
expect(dailyStats[0]).toHaveProperty('date');
|
|
expect(dailyStats[0]).toHaveProperty('new_users');
|
|
expect(dailyStats[0]).toHaveProperty('new_flyers');
|
|
});
|
|
|
|
it('should forbid a regular user from fetching daily stats', async () => {
|
|
const response = await request
|
|
.get('/api/admin/stats/daily')
|
|
.set('Authorization', `Bearer ${regularUserToken}`);
|
|
expect(response.status).toBe(403);
|
|
const errorData = response.body.error;
|
|
expect(errorData.message).toBe('Forbidden: Administrator access required.');
|
|
});
|
|
});
|
|
|
|
describe('GET /api/admin/corrections', () => {
|
|
it('should allow an admin to fetch suggested corrections', async () => {
|
|
// This test just verifies access and correct response shape.
|
|
// More detailed tests would require seeding corrections.
|
|
const response = await request
|
|
.get('/api/admin/corrections')
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
const corrections = response.body.data;
|
|
expect(corrections).toBeDefined();
|
|
expect(Array.isArray(corrections)).toBe(true);
|
|
});
|
|
|
|
it('should forbid a regular user from fetching suggested corrections', async () => {
|
|
const response = await request
|
|
.get('/api/admin/corrections')
|
|
.set('Authorization', `Bearer ${regularUserToken}`);
|
|
expect(response.status).toBe(403);
|
|
const errorData = response.body.error;
|
|
expect(errorData.message).toBe('Forbidden: Administrator access required.');
|
|
});
|
|
});
|
|
|
|
describe('GET /api/admin/brands', () => {
|
|
it('should allow an admin to fetch all brands', async () => {
|
|
const response = await request
|
|
.get('/api/admin/brands')
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
const brands = response.body.data;
|
|
expect(brands).toBeDefined();
|
|
expect(Array.isArray(brands)).toBe(true);
|
|
// Even if no brands exist, it should return an array.
|
|
// (We rely on seed or empty state here, which is fine for a read test,
|
|
// but creating a brand would be strictly better if we wanted to assert length > 0)
|
|
});
|
|
|
|
it('should forbid a regular user from fetching all brands', async () => {
|
|
const response = await request
|
|
.get('/api/admin/brands')
|
|
.set('Authorization', `Bearer ${regularUserToken}`);
|
|
expect(response.status).toBe(403);
|
|
const errorData = response.body.error;
|
|
expect(errorData.message).toBe('Forbidden: Administrator access required.');
|
|
});
|
|
});
|
|
|
|
describe('Admin Data Modification Routes', () => {
|
|
let testStoreId: number;
|
|
let testFlyerItemId: number;
|
|
let testCorrectionId: number;
|
|
|
|
// Create a store and flyer once for all tests in this block.
|
|
beforeAll(async () => {
|
|
// Create a dummy store with location to ensure foreign keys exist
|
|
const store = await createStoreWithLocation(getPool(), {
|
|
name: `Admin Test Store - ${Date.now()}`,
|
|
address: '100 Admin St',
|
|
city: 'Toronto',
|
|
province: 'ON',
|
|
postalCode: 'M5V 1A1',
|
|
});
|
|
testStoreId = store.storeId;
|
|
createdStoreLocations.push(store);
|
|
});
|
|
|
|
// Before each modification test, create a fresh flyer item and a correction for it.
|
|
beforeEach(async () => {
|
|
const flyerRes = await getPool().query(
|
|
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum)
|
|
VALUES ($1, 'admin-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/admin-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/admin-test.jpg', 1, $2) RETURNING flyer_id`,
|
|
// The checksum must be a unique 64-character string to satisfy the DB constraint.
|
|
// We generate a dynamic string and pad it to 64 characters.
|
|
[testStoreId, `checksum-${Date.now()}-${Math.random()}`.padEnd(64, '0')],
|
|
);
|
|
const flyerId = flyerRes.rows[0].flyer_id;
|
|
createdFlyerIds.push(flyerId);
|
|
|
|
const flyerItemRes = await getPool().query(
|
|
`INSERT INTO public.flyer_items (flyer_id, item, price_display, price_in_cents, quantity)
|
|
VALUES ($1, 'Test Item for Correction', '$1.99', 199, 'each') RETURNING flyer_item_id`,
|
|
[flyerId],
|
|
);
|
|
testFlyerItemId = flyerItemRes.rows[0].flyer_item_id;
|
|
|
|
const correctionRes = await getPool().query(
|
|
`INSERT INTO public.suggested_corrections (flyer_item_id, user_id, correction_type, suggested_value, status)
|
|
VALUES ($1, $2, 'WRONG_PRICE', '250', 'pending') RETURNING suggested_correction_id`,
|
|
[testFlyerItemId, adminUser.user.user_id],
|
|
);
|
|
testCorrectionId = correctionRes.rows[0].suggested_correction_id;
|
|
createdCorrectionIds.push(testCorrectionId);
|
|
});
|
|
|
|
it('should allow an admin to approve a correction', async () => {
|
|
// Act: Approve the correction.
|
|
const response = await request
|
|
.post(`/api/admin/corrections/${testCorrectionId}/approve`)
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
expect(response.status).toBe(200);
|
|
|
|
// Assert: Verify the flyer item's price was updated and the correction status changed.
|
|
const { rows: itemRows } = await getPool().query(
|
|
'SELECT price_in_cents FROM public.flyer_items WHERE flyer_item_id = $1',
|
|
[testFlyerItemId],
|
|
);
|
|
expect(itemRows[0].price_in_cents).toBe(250);
|
|
|
|
const { rows: correctionRows } = await getPool().query(
|
|
'SELECT status FROM public.suggested_corrections WHERE suggested_correction_id = $1',
|
|
[testCorrectionId],
|
|
);
|
|
expect(correctionRows[0].status).toBe('approved');
|
|
});
|
|
|
|
it('should allow an admin to reject a correction', async () => {
|
|
// Act: Reject the correction.
|
|
const response = await request
|
|
.post(`/api/admin/corrections/${testCorrectionId}/reject`)
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
expect(response.status).toBe(200);
|
|
|
|
// Assert: Verify the correction status changed.
|
|
const { rows: correctionRows } = await getPool().query(
|
|
'SELECT status FROM public.suggested_corrections WHERE suggested_correction_id = $1',
|
|
[testCorrectionId],
|
|
);
|
|
expect(correctionRows[0].status).toBe('rejected');
|
|
});
|
|
|
|
it('should allow an admin to update a correction', async () => {
|
|
// Act: Update the suggested value of the correction.
|
|
const response = await request
|
|
.put(`/api/admin/corrections/${testCorrectionId}`)
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.send({ suggested_value: '300' });
|
|
const updatedCorrection = response.body.data;
|
|
|
|
// Assert: Verify the API response and the database state.
|
|
expect(updatedCorrection.suggested_value).toBe('300');
|
|
const { rows } = await getPool().query(
|
|
'SELECT suggested_value FROM public.suggested_corrections WHERE suggested_correction_id = $1',
|
|
[testCorrectionId],
|
|
);
|
|
expect(rows[0].suggested_value).toBe('300');
|
|
});
|
|
|
|
it('should allow an admin to update a recipe status', async () => {
|
|
// Create a recipe specifically for this test
|
|
const recipeRes = await getPool().query(
|
|
`INSERT INTO public.recipes (name, instructions, user_id) VALUES ('Admin Test Recipe', 'Cook it', $1) RETURNING recipe_id`,
|
|
[regularUser.user.user_id],
|
|
);
|
|
const recipeId = recipeRes.rows[0].recipe_id;
|
|
|
|
// Act: Update the status to 'public'.
|
|
const response = await request
|
|
.put(`/api/admin/recipes/${recipeId}/status`)
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.send({ status: 'public' });
|
|
expect(response.status).toBe(200);
|
|
|
|
// Assert: Verify the status was updated in the database.
|
|
const { rows: updatedRecipeRows } = await getPool().query(
|
|
'SELECT status FROM public.recipes WHERE recipe_id = $1',
|
|
[recipeId],
|
|
);
|
|
expect(updatedRecipeRows[0].status).toBe('public');
|
|
});
|
|
});
|
|
|
|
describe('DELETE /api/admin/users/:id', () => {
|
|
it("should allow an admin to delete another user's account", async () => {
|
|
// Create a dedicated user for this deletion test to avoid affecting other tests
|
|
const { user: userToDelete } = await createAndLoginUser({
|
|
email: `delete-target-${Date.now()}@test.com`,
|
|
fullName: 'User To Delete',
|
|
request,
|
|
});
|
|
|
|
// Act: Call the delete endpoint as an admin.
|
|
const response = await request
|
|
.delete(`/api/admin/users/${userToDelete.user.user_id}`)
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
|
|
// Assert: Check for a successful deletion status.
|
|
expect(response.status).toBe(204);
|
|
});
|
|
|
|
it('should prevent an admin from deleting their own account', async () => {
|
|
// Act: Call the delete endpoint as the same admin user.
|
|
const adminUserId = adminUser.user.user_id;
|
|
const response = await request
|
|
.delete(`/api/admin/users/${adminUserId}`)
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
|
|
// Assert:
|
|
// The service throws ValidationError, which maps to 400.
|
|
// We also allow 403 in case authorization middleware catches it in the future.
|
|
if (response.status !== 400 && response.status !== 403) {
|
|
console.error(
|
|
'[DEBUG] Self-deletion failed with unexpected status:',
|
|
response.status,
|
|
response.body,
|
|
);
|
|
}
|
|
expect([400, 403]).toContain(response.status);
|
|
expect(response.body.error.message).toMatch(/Admins cannot delete their own account/);
|
|
});
|
|
|
|
it('should return 404 if the user to be deleted is not found', async () => {
|
|
// Arrange: Use a valid UUID that does not exist
|
|
const notFoundUserId = '00000000-0000-0000-0000-000000000000';
|
|
|
|
const response = await request
|
|
.delete(`/api/admin/users/${notFoundUserId}`)
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
|
|
// Assert: Check for a 404 status code
|
|
expect(response.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('Queue Management Routes', () => {
|
|
describe('GET /api/admin/queues/status', () => {
|
|
it('should return queue status for all queues', async () => {
|
|
const response = await request
|
|
.get('/api/admin/queues/status')
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data).toBeInstanceOf(Array);
|
|
|
|
// Should have data for each queue
|
|
if (response.body.data.length > 0) {
|
|
const firstQueue = response.body.data[0];
|
|
expect(firstQueue).toHaveProperty('name');
|
|
expect(firstQueue).toHaveProperty('counts');
|
|
}
|
|
});
|
|
|
|
it('should forbid regular users from viewing queue status', async () => {
|
|
const response = await request
|
|
.get('/api/admin/queues/status')
|
|
.set('Authorization', `Bearer ${regularUserToken}`);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(response.body.error.message).toBe('Forbidden: Administrator access required.');
|
|
});
|
|
});
|
|
|
|
describe('POST /api/admin/trigger/analytics-report', () => {
|
|
it('should enqueue an analytics report job', async () => {
|
|
const response = await request
|
|
.post('/api/admin/trigger/analytics-report')
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
|
|
expect(response.status).toBe(202); // 202 Accepted for async job enqueue
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data.message).toContain('enqueued');
|
|
});
|
|
|
|
it('should forbid regular users from triggering analytics report', async () => {
|
|
const response = await request
|
|
.post('/api/admin/trigger/analytics-report')
|
|
.set('Authorization', `Bearer ${regularUserToken}`);
|
|
|
|
expect(response.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/admin/trigger/weekly-analytics', () => {
|
|
it('should enqueue a weekly analytics job', async () => {
|
|
const response = await request
|
|
.post('/api/admin/trigger/weekly-analytics')
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
|
|
expect(response.status).toBe(202); // 202 Accepted for async job enqueue
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data.message).toContain('enqueued');
|
|
});
|
|
|
|
it('should forbid regular users from triggering weekly analytics', async () => {
|
|
const response = await request
|
|
.post('/api/admin/trigger/weekly-analytics')
|
|
.set('Authorization', `Bearer ${regularUserToken}`);
|
|
|
|
expect(response.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/admin/trigger/daily-deal-check', () => {
|
|
it('should enqueue a daily deal check job', async () => {
|
|
const response = await request
|
|
.post('/api/admin/trigger/daily-deal-check')
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
|
|
expect(response.status).toBe(202); // 202 Accepted for async job trigger
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data.message).toContain('triggered');
|
|
});
|
|
|
|
it('should forbid regular users from triggering daily deal check', async () => {
|
|
const response = await request
|
|
.post('/api/admin/trigger/daily-deal-check')
|
|
.set('Authorization', `Bearer ${regularUserToken}`);
|
|
|
|
expect(response.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/admin/system/clear-cache', () => {
|
|
it('should clear the application cache', async () => {
|
|
const response = await request
|
|
.post('/api/admin/system/clear-cache')
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data.message).toContain('cleared');
|
|
});
|
|
|
|
it('should forbid regular users from clearing cache', async () => {
|
|
const response = await request
|
|
.post('/api/admin/system/clear-cache')
|
|
.set('Authorization', `Bearer ${regularUserToken}`);
|
|
|
|
expect(response.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/admin/jobs/:queue/:id/retry', () => {
|
|
it('should return validation error for invalid queue name', async () => {
|
|
const response = await request
|
|
.post('/api/admin/jobs/invalid-queue-name/1/retry')
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.success).toBe(false);
|
|
expect(response.body.error.code).toBe('VALIDATION_ERROR');
|
|
});
|
|
|
|
it('should return 404 for non-existent job', async () => {
|
|
const response = await request
|
|
.post('/api/admin/jobs/flyer-processing/999999999/retry')
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(response.body.success).toBe(false);
|
|
});
|
|
|
|
it('should forbid regular users from retrying jobs', async () => {
|
|
const response = await request
|
|
.post('/api/admin/jobs/flyer-processing/1/retry')
|
|
.set('Authorization', `Bearer ${regularUserToken}`);
|
|
|
|
expect(response.status).toBe(403);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('GET /api/admin/users', () => {
|
|
it('should return all users for admin', async () => {
|
|
const response = await request
|
|
.get('/api/admin/users')
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.success).toBe(true);
|
|
// The endpoint returns { users: [...], total: N }
|
|
expect(response.body.data).toHaveProperty('users');
|
|
expect(response.body.data).toHaveProperty('total');
|
|
expect(response.body.data.users).toBeInstanceOf(Array);
|
|
expect(typeof response.body.data.total).toBe('number');
|
|
});
|
|
|
|
it('should forbid regular users from listing all users', async () => {
|
|
const response = await request
|
|
.get('/api/admin/users')
|
|
.set('Authorization', `Bearer ${regularUserToken}`);
|
|
|
|
expect(response.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/admin/review/flyers', () => {
|
|
it('should return pending review flyers for admin', async () => {
|
|
const response = await request
|
|
.get('/api/admin/review/flyers')
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data).toBeInstanceOf(Array);
|
|
});
|
|
|
|
it('should forbid regular users from viewing pending flyers', async () => {
|
|
const response = await request
|
|
.get('/api/admin/review/flyers')
|
|
.set('Authorization', `Bearer ${regularUserToken}`);
|
|
|
|
expect(response.status).toBe(403);
|
|
});
|
|
});
|
|
});
|