All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m11s
315 lines
13 KiB
TypeScript
315 lines
13 KiB
TypeScript
// src/tests/integration/admin.integration.test.ts
|
|
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
import supertest from 'supertest';
|
|
import app from '../../../server';
|
|
import { getPool } from '../../services/db/connection.db';
|
|
import type { UserProfile } from '../../types';
|
|
import { createAndLoginUser } from '../utils/testHelpers';
|
|
import { cleanupDb } from '../utils/cleanup';
|
|
|
|
/**
|
|
* @vitest-environment node
|
|
*/
|
|
const request = supertest(app);
|
|
|
|
describe('Admin API Routes Integration Tests', () => {
|
|
let adminToken: string;
|
|
let adminUser: UserProfile;
|
|
let regularUser: UserProfile;
|
|
let regularUserToken: string;
|
|
const createdUserIds: string[] = [];
|
|
const createdStoreIds: number[] = [];
|
|
|
|
beforeAll(async () => {
|
|
// 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 () => {
|
|
await cleanupDb({
|
|
userIds: createdUserIds,
|
|
storeIds: createdStoreIds,
|
|
});
|
|
});
|
|
|
|
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;
|
|
// 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;
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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 and flyer to ensure foreign keys exist
|
|
// Use a unique name to prevent conflicts if tests are run in parallel or without full DB reset.
|
|
const storeName = `Admin Test Store - ${Date.now()}`;
|
|
const storeRes = await getPool().query(
|
|
`INSERT INTO public.stores (name) VALUES ($1) RETURNING store_id`,
|
|
[storeName],
|
|
);
|
|
testStoreId = storeRes.rows[0].store_id;
|
|
createdStoreIds.push(testStoreId);
|
|
});
|
|
|
|
// 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, item_count, checksum)
|
|
VALUES ($1, 'admin-test.jpg', 'https://example.com/flyer-images/asdmin-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;
|
|
|
|
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;
|
|
});
|
|
|
|
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;
|
|
|
|
// 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 () => {
|
|
// Act: Call the delete endpoint as an admin.
|
|
const targetUserId = regularUser.user.user_id;
|
|
const response = await request
|
|
.delete(`/api/admin/users/${targetUserId}`)
|
|
.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: Check for a 400 (or other appropriate) status code and an error message.
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.message).toMatch(/Admins cannot delete their own account/);
|
|
});
|
|
|
|
it('should return 404 if the user to be deleted is not found', async () => {
|
|
// Arrange: Mock the userRepo.deleteUserById to throw a NotFoundError
|
|
const notFoundUserId = 'non-existent-user-id';
|
|
|
|
const response = await request
|
|
.delete(`/api/admin/users/${notFoundUserId}`)
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
|
|
// Assert: Check for a 400 status code because the UUID is invalid and caught by validation.
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('should return 500 on a generic database error', async () => {
|
|
// Arrange: Mock the userRepo.deleteUserById to throw a generic error
|
|
const genericUserId = 'generic-error-user-id';
|
|
|
|
const response = await request
|
|
.delete(`/api/admin/users/${genericUserId}`)
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|
|
|
// Assert: Check for a 400 status code because the UUID is invalid and caught by validation.
|
|
expect(response.status).toBe(400);
|
|
});
|
|
});
|
|
});
|