📋 What Was Accomplished Backend Changes: - ✅ Enhanced API Endpoint: Updated GET /api/v1/events to accept optional date query parameter - ✅ Input Validation: Added YYYY-MM-DD format validation to PaginationQueryDto - ✅ Database Filtering: Implemented timezone-aware date filtering in EventsService - ✅ Controller Integration: Updated EventsController to pass date parameter to service Frontend Changes: - ✅ Date Picker Component: Created reusable DatePicker component following project design system - ✅ Gallery UI Enhancement: Integrated date picker into gallery page with clear labeling - ✅ State Management: Implemented reactive date state with automatic re-fetching - ✅ Clear Filter Functionality: Added "Clear Filter" button for easy reset - ✅ Enhanced UX: Improved empty states for filtered vs unfiltered views 🔍 Technical Implementation API Design: GET /api/v1/events?date=2025-08-02&limit=20&cursor=xxx Key Files Modified: - meteor-web-backend/src/events/dto/pagination-query.dto.ts - meteor-web-backend/src/events/events.service.ts - meteor-web-backend/src/events/events.controller.ts - meteor-frontend/src/components/ui/date-picker.tsx (new) - meteor-frontend/src/app/gallery/page.tsx - meteor-frontend/src/hooks/use-events.ts - meteor-frontend/src/services/events.ts ✅ All Acceptance Criteria Met 1. ✅ Backend API Enhancement: Accepts optional date parameter 2. ✅ Date Filtering Logic: Returns events for specific calendar date 3. ✅ Date Picker UI: Clean, accessible interface component 4. ✅ Automatic Re-fetching: Immediate data updates on date selection 5. ✅ Filtered Display: Correctly shows only events for selected date 6. ✅ Clear Filter: One-click reset to view all events 🧪 Quality Assurance - ✅ Backend Build: Successful compilation with no errors - ✅ Frontend Build: Successful Next.js build with no warnings - ✅ Linting: All ESLint checks pass - ✅ Functionality: Feature working as specified 🎉 Epic 2 Complete! With Story 2.9 completion, Epic 2: Commercialization & Core User Experience is now DONE! Epic 2 Achievements: - 🔐 Full-stack device status monitoring - 💳 Robust payment and subscription system - 🛡️ Subscription-based access control - 📊 Enhanced data browsing with detail pages - 📅 Date-based event filtering
450 lines
14 KiB
TypeScript
450 lines
14 KiB
TypeScript
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
import { JwtModule } from '@nestjs/jwt';
|
|
import * as request from 'supertest';
|
|
import { Repository } from 'typeorm';
|
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
|
import { DevicesModule } from '../src/devices/devices.module';
|
|
import { AuthModule } from '../src/auth/auth.module';
|
|
import { Device } from '../src/entities/device.entity';
|
|
import { InventoryDevice } from '../src/entities/inventory-device.entity';
|
|
import { UserProfile } from '../src/entities/user-profile.entity';
|
|
import { UserIdentity } from '../src/entities/user-identity.entity';
|
|
|
|
describe('Devices (e2e)', () => {
|
|
let app: INestApplication;
|
|
let deviceRepository: Repository<Device>;
|
|
let inventoryDeviceRepository: Repository<InventoryDevice>;
|
|
let userProfileRepository: Repository<UserProfile>;
|
|
let userIdentityRepository: Repository<UserIdentity>;
|
|
|
|
const testDatabaseUrl = process.env.TEST_DATABASE_URL;
|
|
|
|
beforeAll(async () => {
|
|
if (!testDatabaseUrl) {
|
|
throw new Error('TEST_DATABASE_URL environment variable is required');
|
|
}
|
|
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
imports: [
|
|
TypeOrmModule.forRoot({
|
|
type: 'postgres',
|
|
url: testDatabaseUrl,
|
|
entities: [UserProfile, UserIdentity, Device, InventoryDevice],
|
|
synchronize: false,
|
|
logging: false,
|
|
}),
|
|
AuthModule,
|
|
DevicesModule,
|
|
JwtModule.register({
|
|
secret: 'test-secret',
|
|
signOptions: { expiresIn: '1h' },
|
|
}),
|
|
],
|
|
}).compile();
|
|
|
|
app = moduleFixture.createNestApplication();
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({
|
|
whitelist: true,
|
|
forbidNonWhitelisted: true,
|
|
transform: true,
|
|
}),
|
|
);
|
|
|
|
await app.init();
|
|
|
|
deviceRepository = moduleFixture.get<Repository<Device>>(
|
|
getRepositoryToken(Device),
|
|
);
|
|
inventoryDeviceRepository = moduleFixture.get<Repository<InventoryDevice>>(
|
|
getRepositoryToken(InventoryDevice),
|
|
);
|
|
userProfileRepository = moduleFixture.get<Repository<UserProfile>>(
|
|
getRepositoryToken(UserProfile),
|
|
);
|
|
userIdentityRepository = moduleFixture.get<Repository<UserIdentity>>(
|
|
getRepositoryToken(UserIdentity),
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Clean up test data
|
|
await deviceRepository.delete({});
|
|
await inventoryDeviceRepository.delete({});
|
|
await userIdentityRepository.delete({});
|
|
await userProfileRepository.delete({});
|
|
});
|
|
|
|
describe('POST /api/v1/devices/register', () => {
|
|
let authToken: string;
|
|
let testUserProfile: UserProfile;
|
|
|
|
beforeEach(async () => {
|
|
// Create test user
|
|
testUserProfile = userProfileRepository.create({
|
|
displayName: 'Test User',
|
|
});
|
|
await userProfileRepository.save(testUserProfile);
|
|
|
|
const testUserIdentity = userIdentityRepository.create({
|
|
userProfileId: testUserProfile.id,
|
|
provider: 'email',
|
|
providerId: 'test@example.com',
|
|
email: 'test@example.com',
|
|
passwordHash: 'hashed_password',
|
|
});
|
|
await userIdentityRepository.save(testUserIdentity);
|
|
|
|
// Register user and get token
|
|
const registerResponse = await request(app.getHttpServer())
|
|
.post('/api/v1/auth/register-email')
|
|
.send({
|
|
email: 'auth-test@example.com',
|
|
password: 'TestPassword123',
|
|
displayName: 'Auth Test User',
|
|
});
|
|
|
|
const loginResponse = await request(app.getHttpServer())
|
|
.post('/api/v1/auth/login-email')
|
|
.send({
|
|
email: 'auth-test@example.com',
|
|
password: 'TestPassword123',
|
|
});
|
|
|
|
authToken = loginResponse.body.accessToken;
|
|
});
|
|
|
|
it('should reject unauthenticated requests', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/devices/register')
|
|
.send({
|
|
hardwareId: 'TEST_DEVICE_001',
|
|
});
|
|
|
|
expect(response.status).toBe(401);
|
|
});
|
|
|
|
it('should reject invalid hardware_id format', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/devices/register')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
hardwareId: 'a@b', // Invalid format
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.message).toContain(
|
|
'Hardware ID can only contain alphanumeric characters',
|
|
);
|
|
});
|
|
|
|
it('should reject hardware_id that is too short', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/devices/register')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
hardwareId: 'ab', // Too short
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.message).toContain(
|
|
'Hardware ID must be at least 3 characters long',
|
|
);
|
|
});
|
|
|
|
it('should reject device not in inventory', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/devices/register')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
hardwareId: 'NON_EXISTENT_DEVICE',
|
|
});
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(response.body.message).toContain('not found in inventory');
|
|
});
|
|
|
|
it('should reject already claimed device', async () => {
|
|
// Create inventory device that is already claimed
|
|
const inventoryDevice = inventoryDeviceRepository.create({
|
|
hardwareId: 'CLAIMED_DEVICE_001',
|
|
isClaimed: true,
|
|
});
|
|
await inventoryDeviceRepository.save(inventoryDevice);
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/devices/register')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
hardwareId: 'CLAIMED_DEVICE_001',
|
|
});
|
|
|
|
expect(response.status).toBe(409);
|
|
expect(response.body.message).toContain('has already been claimed');
|
|
});
|
|
|
|
it('should successfully register a legitimate unclaimed device', async () => {
|
|
// Create unclaimed inventory device
|
|
const inventoryDevice = inventoryDeviceRepository.create({
|
|
hardwareId: 'LEGITIMATE_DEVICE_001',
|
|
isClaimed: false,
|
|
deviceModel: 'TestModel v1.0',
|
|
});
|
|
await inventoryDeviceRepository.save(inventoryDevice);
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/devices/register')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
hardwareId: 'LEGITIMATE_DEVICE_001',
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.message).toBe('Device registered successfully');
|
|
expect(response.body.device).toBeDefined();
|
|
expect(response.body.device.hardwareId).toBe('LEGITIMATE_DEVICE_001');
|
|
expect(response.body.device.userProfileId).toBeDefined();
|
|
|
|
// Verify device was created in devices table
|
|
const createdDevice = await deviceRepository.findOne({
|
|
where: { hardwareId: 'LEGITIMATE_DEVICE_001' },
|
|
});
|
|
expect(createdDevice).toBeDefined();
|
|
|
|
// Verify inventory device was marked as claimed
|
|
const updatedInventoryDevice = await inventoryDeviceRepository.findOne({
|
|
where: { hardwareId: 'LEGITIMATE_DEVICE_001' },
|
|
});
|
|
expect(updatedInventoryDevice?.isClaimed).toBe(true);
|
|
});
|
|
|
|
it('should reject registering the same device twice', async () => {
|
|
// Create unclaimed inventory device
|
|
const inventoryDevice = inventoryDeviceRepository.create({
|
|
hardwareId: 'DUPLICATE_TEST_DEVICE',
|
|
isClaimed: false,
|
|
});
|
|
await inventoryDeviceRepository.save(inventoryDevice);
|
|
|
|
// First registration should succeed
|
|
const firstResponse = await request(app.getHttpServer())
|
|
.post('/api/v1/devices/register')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
hardwareId: 'DUPLICATE_TEST_DEVICE',
|
|
});
|
|
|
|
expect(firstResponse.status).toBe(201);
|
|
|
|
// Second registration should fail
|
|
const secondResponse = await request(app.getHttpServer())
|
|
.post('/api/v1/devices/register')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
hardwareId: 'DUPLICATE_TEST_DEVICE',
|
|
});
|
|
|
|
expect(secondResponse.status).toBe(409);
|
|
expect(secondResponse.body.message).toContain('has already been claimed');
|
|
});
|
|
});
|
|
|
|
describe('GET /api/v1/devices', () => {
|
|
let authToken: string;
|
|
|
|
beforeEach(async () => {
|
|
// Register and login user
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/auth/register-email')
|
|
.send({
|
|
email: 'list-test@example.com',
|
|
password: 'TestPassword123',
|
|
displayName: 'List Test User',
|
|
});
|
|
|
|
const loginResponse = await request(app.getHttpServer())
|
|
.post('/api/v1/auth/login-email')
|
|
.send({
|
|
email: 'list-test@example.com',
|
|
password: 'TestPassword123',
|
|
});
|
|
|
|
authToken = loginResponse.body.accessToken;
|
|
});
|
|
|
|
it('should return empty list for user with no devices', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v1/devices')
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.devices).toEqual([]);
|
|
});
|
|
|
|
it('should require authentication', async () => {
|
|
const response = await request(app.getHttpServer()).get(
|
|
'/api/v1/devices',
|
|
);
|
|
|
|
expect(response.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/v1/devices/heartbeat', () => {
|
|
let authToken: string;
|
|
let testDevice: Device;
|
|
|
|
beforeEach(async () => {
|
|
// Register and login user
|
|
const registerResponse = await request(app.getHttpServer())
|
|
.post('/api/v1/auth/register-email')
|
|
.send({
|
|
email: 'heartbeat-test@example.com',
|
|
password: 'TestPassword123',
|
|
displayName: 'Heartbeat Test User',
|
|
});
|
|
|
|
const loginResponse = await request(app.getHttpServer())
|
|
.post('/api/v1/auth/login-email')
|
|
.send({
|
|
email: 'heartbeat-test@example.com',
|
|
password: 'TestPassword123',
|
|
});
|
|
|
|
authToken = loginResponse.body.accessToken;
|
|
|
|
// Create inventory device
|
|
const inventoryDevice = inventoryDeviceRepository.create({
|
|
hardwareId: 'HEARTBEAT_TEST_DEVICE',
|
|
isClaimed: false,
|
|
});
|
|
await inventoryDeviceRepository.save(inventoryDevice);
|
|
|
|
// Register device
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/devices/register')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
hardwareId: 'HEARTBEAT_TEST_DEVICE',
|
|
});
|
|
|
|
// Get the created device
|
|
testDevice = await deviceRepository.findOne({
|
|
where: { hardwareId: 'HEARTBEAT_TEST_DEVICE' },
|
|
});
|
|
|
|
if (!testDevice) {
|
|
throw new Error('Test device not found after registration');
|
|
}
|
|
});
|
|
|
|
it('should reject unauthenticated requests', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/devices/heartbeat')
|
|
.send({
|
|
hardwareId: 'HEARTBEAT_TEST_DEVICE',
|
|
});
|
|
|
|
expect(response.status).toBe(401);
|
|
});
|
|
|
|
it('should reject invalid hardware_id format', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/devices/heartbeat')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
hardwareId: 'a@b', // Invalid format
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.message).toContain(
|
|
'Hardware ID can only contain alphanumeric characters',
|
|
);
|
|
});
|
|
|
|
it('should reject heartbeat for non-existent device', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/devices/heartbeat')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
hardwareId: 'NON_EXISTENT_DEVICE',
|
|
});
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(response.body.message).toContain('not found');
|
|
});
|
|
|
|
it('should reject heartbeat for device belonging to different user', async () => {
|
|
// Register another user
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/auth/register-email')
|
|
.send({
|
|
email: 'other-user@example.com',
|
|
password: 'TestPassword123',
|
|
displayName: 'Other User',
|
|
});
|
|
|
|
const otherUserLoginResponse = await request(app.getHttpServer())
|
|
.post('/api/v1/auth/login-email')
|
|
.send({
|
|
email: 'other-user@example.com',
|
|
password: 'TestPassword123',
|
|
});
|
|
|
|
const otherUserToken = otherUserLoginResponse.body.accessToken;
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/devices/heartbeat')
|
|
.set('Authorization', `Bearer ${otherUserToken}`)
|
|
.send({
|
|
hardwareId: 'HEARTBEAT_TEST_DEVICE',
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.message).toContain(
|
|
'Device does not belong to the authenticated user',
|
|
);
|
|
});
|
|
|
|
it('should successfully process heartbeat for valid device', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/devices/heartbeat')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
hardwareId: 'HEARTBEAT_TEST_DEVICE',
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.message).toBe('Heartbeat processed successfully');
|
|
|
|
// Verify device was updated
|
|
const updatedDevice = await deviceRepository.findOne({
|
|
where: { id: testDevice.id },
|
|
});
|
|
|
|
expect(updatedDevice).toBeDefined();
|
|
expect(updatedDevice!.status).toBe('online');
|
|
expect(updatedDevice!.lastSeenAt).toBeDefined();
|
|
expect(new Date(updatedDevice!.lastSeenAt!).getTime()).toBeGreaterThan(
|
|
new Date().getTime() - 10000, // Within last 10 seconds
|
|
);
|
|
});
|
|
|
|
it('should require hardwareId in request body', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/devices/heartbeat')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({}); // Empty body
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.message).toContain('hardwareId');
|
|
});
|
|
});
|
|
});
|