349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
import { JwtModule, JwtService } from '@nestjs/jwt';
|
|
import * as request from 'supertest';
|
|
import { DataSource } from 'typeorm';
|
|
import * as bcrypt from 'bcrypt';
|
|
|
|
import { AuthModule } from '../src/modules/auth/auth.module';
|
|
import { UsersModule } from '../src/modules/users/users.module';
|
|
import { StoresModule } from '../src/modules/stores/stores.module';
|
|
import { InventoryModule } from '../src/modules/inventory/inventory.module';
|
|
import { CreditsModule } from '../src/modules/credits/credits.module';
|
|
import { User } from '../src/modules/users/entities/user.entity';
|
|
import { Store } from '../src/modules/stores/entities/store.entity';
|
|
import { StoreUser } from '../src/modules/stores/entities/store-user.entity';
|
|
import { InventoryItem } from '../src/modules/inventory/entities/inventory-item.entity';
|
|
import { Video } from '../src/modules/videos/entities/video.entity';
|
|
import { Otp } from '../src/modules/auth/entities/otp.entity';
|
|
import { RefreshToken } from '../src/modules/auth/entities/refresh-token.entity';
|
|
import { CreditBalance } from '../src/modules/credits/entities/credit-balance.entity';
|
|
import { CreditTransaction } from '../src/modules/credits/entities/credit-transaction.entity';
|
|
import { CreditPackage } from '../src/modules/credits/entities/credit-package.entity';
|
|
|
|
describe('InventoryController (e2e)', () => {
|
|
let app: INestApplication;
|
|
let dataSource: DataSource;
|
|
let jwtService: JwtService;
|
|
let accessToken: string;
|
|
let userId: string;
|
|
let storeId: string;
|
|
|
|
beforeAll(async () => {
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
imports: [
|
|
ConfigModule.forRoot({
|
|
isGlobal: true,
|
|
}),
|
|
TypeOrmModule.forRootAsync({
|
|
imports: [ConfigModule],
|
|
useFactory: () => ({
|
|
type: 'postgres',
|
|
host: process.env.DATABASE_HOST || 'localhost',
|
|
port: parseInt(process.env.DATABASE_PORT || '5433'),
|
|
username: process.env.DATABASE_USER || 'postgres',
|
|
password: process.env.DATABASE_PASSWORD || 'postgres',
|
|
database: process.env.DATABASE_NAME || 'miinventario_test',
|
|
entities: [
|
|
User, Store, StoreUser, InventoryItem, Video,
|
|
Otp, RefreshToken, CreditBalance, CreditTransaction, CreditPackage
|
|
],
|
|
synchronize: true,
|
|
dropSchema: true,
|
|
}),
|
|
inject: [ConfigService],
|
|
}),
|
|
JwtModule.register({
|
|
secret: 'test-secret',
|
|
signOptions: { expiresIn: '15m' },
|
|
}),
|
|
AuthModule,
|
|
UsersModule,
|
|
StoresModule,
|
|
InventoryModule,
|
|
CreditsModule,
|
|
],
|
|
})
|
|
.overrideProvider(ConfigService)
|
|
.useValue({
|
|
get: (key: string, defaultValue?: any) => {
|
|
const config: Record<string, any> = {
|
|
NODE_ENV: 'development',
|
|
DATABASE_HOST: process.env.DATABASE_HOST || 'localhost',
|
|
DATABASE_PORT: parseInt(process.env.DATABASE_PORT || '5433'),
|
|
DATABASE_NAME: process.env.DATABASE_NAME || 'miinventario_test',
|
|
DATABASE_USER: process.env.DATABASE_USER || 'postgres',
|
|
DATABASE_PASSWORD: process.env.DATABASE_PASSWORD || 'postgres',
|
|
JWT_SECRET: 'test-secret',
|
|
JWT_EXPIRES_IN: '15m',
|
|
JWT_REFRESH_SECRET: 'test-refresh-secret',
|
|
JWT_REFRESH_EXPIRES_IN: '7d',
|
|
};
|
|
return config[key] ?? defaultValue;
|
|
},
|
|
})
|
|
.compile();
|
|
|
|
app = moduleFixture.createNestApplication();
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({
|
|
whitelist: true,
|
|
forbidNonWhitelisted: true,
|
|
transform: true,
|
|
}),
|
|
);
|
|
|
|
await app.init();
|
|
dataSource = moduleFixture.get<DataSource>(DataSource);
|
|
jwtService = moduleFixture.get<JwtService>(JwtService);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (dataSource?.isInitialized) {
|
|
await dataSource.destroy();
|
|
}
|
|
await app.close();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Clean up - order matters due to foreign keys
|
|
await dataSource.query('DELETE FROM inventory_items');
|
|
await dataSource.query('DELETE FROM videos');
|
|
await dataSource.query('DELETE FROM store_users');
|
|
await dataSource.query('DELETE FROM stores');
|
|
await dataSource.query('DELETE FROM credit_transactions');
|
|
await dataSource.query('DELETE FROM credit_balances');
|
|
await dataSource.query('DELETE FROM refresh_tokens');
|
|
await dataSource.query('DELETE FROM otps');
|
|
await dataSource.query('DELETE FROM users');
|
|
|
|
// Create test user
|
|
const passwordHash = await bcrypt.hash('testpassword123', 10);
|
|
const userResult = await dataSource.query(
|
|
`INSERT INTO users (id, phone, name, "passwordHash", role, "isActive")
|
|
VALUES (uuid_generate_v4(), '5512345678', 'Test User', $1, 'USER', true)
|
|
RETURNING id`,
|
|
[passwordHash],
|
|
);
|
|
userId = userResult[0].id;
|
|
|
|
// Create credit balance
|
|
await dataSource.query(
|
|
`INSERT INTO credit_balances (id, "userId", balance, "totalPurchased", "totalConsumed", "totalFromReferrals")
|
|
VALUES (uuid_generate_v4(), $1, 100, 100, 0, 0)`,
|
|
[userId],
|
|
);
|
|
|
|
// Create test store
|
|
const storeResult = await dataSource.query(
|
|
`INSERT INTO stores (id, "ownerId", name, giro, address, "isActive")
|
|
VALUES (uuid_generate_v4(), $1, 'Test Store', 'Abarrotes', 'Test Address', true)
|
|
RETURNING id`,
|
|
[userId],
|
|
);
|
|
storeId = storeResult[0].id;
|
|
|
|
// Create inventory items
|
|
await dataSource.query(
|
|
`INSERT INTO inventory_items (id, "storeId", name, category, quantity, price, "minStock")
|
|
VALUES
|
|
(uuid_generate_v4(), $1, 'Coca Cola 600ml', 'Bebidas', 50, 18.00, 10),
|
|
(uuid_generate_v4(), $1, 'Sabritas Original', 'Snacks', 25, 15.50, 5),
|
|
(uuid_generate_v4(), $1, 'Pan Bimbo', 'Panaderia', 3, 45.00, 5)`,
|
|
[storeId],
|
|
);
|
|
|
|
// Generate token
|
|
accessToken = jwtService.sign({ sub: userId });
|
|
});
|
|
|
|
describe('GET /stores/:storeId/inventory', () => {
|
|
it('should return inventory items for the store', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/stores/${storeId}/inventory`)
|
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
.expect(200);
|
|
|
|
expect(response.body.items).toHaveLength(3);
|
|
expect(response.body.total).toBe(3);
|
|
expect(response.body.page).toBe(1);
|
|
expect(response.body).toHaveProperty('hasMore');
|
|
});
|
|
|
|
it('should support pagination', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/stores/${storeId}/inventory?page=1&limit=2`)
|
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
.expect(200);
|
|
|
|
expect(response.body.items).toHaveLength(2);
|
|
expect(response.body.limit).toBe(2);
|
|
});
|
|
|
|
it('should fail without authentication', async () => {
|
|
await request(app.getHttpServer())
|
|
.get(`/stores/${storeId}/inventory`)
|
|
.expect(401);
|
|
});
|
|
|
|
it('should fail for store owned by another user', async () => {
|
|
// Create another user and store
|
|
const otherUserResult = await dataSource.query(
|
|
`INSERT INTO users (id, phone, name, role, "isActive")
|
|
VALUES (uuid_generate_v4(), '9999999999', 'Other User', 'USER', true)
|
|
RETURNING id`,
|
|
);
|
|
const otherUserId = otherUserResult[0].id;
|
|
|
|
const otherStoreResult = await dataSource.query(
|
|
`INSERT INTO stores (id, "ownerId", name, giro, "isActive")
|
|
VALUES (uuid_generate_v4(), $1, 'Other Store', 'Farmacia', true)
|
|
RETURNING id`,
|
|
[otherUserId],
|
|
);
|
|
const otherStoreId = otherStoreResult[0].id;
|
|
|
|
// The API returns 403 Forbidden when accessing stores you don't own
|
|
await request(app.getHttpServer())
|
|
.get(`/stores/${otherStoreId}/inventory`)
|
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
.expect(403);
|
|
});
|
|
});
|
|
|
|
describe('GET /stores/:storeId/inventory/statistics', () => {
|
|
it('should return inventory statistics', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/stores/${storeId}/inventory/statistics`)
|
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
.expect(200);
|
|
|
|
expect(response.body).toHaveProperty('totalItems');
|
|
expect(response.body).toHaveProperty('totalValue');
|
|
expect(response.body).toHaveProperty('lowStockCount');
|
|
});
|
|
});
|
|
|
|
describe('GET /stores/:storeId/inventory/low-stock', () => {
|
|
it('should return low stock items', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/stores/${storeId}/inventory/low-stock`)
|
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
.expect(200);
|
|
|
|
// Pan Bimbo has quantity 3 and minStock 5, so it should be low stock
|
|
expect(response.body).toBeInstanceOf(Array);
|
|
const lowStockItem = response.body.find((item: any) => item.name === 'Pan Bimbo');
|
|
expect(lowStockItem).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('GET /stores/:storeId/inventory/categories', () => {
|
|
it('should return categories', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/stores/${storeId}/inventory/categories`)
|
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
.expect(200);
|
|
|
|
expect(response.body).toBeInstanceOf(Array);
|
|
expect(response.body.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('GET /stores/:storeId/inventory/:itemId', () => {
|
|
let itemId: string;
|
|
|
|
beforeEach(async () => {
|
|
const result = await dataSource.query(
|
|
`SELECT id FROM inventory_items WHERE "storeId" = $1 LIMIT 1`,
|
|
[storeId],
|
|
);
|
|
itemId = result[0].id;
|
|
});
|
|
|
|
it('should return a specific inventory item', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/stores/${storeId}/inventory/${itemId}`)
|
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
.expect(200);
|
|
|
|
expect(response.body).toHaveProperty('id', itemId);
|
|
expect(response.body).toHaveProperty('name');
|
|
expect(response.body).toHaveProperty('quantity');
|
|
});
|
|
|
|
it('should fail for non-existent item', async () => {
|
|
await request(app.getHttpServer())
|
|
.get(`/stores/${storeId}/inventory/00000000-0000-0000-0000-000000000000`)
|
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
.expect(404);
|
|
});
|
|
});
|
|
|
|
describe('PATCH /stores/:storeId/inventory/:itemId', () => {
|
|
let itemId: string;
|
|
|
|
beforeEach(async () => {
|
|
const result = await dataSource.query(
|
|
`SELECT id FROM inventory_items WHERE "storeId" = $1 LIMIT 1`,
|
|
[storeId],
|
|
);
|
|
itemId = result[0].id;
|
|
});
|
|
|
|
it('should update inventory item', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.patch(`/stores/${storeId}/inventory/${itemId}`)
|
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
.send({ quantity: 100, price: 20.00 })
|
|
.expect(200);
|
|
|
|
expect(response.body.quantity).toBe(100);
|
|
expect(parseFloat(response.body.price)).toBe(20.00);
|
|
});
|
|
|
|
it('should fail for non-existent item', async () => {
|
|
await request(app.getHttpServer())
|
|
.patch(`/stores/${storeId}/inventory/00000000-0000-0000-0000-000000000000`)
|
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
.send({ quantity: 100 })
|
|
.expect(404);
|
|
});
|
|
});
|
|
|
|
describe('DELETE /stores/:storeId/inventory/:itemId', () => {
|
|
let itemId: string;
|
|
|
|
beforeEach(async () => {
|
|
const result = await dataSource.query(
|
|
`SELECT id FROM inventory_items WHERE "storeId" = $1 LIMIT 1`,
|
|
[storeId],
|
|
);
|
|
itemId = result[0].id;
|
|
});
|
|
|
|
it('should delete inventory item', async () => {
|
|
await request(app.getHttpServer())
|
|
.delete(`/stores/${storeId}/inventory/${itemId}`)
|
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
.expect(200);
|
|
|
|
// Verify item is deleted
|
|
const items = await dataSource.query(
|
|
'SELECT * FROM inventory_items WHERE id = $1',
|
|
[itemId],
|
|
);
|
|
expect(items).toHaveLength(0);
|
|
});
|
|
|
|
it('should fail for non-existent item', async () => {
|
|
await request(app.getHttpServer())
|
|
.delete(`/stores/${storeId}/inventory/00000000-0000-0000-0000-000000000000`)
|
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
.expect(404);
|
|
});
|
|
});
|
|
});
|