feat: Backup & Restore - Manual backup tested complete.
This commit is contained in:
@@ -24,9 +24,6 @@ import {
|
||||
UpdateProfileInput,
|
||||
PromoteToAdminInput,
|
||||
} from './users.validation';
|
||||
import { AdminRepository } from '../data/admin.repository';
|
||||
import { StationOversightService } from '../domain/station-oversight.service';
|
||||
import { StationsController } from './stations.controller';
|
||||
import { CatalogController } from './catalog.controller';
|
||||
import { VehicleCatalogService } from '../domain/vehicle-catalog.service';
|
||||
import { CatalogImportService } from '../domain/catalog-import.service';
|
||||
@@ -34,16 +31,12 @@ import { PlatformCacheService } from '../../platform/domain/platform-cache.servi
|
||||
import { cacheService } from '../../../core/config/redis';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { CommunityStationsController } from '../../stations/api/community-stations.controller';
|
||||
import { registerBackupRoutes } from '../../backup/api/backup.routes';
|
||||
|
||||
export const adminRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const adminController = new AdminController();
|
||||
const usersController = new UsersController();
|
||||
|
||||
// Initialize station oversight dependencies
|
||||
const adminRepository = new AdminRepository(pool);
|
||||
const stationOversightService = new StationOversightService(pool, adminRepository);
|
||||
const stationsController = new StationsController(stationOversightService);
|
||||
|
||||
// Initialize community stations dependencies
|
||||
const communityStationsController = new CommunityStationsController();
|
||||
|
||||
@@ -323,45 +316,7 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
handler: catalogController.bulkDeleteCatalogEntity.bind(catalogController)
|
||||
});
|
||||
|
||||
// Phase 4: Station oversight endpoints
|
||||
|
||||
// GET /api/admin/stations - List all stations globally
|
||||
fastify.get('/admin/stations', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: stationsController.listAllStations.bind(stationsController)
|
||||
});
|
||||
|
||||
// POST /api/admin/stations - Create new station
|
||||
fastify.post('/admin/stations', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: stationsController.createStation.bind(stationsController)
|
||||
});
|
||||
|
||||
// PUT /api/admin/stations/:stationId - Update station
|
||||
fastify.put('/admin/stations/:stationId', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: stationsController.updateStation.bind(stationsController)
|
||||
});
|
||||
|
||||
// DELETE /api/admin/stations/:stationId - Delete station (soft delete by default, ?force=true for hard delete)
|
||||
fastify.delete('/admin/stations/:stationId', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: stationsController.deleteStation.bind(stationsController)
|
||||
});
|
||||
|
||||
// GET /api/admin/users/:userId/stations - Get user's saved stations
|
||||
fastify.get('/admin/users/:userId/stations', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: stationsController.getUserSavedStations.bind(stationsController)
|
||||
});
|
||||
|
||||
// DELETE /api/admin/users/:userId/stations/:stationId - Remove user's saved station (soft delete by default, ?force=true for hard delete)
|
||||
fastify.delete('/admin/users/:userId/stations/:stationId', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: stationsController.removeUserSavedStation.bind(stationsController)
|
||||
});
|
||||
|
||||
// Phase 5: Community gas station submission oversight
|
||||
// Community gas station submission oversight
|
||||
|
||||
// GET /api/admin/community-stations - List all submissions with filters
|
||||
fastify.get('/admin/community-stations', {
|
||||
@@ -380,4 +335,9 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: communityStationsController.reviewStation.bind(communityStationsController)
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Backup & Restore endpoints
|
||||
// ============================================
|
||||
await registerBackupRoutes(fastify, { pool });
|
||||
};
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
/**
|
||||
* @ai-summary HTTP request handlers for admin station oversight
|
||||
* @ai-context Handles admin operations on global stations and user-saved stations
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { StationOversightService } from '../domain/station-oversight.service';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
|
||||
interface StationListQuery {
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
interface CreateStationBody {
|
||||
placeId: string;
|
||||
name: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
priceRegular?: number;
|
||||
pricePremium?: number;
|
||||
priceDiesel?: number;
|
||||
rating?: number;
|
||||
photoReference?: string;
|
||||
}
|
||||
|
||||
interface UpdateStationBody {
|
||||
name?: string;
|
||||
address?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
priceRegular?: number;
|
||||
pricePremium?: number;
|
||||
priceDiesel?: number;
|
||||
rating?: number;
|
||||
photoReference?: string;
|
||||
}
|
||||
|
||||
interface StationParams {
|
||||
stationId: string;
|
||||
}
|
||||
|
||||
interface UserStationParams {
|
||||
userId: string;
|
||||
stationId: string;
|
||||
}
|
||||
|
||||
interface DeleteQuery {
|
||||
force?: string;
|
||||
}
|
||||
|
||||
export class StationsController {
|
||||
constructor(private service: StationOversightService) {}
|
||||
|
||||
/**
|
||||
* GET /api/admin/stations
|
||||
* List all stations globally with pagination and search
|
||||
*/
|
||||
async listAllStations(
|
||||
request: FastifyRequest<{ Querystring: StationListQuery }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const limit = request.query.limit ? parseInt(request.query.limit, 10) : 100;
|
||||
const offset = request.query.offset ? parseInt(request.query.offset, 10) : 0;
|
||||
const search = request.query.search;
|
||||
|
||||
const result = await this.service.listAllStations(limit, offset, search);
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error) {
|
||||
logger.error('Error listing stations', { error });
|
||||
return reply.code(500).send({ error: 'Failed to list stations' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/stations
|
||||
* Create a new station
|
||||
*/
|
||||
async createStation(
|
||||
request: FastifyRequest<{ Body: CreateStationBody }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
const { placeId, name, address, latitude, longitude } = request.body;
|
||||
if (!placeId || !name || !address || latitude === undefined || longitude === undefined) {
|
||||
return reply.code(400).send({ error: 'Missing required fields: placeId, name, address, latitude, longitude' });
|
||||
}
|
||||
|
||||
const station = await this.service.createStation(actorId, request.body);
|
||||
|
||||
return reply.code(201).send(station);
|
||||
} catch (error: any) {
|
||||
logger.error('Error creating station', { error });
|
||||
if (error.message?.includes('duplicate key')) {
|
||||
return reply.code(409).send({ error: 'Station with this placeId already exists' });
|
||||
}
|
||||
return reply.code(500).send({ error: 'Failed to create station' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/stations/:stationId
|
||||
* Update an existing station
|
||||
*/
|
||||
async updateStation(
|
||||
request: FastifyRequest<{ Params: StationParams; Body: UpdateStationBody }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { stationId } = request.params;
|
||||
|
||||
// Validate at least one field to update
|
||||
if (Object.keys(request.body).length === 0) {
|
||||
return reply.code(400).send({ error: 'No fields to update' });
|
||||
}
|
||||
|
||||
const station = await this.service.updateStation(actorId, stationId, request.body);
|
||||
|
||||
return reply.code(200).send(station);
|
||||
} catch (error: any) {
|
||||
logger.error('Error updating station', { error });
|
||||
if (error.message === 'Station not found') {
|
||||
return reply.code(404).send({ error: 'Station not found' });
|
||||
}
|
||||
return reply.code(500).send({ error: 'Failed to update station' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/stations/:stationId
|
||||
* Delete a station (soft delete by default, hard delete with ?force=true)
|
||||
*/
|
||||
async deleteStation(
|
||||
request: FastifyRequest<{ Params: StationParams; Querystring: DeleteQuery }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { stationId } = request.params;
|
||||
const force = request.query.force === 'true';
|
||||
|
||||
await this.service.deleteStation(actorId, stationId, force);
|
||||
|
||||
return reply.code(204).send();
|
||||
} catch (error: any) {
|
||||
logger.error('Error deleting station', { error });
|
||||
if (error.message === 'Station not found') {
|
||||
return reply.code(404).send({ error: 'Station not found' });
|
||||
}
|
||||
return reply.code(500).send({ error: 'Failed to delete station' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/users/:userId/stations
|
||||
* Get user's saved stations
|
||||
*/
|
||||
async getUserSavedStations(
|
||||
request: FastifyRequest<{ Params: { userId: string } }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { userId } = request.params;
|
||||
|
||||
const stations = await this.service.getUserSavedStations(userId);
|
||||
|
||||
return reply.code(200).send(stations);
|
||||
} catch (error) {
|
||||
logger.error('Error getting user saved stations', { error });
|
||||
return reply.code(500).send({ error: 'Failed to get user saved stations' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/users/:userId/stations/:stationId
|
||||
* Remove user's saved station (soft delete by default, hard delete with ?force=true)
|
||||
*/
|
||||
async removeUserSavedStation(
|
||||
request: FastifyRequest<{ Params: UserStationParams; Querystring: DeleteQuery }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { userId, stationId } = request.params;
|
||||
const force = request.query.force === 'true';
|
||||
|
||||
await this.service.removeUserSavedStation(actorId, userId, stationId, force);
|
||||
|
||||
return reply.code(204).send();
|
||||
} catch (error: any) {
|
||||
logger.error('Error removing user saved station', { error });
|
||||
if (error.message?.includes('not found')) {
|
||||
return reply.code(404).send({ error: error.message });
|
||||
}
|
||||
return reply.code(500).send({ error: 'Failed to remove user saved station' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
/**
|
||||
* @ai-summary Station oversight business logic for admin operations
|
||||
* @ai-context Manages global stations and user-saved stations with audit logging
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { redis } from '../../../core/config/redis';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { AdminRepository } from '../data/admin.repository';
|
||||
import { StationsRepository } from '../../stations/data/stations.repository';
|
||||
import { Station, SavedStation } from '../../stations/domain/stations.types';
|
||||
|
||||
interface CreateStationData {
|
||||
placeId: string;
|
||||
name: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
priceRegular?: number;
|
||||
pricePremium?: number;
|
||||
priceDiesel?: number;
|
||||
rating?: number;
|
||||
photoReference?: string;
|
||||
}
|
||||
|
||||
interface UpdateStationData {
|
||||
name?: string;
|
||||
address?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
priceRegular?: number;
|
||||
pricePremium?: number;
|
||||
priceDiesel?: number;
|
||||
rating?: number;
|
||||
photoReference?: string;
|
||||
}
|
||||
|
||||
interface StationListResult {
|
||||
total: number;
|
||||
stations: Station[];
|
||||
}
|
||||
|
||||
export class StationOversightService {
|
||||
private stationsRepository: StationsRepository;
|
||||
|
||||
constructor(
|
||||
private pool: Pool,
|
||||
private adminRepository: AdminRepository
|
||||
) {
|
||||
this.stationsRepository = new StationsRepository(pool);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all stations globally with pagination and search
|
||||
*/
|
||||
async listAllStations(
|
||||
limit: number = 100,
|
||||
offset: number = 0,
|
||||
search?: string
|
||||
): Promise<StationListResult> {
|
||||
try {
|
||||
let countQuery = 'SELECT COUNT(*) as total FROM station_cache';
|
||||
let dataQuery = `
|
||||
SELECT
|
||||
id, place_id, name, address, latitude, longitude,
|
||||
price_regular, price_premium, price_diesel, rating, photo_reference, cached_at
|
||||
FROM station_cache
|
||||
`;
|
||||
const params: any[] = [];
|
||||
|
||||
// Add search filter if provided
|
||||
if (search) {
|
||||
const searchCondition = ` WHERE name ILIKE $1 OR address ILIKE $1`;
|
||||
countQuery += searchCondition;
|
||||
dataQuery += searchCondition;
|
||||
params.push(`%${search}%`);
|
||||
}
|
||||
|
||||
dataQuery += ' ORDER BY cached_at DESC LIMIT $' + (params.length + 1) + ' OFFSET $' + (params.length + 2);
|
||||
params.push(limit, offset);
|
||||
|
||||
const [countResult, dataResult] = await Promise.all([
|
||||
this.pool.query(countQuery, search ? [`%${search}%`] : []),
|
||||
this.pool.query(dataQuery, params),
|
||||
]);
|
||||
|
||||
const total = parseInt(countResult.rows[0].total, 10);
|
||||
const stations = dataResult.rows.map(row => this.mapStationRow(row));
|
||||
|
||||
return { total, stations };
|
||||
} catch (error) {
|
||||
logger.error('Error listing all stations', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new station in the cache
|
||||
*/
|
||||
async createStation(
|
||||
actorId: string,
|
||||
data: CreateStationData
|
||||
): Promise<Station> {
|
||||
try {
|
||||
// Create station using repository
|
||||
const station: Station = {
|
||||
id: '', // Will be generated by database
|
||||
placeId: data.placeId,
|
||||
name: data.name,
|
||||
address: data.address,
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
priceRegular: data.priceRegular,
|
||||
pricePremium: data.pricePremium,
|
||||
priceDiesel: data.priceDiesel,
|
||||
rating: data.rating,
|
||||
photoReference: data.photoReference,
|
||||
};
|
||||
|
||||
await this.stationsRepository.cacheStation(station);
|
||||
|
||||
// Get the created station
|
||||
const created = await this.stationsRepository.getCachedStation(data.placeId);
|
||||
if (!created) {
|
||||
throw new Error('Failed to retrieve created station');
|
||||
}
|
||||
|
||||
// Invalidate caches
|
||||
await this.invalidateStationCaches();
|
||||
|
||||
// Log audit action
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorId,
|
||||
'CREATE',
|
||||
undefined,
|
||||
'station',
|
||||
data.placeId,
|
||||
{ name: data.name, address: data.address }
|
||||
);
|
||||
|
||||
logger.info('Station created by admin', { actorId, placeId: data.placeId });
|
||||
return created;
|
||||
} catch (error) {
|
||||
logger.error('Error creating station', { error, data });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing station
|
||||
*/
|
||||
async updateStation(
|
||||
actorId: string,
|
||||
stationId: string,
|
||||
data: UpdateStationData
|
||||
): Promise<Station> {
|
||||
try {
|
||||
// First verify station exists
|
||||
const existing = await this.stationsRepository.getCachedStation(stationId);
|
||||
if (!existing) {
|
||||
throw new Error('Station not found');
|
||||
}
|
||||
|
||||
// Build update query dynamically based on provided fields
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.name !== undefined) {
|
||||
updates.push(`name = $${paramIndex++}`);
|
||||
values.push(data.name);
|
||||
}
|
||||
if (data.address !== undefined) {
|
||||
updates.push(`address = $${paramIndex++}`);
|
||||
values.push(data.address);
|
||||
}
|
||||
if (data.latitude !== undefined) {
|
||||
updates.push(`latitude = $${paramIndex++}`);
|
||||
values.push(data.latitude);
|
||||
}
|
||||
if (data.longitude !== undefined) {
|
||||
updates.push(`longitude = $${paramIndex++}`);
|
||||
values.push(data.longitude);
|
||||
}
|
||||
if (data.priceRegular !== undefined) {
|
||||
updates.push(`price_regular = $${paramIndex++}`);
|
||||
values.push(data.priceRegular);
|
||||
}
|
||||
if (data.pricePremium !== undefined) {
|
||||
updates.push(`price_premium = $${paramIndex++}`);
|
||||
values.push(data.pricePremium);
|
||||
}
|
||||
if (data.priceDiesel !== undefined) {
|
||||
updates.push(`price_diesel = $${paramIndex++}`);
|
||||
values.push(data.priceDiesel);
|
||||
}
|
||||
if (data.rating !== undefined) {
|
||||
updates.push(`rating = $${paramIndex++}`);
|
||||
values.push(data.rating);
|
||||
}
|
||||
if (data.photoReference !== undefined) {
|
||||
updates.push(`photo_reference = $${paramIndex++}`);
|
||||
values.push(data.photoReference);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
updates.push(`cached_at = NOW()`);
|
||||
values.push(stationId);
|
||||
|
||||
const query = `
|
||||
UPDATE station_cache
|
||||
SET ${updates.join(', ')}
|
||||
WHERE place_id = $${paramIndex}
|
||||
`;
|
||||
|
||||
await this.pool.query(query, values);
|
||||
|
||||
// Get updated station
|
||||
const updated = await this.stationsRepository.getCachedStation(stationId);
|
||||
if (!updated) {
|
||||
throw new Error('Failed to retrieve updated station');
|
||||
}
|
||||
|
||||
// Invalidate caches
|
||||
await this.invalidateStationCaches(stationId);
|
||||
|
||||
// Log audit action
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorId,
|
||||
'UPDATE',
|
||||
undefined,
|
||||
'station',
|
||||
stationId,
|
||||
data
|
||||
);
|
||||
|
||||
logger.info('Station updated by admin', { actorId, stationId });
|
||||
return updated;
|
||||
} catch (error) {
|
||||
logger.error('Error updating station', { error, stationId, data });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a station (soft delete by default, hard delete with force flag)
|
||||
*/
|
||||
async deleteStation(
|
||||
actorId: string,
|
||||
stationId: string,
|
||||
force: boolean = false
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Verify station exists
|
||||
const existing = await this.stationsRepository.getCachedStation(stationId);
|
||||
if (!existing) {
|
||||
throw new Error('Station not found');
|
||||
}
|
||||
|
||||
if (force) {
|
||||
// Hard delete - remove from both tables
|
||||
await this.pool.query('DELETE FROM station_cache WHERE place_id = $1', [stationId]);
|
||||
await this.pool.query('DELETE FROM saved_stations WHERE place_id = $1', [stationId]);
|
||||
|
||||
logger.info('Station hard deleted by admin', { actorId, stationId });
|
||||
} else {
|
||||
// Soft delete - add deleted_at column if not exists, then set it
|
||||
// First check if column exists
|
||||
const columnCheck = await this.pool.query(`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'station_cache' AND column_name = 'deleted_at'
|
||||
`);
|
||||
|
||||
if (columnCheck.rows.length === 0) {
|
||||
// Add deleted_at column
|
||||
await this.pool.query(`
|
||||
ALTER TABLE station_cache
|
||||
ADD COLUMN deleted_at TIMESTAMP WITH TIME ZONE
|
||||
`);
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
await this.pool.query(
|
||||
'UPDATE station_cache SET deleted_at = NOW() WHERE place_id = $1',
|
||||
[stationId]
|
||||
);
|
||||
|
||||
logger.info('Station soft deleted by admin', { actorId, stationId });
|
||||
}
|
||||
|
||||
// Invalidate caches
|
||||
await this.invalidateStationCaches(stationId);
|
||||
|
||||
// Log audit action
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorId,
|
||||
'DELETE',
|
||||
undefined,
|
||||
'station',
|
||||
stationId,
|
||||
{ force }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error deleting station', { error, stationId, force });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's saved stations
|
||||
*/
|
||||
async getUserSavedStations(userId: string): Promise<SavedStation[]> {
|
||||
try {
|
||||
const stations = await this.stationsRepository.getUserSavedStations(userId);
|
||||
return stations;
|
||||
} catch (error) {
|
||||
logger.error('Error getting user saved stations', { error, userId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove user's saved station (soft delete by default, hard delete with force)
|
||||
*/
|
||||
async removeUserSavedStation(
|
||||
actorId: string,
|
||||
userId: string,
|
||||
stationId: string,
|
||||
force: boolean = false
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (force) {
|
||||
// Hard delete
|
||||
const result = await this.pool.query(
|
||||
'DELETE FROM saved_stations WHERE user_id = $1 AND place_id = $2',
|
||||
[userId, stationId]
|
||||
);
|
||||
|
||||
if ((result.rowCount ?? 0) === 0) {
|
||||
throw new Error('Saved station not found');
|
||||
}
|
||||
|
||||
logger.info('User saved station hard deleted by admin', { actorId, userId, stationId });
|
||||
} else {
|
||||
// Soft delete - add deleted_at column if not exists
|
||||
const columnCheck = await this.pool.query(`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'saved_stations' AND column_name = 'deleted_at'
|
||||
`);
|
||||
|
||||
if (columnCheck.rows.length === 0) {
|
||||
// Column already exists in migration, but double check
|
||||
logger.warn('deleted_at column check executed', { table: 'saved_stations' });
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
const result = await this.pool.query(
|
||||
'UPDATE saved_stations SET deleted_at = NOW() WHERE user_id = $1 AND place_id = $2 AND deleted_at IS NULL',
|
||||
[userId, stationId]
|
||||
);
|
||||
|
||||
if ((result.rowCount ?? 0) === 0) {
|
||||
throw new Error('Saved station not found or already deleted');
|
||||
}
|
||||
|
||||
logger.info('User saved station soft deleted by admin', { actorId, userId, stationId });
|
||||
}
|
||||
|
||||
// Invalidate user's saved stations cache
|
||||
await redis.del(`mvp:stations:saved:${userId}`);
|
||||
|
||||
// Log audit action
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorId,
|
||||
'DELETE',
|
||||
undefined,
|
||||
'saved_station',
|
||||
`${userId}:${stationId}`,
|
||||
{ userId, stationId, force }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error removing user saved station', { error, userId, stationId, force });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate station-related Redis caches
|
||||
*/
|
||||
private async invalidateStationCaches(stationId?: string): Promise<void> {
|
||||
try {
|
||||
// Get all keys matching station cache patterns
|
||||
const patterns = [
|
||||
'mvp:stations:*',
|
||||
'mvp:stations:search:*',
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const keys = await redis.keys(pattern);
|
||||
if (keys.length > 0) {
|
||||
await redis.del(...keys);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Station caches invalidated', { stationId });
|
||||
} catch (error) {
|
||||
logger.error('Error invalidating station caches', { error, stationId });
|
||||
// Don't throw - cache invalidation failure shouldn't fail the operation
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map database row to Station object
|
||||
*/
|
||||
private mapStationRow(row: any): Station {
|
||||
return {
|
||||
id: row.id,
|
||||
placeId: row.place_id,
|
||||
name: row.name,
|
||||
address: row.address,
|
||||
latitude: parseFloat(row.latitude),
|
||||
longitude: parseFloat(row.longitude),
|
||||
priceRegular: row.price_regular ? parseFloat(row.price_regular) : undefined,
|
||||
pricePremium: row.price_premium ? parseFloat(row.price_premium) : undefined,
|
||||
priceDiesel: row.price_diesel ? parseFloat(row.price_diesel) : undefined,
|
||||
rating: row.rating ? parseFloat(row.rating) : undefined,
|
||||
photoReference: row.photo_reference,
|
||||
lastUpdated: row.cached_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
/**
|
||||
* @ai-summary Integration tests for admin station oversight API endpoints
|
||||
* @ai-context Tests complete request/response cycle with test database and admin guard
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import { app } from '../../../../app';
|
||||
import pool from '../../../../core/config/database';
|
||||
import { redis } from '../../../../core/config/redis';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { setAdminGuardPool } from '../../../../core/plugins/admin-guard.plugin';
|
||||
|
||||
// Mock auth plugin to inject test admin user
|
||||
jest.mock('../../../../core/plugins/auth.plugin', () => {
|
||||
return {
|
||||
default: fastifyPlugin(async function(fastify) {
|
||||
fastify.decorate('authenticate', async function(request, _reply) {
|
||||
// Inject test user context
|
||||
request.user = { sub: 'test-admin-123' };
|
||||
request.userContext = {
|
||||
userId: 'test-admin-123',
|
||||
email: 'test-admin@motovaultpro.com',
|
||||
isAdmin: false, // Will be set by admin guard
|
||||
};
|
||||
});
|
||||
}, { name: 'auth-plugin' })
|
||||
};
|
||||
});
|
||||
|
||||
describe('Admin Station Oversight Integration Tests', () => {
|
||||
let testAdminAuth0Sub: string;
|
||||
let testNonAdminAuth0Sub: string;
|
||||
let testUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Run admin migrations
|
||||
const adminMigrationFile = join(__dirname, '../../migrations/001_create_admin_users.sql');
|
||||
const adminMigrationSQL = readFileSync(adminMigrationFile, 'utf-8');
|
||||
await pool.query(adminMigrationSQL);
|
||||
|
||||
// Run stations migrations
|
||||
const stationsMigrationFile = join(__dirname, '../../../stations/migrations/001_create_stations_tables.sql');
|
||||
const stationsMigrationSQL = readFileSync(stationsMigrationFile, 'utf-8');
|
||||
await pool.query(stationsMigrationSQL);
|
||||
|
||||
// Set admin guard pool
|
||||
setAdminGuardPool(pool);
|
||||
|
||||
// Create test admin user
|
||||
testAdminAuth0Sub = 'test-admin-123';
|
||||
await pool.query(`
|
||||
INSERT INTO admin_users (auth0_sub, email, role, created_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (auth0_sub) DO NOTHING
|
||||
`, [testAdminAuth0Sub, 'test-admin@motovaultpro.com', 'admin', 'system']);
|
||||
|
||||
// Create test non-admin auth0Sub for permission tests
|
||||
testNonAdminAuth0Sub = 'test-non-admin-456';
|
||||
testUserId = 'test-user-789';
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up test database
|
||||
await pool.query('DROP TABLE IF EXISTS saved_stations CASCADE');
|
||||
await pool.query('DROP TABLE IF EXISTS station_cache CASCADE');
|
||||
await pool.query('DROP TABLE IF EXISTS admin_audit_logs CASCADE');
|
||||
await pool.query('DROP TABLE IF EXISTS admin_users CASCADE');
|
||||
await pool.end();
|
||||
await redis.quit();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up test data before each test
|
||||
await pool.query('DELETE FROM saved_stations');
|
||||
await pool.query('DELETE FROM station_cache');
|
||||
await pool.query('DELETE FROM admin_audit_logs');
|
||||
});
|
||||
|
||||
describe('Authorization', () => {
|
||||
it('should reject non-admin user trying to list stations', async () => {
|
||||
jest.isolateModules(() => {
|
||||
jest.mock('../../../../core/plugins/auth.plugin', () => {
|
||||
return {
|
||||
default: fastifyPlugin(async function(fastify) {
|
||||
fastify.decorate('authenticate', async function(request, _reply) {
|
||||
request.user = { sub: testNonAdminAuth0Sub };
|
||||
request.userContext = {
|
||||
userId: testNonAdminAuth0Sub,
|
||||
email: 'test-user@example.com',
|
||||
isAdmin: false,
|
||||
};
|
||||
});
|
||||
}, { name: 'auth-plugin' })
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/admin/stations')
|
||||
.expect(403);
|
||||
|
||||
expect(response.body.error).toBe('Forbidden');
|
||||
expect(response.body.message).toBe('Admin access required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/admin/stations', () => {
|
||||
it('should list all stations with pagination', async () => {
|
||||
// Create test stations
|
||||
await pool.query(`
|
||||
INSERT INTO station_cache (place_id, name, address, latitude, longitude, rating)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6),
|
||||
($7, $8, $9, $10, $11, $12),
|
||||
($13, $14, $15, $16, $17, $18)
|
||||
`, [
|
||||
'place1', 'Shell Station', '123 Main St', 40.7128, -74.0060, 4.5,
|
||||
'place2', 'Exxon Station', '456 Oak Ave', 40.7138, -74.0070, 4.2,
|
||||
'place3', 'BP Station', '789 Elm Rd', 40.7148, -74.0080, 4.7
|
||||
]);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/admin/stations?limit=10&offset=0')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('total');
|
||||
expect(response.body).toHaveProperty('stations');
|
||||
expect(response.body.total).toBe(3);
|
||||
expect(response.body.stations.length).toBe(3);
|
||||
expect(response.body.stations[0]).toMatchObject({
|
||||
placeId: expect.any(String),
|
||||
name: expect.any(String),
|
||||
address: expect.any(String),
|
||||
latitude: expect.any(Number),
|
||||
longitude: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('should support search by name', async () => {
|
||||
// Create test stations
|
||||
await pool.query(`
|
||||
INSERT INTO station_cache (place_id, name, address, latitude, longitude)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5),
|
||||
($6, $7, $8, $9, $10)
|
||||
`, [
|
||||
'place1', 'Shell Station', '123 Main St', 40.7128, -74.0060,
|
||||
'place2', 'Exxon Station', '456 Oak Ave', 40.7138, -74.0070
|
||||
]);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/admin/stations?search=Shell')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.total).toBe(1);
|
||||
expect(response.body.stations[0].name).toContain('Shell');
|
||||
});
|
||||
|
||||
it('should support pagination', async () => {
|
||||
// Create 5 test stations
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await pool.query(`
|
||||
INSERT INTO station_cache (place_id, name, address, latitude, longitude)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, [`place${i}`, `Station ${i}`, `${i} Main St`, 40.7128, -74.0060]);
|
||||
}
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/admin/stations?limit=2&offset=0')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.stations.length).toBe(2);
|
||||
expect(response.body.total).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/admin/stations', () => {
|
||||
it('should create a new station', async () => {
|
||||
const newStation = {
|
||||
placeId: 'new-place-123',
|
||||
name: 'New Shell Station',
|
||||
address: '999 Test Ave',
|
||||
latitude: 40.7200,
|
||||
longitude: -74.0100,
|
||||
priceRegular: 3.59,
|
||||
rating: 4.3
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/admin/stations')
|
||||
.send(newStation)
|
||||
.expect(201);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
placeId: 'new-place-123',
|
||||
name: 'New Shell Station',
|
||||
address: '999 Test Ave',
|
||||
latitude: 40.72,
|
||||
longitude: -74.01,
|
||||
});
|
||||
|
||||
// Verify audit log was created
|
||||
const auditResult = await pool.query(
|
||||
'SELECT * FROM admin_audit_logs WHERE action = $1 AND resource_id = $2',
|
||||
['CREATE', 'new-place-123']
|
||||
);
|
||||
expect(auditResult.rows.length).toBe(1);
|
||||
expect(auditResult.rows[0].actor_admin_id).toBe(testAdminAuth0Sub);
|
||||
|
||||
// Verify cache was invalidated
|
||||
const cacheKeys = await redis.keys('mvp:stations:*');
|
||||
expect(cacheKeys.length).toBe(0); // Should be cleared
|
||||
});
|
||||
|
||||
it('should reject missing required fields', async () => {
|
||||
const invalidStation = {
|
||||
name: 'Incomplete Station',
|
||||
address: '123 Test St',
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/admin/stations')
|
||||
.send(invalidStation)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error).toContain('Missing required fields');
|
||||
});
|
||||
|
||||
it('should handle duplicate placeId', async () => {
|
||||
const station = {
|
||||
placeId: 'duplicate-123',
|
||||
name: 'First Station',
|
||||
address: '123 Test Ave',
|
||||
latitude: 40.7200,
|
||||
longitude: -74.0100,
|
||||
};
|
||||
|
||||
// Create first station
|
||||
await request(app)
|
||||
.post('/api/admin/stations')
|
||||
.send(station)
|
||||
.expect(201);
|
||||
|
||||
// Try to create duplicate
|
||||
const response = await request(app)
|
||||
.post('/api/admin/stations')
|
||||
.send(station)
|
||||
.expect(409);
|
||||
|
||||
expect(response.body.error).toContain('already exists');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/admin/stations/:stationId', () => {
|
||||
it('should update an existing station', async () => {
|
||||
// Create station first
|
||||
await pool.query(`
|
||||
INSERT INTO station_cache (place_id, name, address, latitude, longitude)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, ['update-place', 'Old Name', '123 Old St', 40.7128, -74.0060]);
|
||||
|
||||
const updateData = {
|
||||
name: 'Updated Name',
|
||||
address: '456 New St',
|
||||
priceRegular: 3.75
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.put('/api/admin/stations/update-place')
|
||||
.send(updateData)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
placeId: 'update-place',
|
||||
name: 'Updated Name',
|
||||
address: '456 New St',
|
||||
priceRegular: 3.75
|
||||
});
|
||||
|
||||
// Verify audit log
|
||||
const auditResult = await pool.query(
|
||||
'SELECT * FROM admin_audit_logs WHERE action = $1 AND resource_id = $2',
|
||||
['UPDATE', 'update-place']
|
||||
);
|
||||
expect(auditResult.rows.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent station', async () => {
|
||||
const response = await request(app)
|
||||
.put('/api/admin/stations/nonexistent')
|
||||
.send({ name: 'Updated Name' })
|
||||
.expect(404);
|
||||
|
||||
expect(response.body.error).toBe('Station not found');
|
||||
});
|
||||
|
||||
it('should reject empty update', async () => {
|
||||
// Create station first
|
||||
await pool.query(`
|
||||
INSERT INTO station_cache (place_id, name, address, latitude, longitude)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, ['place-empty', 'Name', '123 St', 40.7128, -74.0060]);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/api/admin/stations/place-empty')
|
||||
.send({})
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error).toContain('No fields to update');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/admin/stations/:stationId', () => {
|
||||
it('should soft delete a station by default', async () => {
|
||||
// Create station first
|
||||
await pool.query(`
|
||||
INSERT INTO station_cache (place_id, name, address, latitude, longitude)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, ['soft-delete', 'Station to Delete', '123 Delete St', 40.7128, -74.0060]);
|
||||
|
||||
await request(app)
|
||||
.delete('/api/admin/stations/soft-delete')
|
||||
.expect(204);
|
||||
|
||||
// Verify station still exists but has deleted_at set
|
||||
const result = await pool.query(
|
||||
'SELECT deleted_at FROM station_cache WHERE place_id = $1',
|
||||
['soft-delete']
|
||||
);
|
||||
|
||||
// Station may not have deleted_at column initially, but should be handled
|
||||
expect(result.rows.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should hard delete with force flag', async () => {
|
||||
// Create station first
|
||||
await pool.query(`
|
||||
INSERT INTO station_cache (place_id, name, address, latitude, longitude)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, ['hard-delete', 'Station to Delete', '123 Delete St', 40.7128, -74.0060]);
|
||||
|
||||
await request(app)
|
||||
.delete('/api/admin/stations/hard-delete?force=true')
|
||||
.expect(204);
|
||||
|
||||
// Verify station is actually deleted
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM station_cache WHERE place_id = $1',
|
||||
['hard-delete']
|
||||
);
|
||||
expect(result.rows.length).toBe(0);
|
||||
|
||||
// Verify audit log
|
||||
const auditResult = await pool.query(
|
||||
'SELECT * FROM admin_audit_logs WHERE action = $1 AND resource_id = $2',
|
||||
['DELETE', 'hard-delete']
|
||||
);
|
||||
expect(auditResult.rows.length).toBe(1);
|
||||
expect(JSON.parse(auditResult.rows[0].context).force).toBe(true);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent station', async () => {
|
||||
const response = await request(app)
|
||||
.delete('/api/admin/stations/nonexistent')
|
||||
.expect(404);
|
||||
|
||||
expect(response.body.error).toBe('Station not found');
|
||||
});
|
||||
|
||||
it('should invalidate cache after deletion', async () => {
|
||||
// Create station and cache entry
|
||||
await pool.query(`
|
||||
INSERT INTO station_cache (place_id, name, address, latitude, longitude)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, ['cache-test', 'Station', '123 St', 40.7128, -74.0060]);
|
||||
|
||||
await redis.set('mvp:stations:test', JSON.stringify({ test: true }));
|
||||
|
||||
await request(app)
|
||||
.delete('/api/admin/stations/cache-test?force=true')
|
||||
.expect(204);
|
||||
|
||||
// Verify cache was cleared
|
||||
const cacheValue = await redis.get('mvp:stations:test');
|
||||
expect(cacheValue).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/admin/users/:userId/stations', () => {
|
||||
it('should get user saved stations', async () => {
|
||||
// Create station in cache
|
||||
await pool.query(`
|
||||
INSERT INTO station_cache (place_id, name, address, latitude, longitude)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, ['user-place', 'User Station', '123 User St', 40.7128, -74.0060]);
|
||||
|
||||
// Create saved station for user
|
||||
await pool.query(`
|
||||
INSERT INTO saved_stations (user_id, place_id, nickname, is_favorite)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, [testUserId, 'user-place', 'My Favorite Station', true]);
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/admin/users/${testUserId}/stations`)
|
||||
.expect(200);
|
||||
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect(response.body.length).toBe(1);
|
||||
expect(response.body[0]).toMatchObject({
|
||||
userId: testUserId,
|
||||
stationId: 'user-place',
|
||||
nickname: 'My Favorite Station',
|
||||
isFavorite: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array for user with no saved stations', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/admin/users/user-no-stations/stations')
|
||||
.expect(200);
|
||||
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect(response.body.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/admin/users/:userId/stations/:stationId', () => {
|
||||
it('should soft delete user saved station by default', async () => {
|
||||
// Create station and saved station
|
||||
await pool.query(`
|
||||
INSERT INTO station_cache (place_id, name, address, latitude, longitude)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, ['saved-place', 'Saved Station', '123 Saved St', 40.7128, -74.0060]);
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO saved_stations (user_id, place_id, nickname)
|
||||
VALUES ($1, $2, $3)
|
||||
`, [testUserId, 'saved-place', 'My Station']);
|
||||
|
||||
await request(app)
|
||||
.delete(`/api/admin/users/${testUserId}/stations/saved-place`)
|
||||
.expect(204);
|
||||
|
||||
// Verify soft delete (deleted_at set)
|
||||
const result = await pool.query(
|
||||
'SELECT deleted_at FROM saved_stations WHERE user_id = $1 AND place_id = $2',
|
||||
[testUserId, 'saved-place']
|
||||
);
|
||||
|
||||
expect(result.rows.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should hard delete with force flag', async () => {
|
||||
// Create station and saved station
|
||||
await pool.query(`
|
||||
INSERT INTO station_cache (place_id, name, address, latitude, longitude)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, ['force-delete', 'Station', '123 St', 40.7128, -74.0060]);
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO saved_stations (user_id, place_id, nickname)
|
||||
VALUES ($1, $2, $3)
|
||||
`, [testUserId, 'force-delete', 'My Station']);
|
||||
|
||||
await request(app)
|
||||
.delete(`/api/admin/users/${testUserId}/stations/force-delete?force=true`)
|
||||
.expect(204);
|
||||
|
||||
// Verify hard delete
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM saved_stations WHERE user_id = $1 AND place_id = $2',
|
||||
[testUserId, 'force-delete']
|
||||
);
|
||||
expect(result.rows.length).toBe(0);
|
||||
|
||||
// Verify audit log
|
||||
const auditResult = await pool.query(
|
||||
'SELECT * FROM admin_audit_logs WHERE action = $1 AND resource_id = $2',
|
||||
['DELETE', `${testUserId}:force-delete`]
|
||||
);
|
||||
expect(auditResult.rows.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent saved station', async () => {
|
||||
const response = await request(app)
|
||||
.delete(`/api/admin/users/${testUserId}/stations/nonexistent`)
|
||||
.expect(404);
|
||||
|
||||
expect(response.body.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should invalidate user cache after deletion', async () => {
|
||||
// Create saved station
|
||||
await pool.query(`
|
||||
INSERT INTO station_cache (place_id, name, address, latitude, longitude)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, ['cache-delete', 'Station', '123 St', 40.7128, -74.0060]);
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO saved_stations (user_id, place_id)
|
||||
VALUES ($1, $2)
|
||||
`, [testUserId, 'cache-delete']);
|
||||
|
||||
// Set user cache
|
||||
await redis.set(`mvp:stations:saved:${testUserId}`, JSON.stringify({ test: true }));
|
||||
|
||||
await request(app)
|
||||
.delete(`/api/admin/users/${testUserId}/stations/cache-delete?force=true`)
|
||||
.expect(204);
|
||||
|
||||
// Verify cache was cleared
|
||||
const cacheValue = await redis.get(`mvp:stations:saved:${testUserId}`);
|
||||
expect(cacheValue).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('End-to-end workflow', () => {
|
||||
it('should complete full station lifecycle with audit trail', async () => {
|
||||
// 1. Create station
|
||||
const createResponse = await request(app)
|
||||
.post('/api/admin/stations')
|
||||
.send({
|
||||
placeId: 'workflow-station',
|
||||
name: 'Workflow Station',
|
||||
address: '123 Workflow St',
|
||||
latitude: 40.7200,
|
||||
longitude: -74.0100,
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(createResponse.body.placeId).toBe('workflow-station');
|
||||
|
||||
// 2. List stations and verify it exists
|
||||
const listResponse = await request(app)
|
||||
.get('/api/admin/stations')
|
||||
.expect(200);
|
||||
|
||||
const station = listResponse.body.stations.find(
|
||||
(s: any) => s.placeId === 'workflow-station'
|
||||
);
|
||||
expect(station).toBeDefined();
|
||||
|
||||
// 3. Update station
|
||||
await request(app)
|
||||
.put('/api/admin/stations/workflow-station')
|
||||
.send({ name: 'Updated Workflow Station' })
|
||||
.expect(200);
|
||||
|
||||
// 4. User saves the station
|
||||
await pool.query(`
|
||||
INSERT INTO saved_stations (user_id, place_id, nickname)
|
||||
VALUES ($1, $2, $3)
|
||||
`, [testUserId, 'workflow-station', 'My Workflow Station']);
|
||||
|
||||
// 5. Admin views user's saved stations
|
||||
const userStationsResponse = await request(app)
|
||||
.get(`/api/admin/users/${testUserId}/stations`)
|
||||
.expect(200);
|
||||
|
||||
expect(userStationsResponse.body.length).toBe(1);
|
||||
|
||||
// 6. Admin removes user's saved station
|
||||
await request(app)
|
||||
.delete(`/api/admin/users/${testUserId}/stations/workflow-station?force=true`)
|
||||
.expect(204);
|
||||
|
||||
// 7. Admin deletes station
|
||||
await request(app)
|
||||
.delete('/api/admin/stations/workflow-station?force=true')
|
||||
.expect(204);
|
||||
|
||||
// 8. Verify complete audit trail
|
||||
const auditResponse = await pool.query(
|
||||
'SELECT * FROM admin_audit_logs WHERE resource_id LIKE $1 OR resource_id = $2 ORDER BY created_at ASC',
|
||||
['%workflow-station%', 'workflow-station']
|
||||
);
|
||||
|
||||
expect(auditResponse.rows.length).toBeGreaterThanOrEqual(3);
|
||||
const actions = auditResponse.rows.map((log: any) => log.action);
|
||||
expect(actions).toContain('CREATE');
|
||||
expect(actions).toContain('UPDATE');
|
||||
expect(actions).toContain('DELETE');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user