Browse Source
Merge pull request #63 from vernu/fix-docker
Merge pull request #63 from vernu/fix-docker
fix docker issues and improve test coveragepull/64/head
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1289 additions and 337 deletions
-
64.github/workflows/build-and-test.yaml
-
32.github/workflows/docker-publish.yaml
-
15api/.env.example
-
72api/Dockerfile
-
18api/src/auth/auth.controller.spec.ts
-
18api/src/auth/auth.service.spec.ts
-
2api/src/auth/auth.service.ts
-
18api/src/billing/billing.controller.spec.ts
-
18api/src/billing/billing.service.spec.ts
-
227api/src/billing/billing.service.ts
-
18api/src/gateway/gateway.controller.spec.ts
-
775api/src/gateway/gateway.service.spec.ts
-
8api/src/gateway/gateway.service.ts
-
9api/src/main.ts
-
2api/src/users/schemas/user.schema.ts
-
18api/src/users/users.controller.spec.ts
-
18api/src/users/users.service.spec.ts
-
2api/src/webhook/schemas/webhook-notification.schema.ts
-
2api/src/webhook/schemas/webhook-subscription.schema.ts
-
163docker-compose.yaml
-
11mongo-init/init.js
-
2web/.dockerignore
-
2web/.env.example
-
90web/Dockerfile
-
8web/lib/auth.ts
-
14web/lib/httpServerClient.ts
@ -0,0 +1,64 @@ |
|||||
|
name: Build and Test |
||||
|
|
||||
|
on: |
||||
|
push: |
||||
|
paths: |
||||
|
- 'api/**' |
||||
|
- 'web/**' |
||||
|
- '.github/workflows/build-and-test.yaml' |
||||
|
|
||||
|
workflow_dispatch: |
||||
|
inputs: |
||||
|
branch: |
||||
|
description: 'Branch to run workflow on' |
||||
|
required: true |
||||
|
default: 'main' |
||||
|
type: string |
||||
|
|
||||
|
jobs: |
||||
|
build-and-test: |
||||
|
name: Build and Test |
||||
|
runs-on: ubuntu-latest |
||||
|
|
||||
|
steps: |
||||
|
- name: Checkout repository |
||||
|
uses: actions/checkout@v4 |
||||
|
with: |
||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }} |
||||
|
|
||||
|
- name: Set up Node.js |
||||
|
uses: actions/setup-node@v3 |
||||
|
with: |
||||
|
node-version: '18' |
||||
|
|
||||
|
- name: Install pnpm |
||||
|
uses: pnpm/action-setup@v2 |
||||
|
with: |
||||
|
version: 8 |
||||
|
run_install: false |
||||
|
|
||||
|
- name: Get pnpm store directory |
||||
|
shell: bash |
||||
|
run: | |
||||
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV |
||||
|
|
||||
|
- name: Setup pnpm cache |
||||
|
uses: actions/cache@v3 |
||||
|
with: |
||||
|
path: ${{ env.STORE_PATH }} |
||||
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} |
||||
|
restore-keys: | |
||||
|
${{ runner.os }}-pnpm-store- |
||||
|
|
||||
|
- name: Build and test API |
||||
|
run: | |
||||
|
cd api |
||||
|
pnpm install |
||||
|
pnpm run build |
||||
|
pnpm test |
||||
|
|
||||
|
- name: Build web |
||||
|
run: | |
||||
|
cd web |
||||
|
pnpm install |
||||
|
pnpm run build |
||||
@ -1,25 +1,61 @@ |
|||||
FROM node:18-alpine AS base |
|
||||
RUN npm i -g pnpm |
|
||||
|
# Stage 1: Dependencies |
||||
|
FROM node:22-alpine AS deps |
||||
WORKDIR /app |
WORKDIR /app |
||||
|
|
||||
|
# Install pnpm |
||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate |
||||
|
|
||||
|
# Copy package.json and pnpm-lock.yaml |
||||
COPY package.json pnpm-lock.yaml ./ |
COPY package.json pnpm-lock.yaml ./ |
||||
RUN pnpm i |
|
||||
COPY . . |
|
||||
|
|
||||
FROM base AS dev |
|
||||
ENV NODE_ENV=development |
|
||||
ENTRYPOINT ["pnpm", "start:dev"] |
|
||||
|
# Install dependencies |
||||
|
RUN pnpm install --frozen-lockfile |
||||
|
|
||||
|
# Stage 2: Builder |
||||
|
FROM node:22-alpine AS builder |
||||
|
WORKDIR /app |
||||
|
|
||||
|
# Install pnpm |
||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate |
||||
|
|
||||
|
# Copy dependencies from deps stage |
||||
|
COPY --from=deps /app/node_modules ./node_modules |
||||
|
COPY . . |
||||
|
|
||||
FROM base AS build |
|
||||
ENV NODE_ENV=production |
|
||||
|
# Build the application |
||||
RUN pnpm build |
RUN pnpm build |
||||
|
|
||||
FROM node:18-alpine AS prod |
|
||||
ENV NODE_ENV=production |
|
||||
EXPOSE 3005 |
|
||||
|
# Stage 3: Production |
||||
|
FROM node:22-alpine AS runner |
||||
WORKDIR /app |
WORKDIR /app |
||||
RUN npm i -g pnpm |
|
||||
COPY --from=build /app/.env ./.env |
|
||||
COPY --from=build /app/dist ./dist |
|
||||
COPY --from=build /app/package.json /app/pnpm-lock.yaml ./ |
|
||||
RUN pnpm i --prod |
|
||||
ENTRYPOINT ["pnpm", "start"] |
|
||||
|
|
||||
|
# Install pnpm |
||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate |
||||
|
|
||||
|
# Set NODE_ENV to production |
||||
|
ENV NODE_ENV production |
||||
|
|
||||
|
# Copy necessary files for production |
||||
|
COPY --from=builder /app/dist ./dist |
||||
|
COPY --from=builder /app/package.json ./ |
||||
|
COPY --from=builder /app/pnpm-lock.yaml ./ |
||||
|
|
||||
|
# Install only production dependencies |
||||
|
RUN pnpm install --prod --frozen-lockfile |
||||
|
|
||||
|
# Add a non-root user |
||||
|
RUN addgroup --system --gid 1001 nodejs && \ |
||||
|
adduser --system --uid 1001 nestjs && \ |
||||
|
chown -R nestjs:nodejs /app |
||||
|
USER nestjs |
||||
|
|
||||
|
# Expose the port specified by the PORT environment variable (default: 3001) |
||||
|
ENV PORT 300 |
||||
|
EXPOSE ${PORT} |
||||
|
|
||||
|
# Health check to verify app is running |
||||
|
# HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ |
||||
|
# CMD wget -q -O - http://localhost:${PORT}/api/v1/health || exit 1 |
||||
|
|
||||
|
# Command to run the application |
||||
|
CMD ["node", "dist/main"] |
||||
@ -1,18 +0,0 @@ |
|||||
import { Test, TestingModule } from '@nestjs/testing' |
|
||||
import { AuthController } from './auth.controller' |
|
||||
|
|
||||
describe('AuthController', () => { |
|
||||
let controller: AuthController |
|
||||
|
|
||||
beforeEach(async () => { |
|
||||
const module: TestingModule = await Test.createTestingModule({ |
|
||||
controllers: [AuthController], |
|
||||
}).compile() |
|
||||
|
|
||||
controller = module.get<AuthController>(AuthController) |
|
||||
}) |
|
||||
|
|
||||
it('should be defined', () => { |
|
||||
expect(controller).toBeDefined() |
|
||||
}) |
|
||||
}) |
|
||||
@ -1,18 +0,0 @@ |
|||||
import { Test, TestingModule } from '@nestjs/testing' |
|
||||
import { AuthService } from './auth.service' |
|
||||
|
|
||||
describe('AuthService', () => { |
|
||||
let service: AuthService |
|
||||
|
|
||||
beforeEach(async () => { |
|
||||
const module: TestingModule = await Test.createTestingModule({ |
|
||||
providers: [AuthService], |
|
||||
}).compile() |
|
||||
|
|
||||
service = module.get<AuthService>(AuthService) |
|
||||
}) |
|
||||
|
|
||||
it('should be defined', () => { |
|
||||
expect(service).toBeDefined() |
|
||||
}) |
|
||||
}) |
|
||||
@ -1,18 +0,0 @@ |
|||||
import { Test, TestingModule } from '@nestjs/testing'; |
|
||||
import { BillingController } from './billing.controller'; |
|
||||
|
|
||||
describe('BillingController', () => { |
|
||||
let controller: BillingController; |
|
||||
|
|
||||
beforeEach(async () => { |
|
||||
const module: TestingModule = await Test.createTestingModule({ |
|
||||
controllers: [BillingController], |
|
||||
}).compile(); |
|
||||
|
|
||||
controller = module.get<BillingController>(BillingController); |
|
||||
}); |
|
||||
|
|
||||
it('should be defined', () => { |
|
||||
expect(controller).toBeDefined(); |
|
||||
}); |
|
||||
}); |
|
||||
@ -1,18 +0,0 @@ |
|||||
import { Test, TestingModule } from '@nestjs/testing'; |
|
||||
import { BillingService } from './billing.service'; |
|
||||
|
|
||||
describe('BillingService', () => { |
|
||||
let service: BillingService; |
|
||||
|
|
||||
beforeEach(async () => { |
|
||||
const module: TestingModule = await Test.createTestingModule({ |
|
||||
providers: [BillingService], |
|
||||
}).compile(); |
|
||||
|
|
||||
service = module.get<BillingService>(BillingService); |
|
||||
}); |
|
||||
|
|
||||
it('should be defined', () => { |
|
||||
expect(service).toBeDefined(); |
|
||||
}); |
|
||||
}); |
|
||||
@ -1,18 +0,0 @@ |
|||||
import { Test, TestingModule } from '@nestjs/testing' |
|
||||
import { GatewayController } from './gateway.controller' |
|
||||
|
|
||||
describe('GatewayController', () => { |
|
||||
let controller: GatewayController |
|
||||
|
|
||||
beforeEach(async () => { |
|
||||
const module: TestingModule = await Test.createTestingModule({ |
|
||||
controllers: [GatewayController], |
|
||||
}).compile() |
|
||||
|
|
||||
controller = module.get<GatewayController>(GatewayController) |
|
||||
}) |
|
||||
|
|
||||
it('should be defined', () => { |
|
||||
expect(controller).toBeDefined() |
|
||||
}) |
|
||||
}) |
|
||||
@ -1,20 +1,791 @@ |
|||||
import { Test, TestingModule } from '@nestjs/testing' |
import { Test, TestingModule } from '@nestjs/testing' |
||||
import { GatewayService } from './gateway.service' |
import { GatewayService } from './gateway.service' |
||||
import { AuthModule } from '../auth/auth.module' |
import { AuthModule } from '../auth/auth.module' |
||||
|
import { getModelToken } from '@nestjs/mongoose' |
||||
|
import { Device, DeviceDocument } from './schemas/device.schema' |
||||
|
import { SMS } from './schemas/sms.schema' |
||||
|
import { SMSBatch } from './schemas/sms-batch.schema' |
||||
|
import { AuthService } from '../auth/auth.service' |
||||
|
import { WebhookService } from '../webhook/webhook.service' |
||||
|
import { BillingService } from '../billing/billing.service' |
||||
|
import { SmsQueueService } from './queue/sms-queue.service' |
||||
|
import { Model } from 'mongoose' |
||||
|
import { ConfigModule } from '@nestjs/config' |
||||
|
import { HttpException, HttpStatus } from '@nestjs/common' |
||||
|
import * as firebaseAdmin from 'firebase-admin' |
||||
|
import { SMSType } from './sms-type.enum' |
||||
|
import { WebhookEvent } from '../webhook/webhook-event.enum' |
||||
|
import { RegisterDeviceInputDTO, SendBulkSMSInputDTO, SendSMSInputDTO } from './gateway.dto' |
||||
|
import { User } from '../users/schemas/user.schema' |
||||
|
import { BatchResponse } from 'firebase-admin/messaging' |
||||
|
|
||||
|
// Mock firebase-admin
|
||||
|
jest.mock('firebase-admin', () => ({ |
||||
|
messaging: jest.fn().mockReturnValue({ |
||||
|
sendEach: jest.fn(), |
||||
|
}), |
||||
|
})) |
||||
|
|
||||
describe('GatewayService', () => { |
describe('GatewayService', () => { |
||||
let service: GatewayService |
let service: GatewayService |
||||
|
let deviceModel: Model<DeviceDocument> |
||||
|
let smsModel: Model<SMS> |
||||
|
let smsBatchModel: Model<SMSBatch> |
||||
|
let authService: AuthService |
||||
|
let webhookService: WebhookService |
||||
|
let billingService: BillingService |
||||
|
let smsQueueService: SmsQueueService |
||||
|
|
||||
|
const mockDeviceModel = { |
||||
|
findOne: jest.fn(), |
||||
|
find: jest.fn(), |
||||
|
findById: jest.fn(), |
||||
|
findByIdAndUpdate: jest.fn(), |
||||
|
findByIdAndDelete: jest.fn(), |
||||
|
create: jest.fn(), |
||||
|
exec: jest.fn(), |
||||
|
countDocuments: jest.fn(), |
||||
|
} |
||||
|
|
||||
|
const mockSmsModel = { |
||||
|
create: jest.fn(), |
||||
|
find: jest.fn(), |
||||
|
updateMany: jest.fn(), |
||||
|
countDocuments: jest.fn(), |
||||
|
} |
||||
|
|
||||
|
const mockSmsBatchModel = { |
||||
|
create: jest.fn(), |
||||
|
findByIdAndUpdate: jest.fn(), |
||||
|
} |
||||
|
|
||||
|
const mockAuthService = { |
||||
|
getUserApiKeys: jest.fn(), |
||||
|
} |
||||
|
|
||||
|
const mockWebhookService = { |
||||
|
deliverNotification: jest.fn(), |
||||
|
} |
||||
|
|
||||
|
const mockBillingService = { |
||||
|
canPerformAction: jest.fn(), |
||||
|
} |
||||
|
|
||||
|
const mockSmsQueueService = { |
||||
|
isQueueEnabled: jest.fn(), |
||||
|
addSendSmsJob: jest.fn(), |
||||
|
} |
||||
|
|
||||
beforeEach(async () => { |
beforeEach(async () => { |
||||
const module: TestingModule = await Test.createTestingModule({ |
const module: TestingModule = await Test.createTestingModule({ |
||||
providers: [GatewayService], |
|
||||
imports: [AuthModule], |
|
||||
|
providers: [ |
||||
|
GatewayService, |
||||
|
{ |
||||
|
provide: getModelToken(Device.name), |
||||
|
useValue: mockDeviceModel, |
||||
|
}, |
||||
|
{ |
||||
|
provide: getModelToken(SMS.name), |
||||
|
useValue: mockSmsModel, |
||||
|
}, |
||||
|
{ |
||||
|
provide: getModelToken(SMSBatch.name), |
||||
|
useValue: mockSmsBatchModel, |
||||
|
}, |
||||
|
{ |
||||
|
provide: AuthService, |
||||
|
useValue: mockAuthService, |
||||
|
}, |
||||
|
{ |
||||
|
provide: WebhookService, |
||||
|
useValue: mockWebhookService, |
||||
|
}, |
||||
|
{ |
||||
|
provide: BillingService, |
||||
|
useValue: mockBillingService, |
||||
|
}, |
||||
|
{ |
||||
|
provide: SmsQueueService, |
||||
|
useValue: mockSmsQueueService, |
||||
|
}, |
||||
|
], |
||||
|
imports: [ConfigModule], |
||||
}).compile() |
}).compile() |
||||
|
|
||||
service = module.get<GatewayService>(GatewayService) |
service = module.get<GatewayService>(GatewayService) |
||||
|
deviceModel = module.get<Model<DeviceDocument>>(getModelToken(Device.name)) |
||||
|
smsModel = module.get<Model<SMS>>(getModelToken(SMS.name)) |
||||
|
smsBatchModel = module.get<Model<SMSBatch>>(getModelToken(SMSBatch.name)) |
||||
|
authService = module.get<AuthService>(AuthService) |
||||
|
webhookService = module.get<WebhookService>(WebhookService) |
||||
|
billingService = module.get<BillingService>(BillingService) |
||||
|
smsQueueService = module.get<SmsQueueService>(SmsQueueService) |
||||
|
|
||||
|
// Reset all mocks
|
||||
|
jest.clearAllMocks() |
||||
}) |
}) |
||||
|
|
||||
it('should be defined', () => { |
it('should be defined', () => { |
||||
expect(service).toBeDefined() |
expect(service).toBeDefined() |
||||
}) |
}) |
||||
|
|
||||
|
describe('registerDevice', () => { |
||||
|
const mockUser = { |
||||
|
_id: 'user123', |
||||
|
name: 'Test User', |
||||
|
email: 'test@example.com', |
||||
|
password: 'password', |
||||
|
role: 'user', |
||||
|
createdAt: new Date(), |
||||
|
updatedAt: new Date() |
||||
|
} as unknown as User; |
||||
|
|
||||
|
const mockDeviceInput: RegisterDeviceInputDTO = { |
||||
|
model: 'Pixel 6', |
||||
|
buildId: 'build123', |
||||
|
fcmToken: 'token123', |
||||
|
enabled: true, |
||||
|
} |
||||
|
const mockDevice = { |
||||
|
_id: 'device123', |
||||
|
...mockDeviceInput, |
||||
|
user: mockUser._id, |
||||
|
} |
||||
|
|
||||
|
it('should update device if it already exists', async () => { |
||||
|
mockDeviceModel.findOne.mockResolvedValue(mockDevice) |
||||
|
mockDeviceModel.findByIdAndUpdate.mockResolvedValue({ |
||||
|
...mockDevice, |
||||
|
fcmToken: 'updatedToken', |
||||
|
}) |
||||
|
|
||||
|
// The implementation internally uses the _id from the found device to update it
|
||||
|
// So we need to avoid the internal call to updateDevice which is failing in the test
|
||||
|
// by mocking the service method directly and restoring it after the test
|
||||
|
const originalUpdateDevice = service.updateDevice; |
||||
|
service.updateDevice = jest.fn().mockResolvedValue({ |
||||
|
...mockDevice, |
||||
|
fcmToken: 'updatedToken', |
||||
|
}); |
||||
|
|
||||
|
const result = await service.registerDevice(mockDeviceInput, mockUser) |
||||
|
|
||||
|
expect(mockDeviceModel.findOne).toHaveBeenCalledWith({ |
||||
|
user: mockUser._id, |
||||
|
model: mockDeviceInput.model, |
||||
|
buildId: mockDeviceInput.buildId, |
||||
|
}) |
||||
|
expect(service.updateDevice).toHaveBeenCalledWith( |
||||
|
mockDevice._id.toString(), |
||||
|
{ ...mockDeviceInput, enabled: true } |
||||
|
) |
||||
|
expect(result).toBeDefined() |
||||
|
|
||||
|
// Restore the original method
|
||||
|
service.updateDevice = originalUpdateDevice; |
||||
|
}) |
||||
|
|
||||
|
it('should create a new device if it does not exist', async () => { |
||||
|
mockDeviceModel.findOne.mockResolvedValue(null) |
||||
|
mockDeviceModel.create.mockResolvedValue(mockDevice) |
||||
|
|
||||
|
const result = await service.registerDevice(mockDeviceInput, mockUser) |
||||
|
|
||||
|
expect(mockDeviceModel.findOne).toHaveBeenCalledWith({ |
||||
|
user: mockUser._id, |
||||
|
model: mockDeviceInput.model, |
||||
|
buildId: mockDeviceInput.buildId, |
||||
|
}) |
||||
|
expect(mockDeviceModel.create).toHaveBeenCalledWith({ |
||||
|
...mockDeviceInput, |
||||
|
user: mockUser, |
||||
|
}) |
||||
|
expect(result).toBeDefined() |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
describe('getDevicesForUser', () => { |
||||
|
const mockUser = { |
||||
|
_id: 'user123', |
||||
|
name: 'Test User', |
||||
|
email: 'test@example.com', |
||||
|
password: 'password', |
||||
|
role: 'user', |
||||
|
createdAt: new Date(), |
||||
|
updatedAt: new Date() |
||||
|
} as unknown as User; |
||||
|
|
||||
|
const mockDevices = [ |
||||
|
{ _id: 'device1', model: 'Pixel 6' }, |
||||
|
{ _id: 'device2', model: 'iPhone 13' }, |
||||
|
] |
||||
|
|
||||
|
it('should return all devices for a user', async () => { |
||||
|
mockDeviceModel.find.mockResolvedValue(mockDevices) |
||||
|
|
||||
|
const result = await service.getDevicesForUser(mockUser) |
||||
|
|
||||
|
expect(mockDeviceModel.find).toHaveBeenCalledWith({ user: mockUser._id }) |
||||
|
expect(result).toEqual(mockDevices) |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
describe('getDeviceById', () => { |
||||
|
const mockDevice = { _id: 'device123', model: 'Pixel 6' } |
||||
|
|
||||
|
it('should return device by id', async () => { |
||||
|
mockDeviceModel.findById.mockResolvedValue(mockDevice) |
||||
|
|
||||
|
const result = await service.getDeviceById('device123') |
||||
|
|
||||
|
expect(mockDeviceModel.findById).toHaveBeenCalledWith('device123') |
||||
|
expect(result).toEqual(mockDevice) |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
describe('updateDevice', () => { |
||||
|
const mockDeviceId = 'device123' |
||||
|
const mockDeviceInput: RegisterDeviceInputDTO = { |
||||
|
model: 'Pixel 6', |
||||
|
buildId: 'build123', |
||||
|
fcmToken: 'updatedToken', |
||||
|
enabled: true, |
||||
|
} |
||||
|
const mockDevice = { |
||||
|
_id: mockDeviceId, |
||||
|
...mockDeviceInput, |
||||
|
} |
||||
|
|
||||
|
it('should update device if it exists', async () => { |
||||
|
mockDeviceModel.findById.mockResolvedValue(mockDevice) |
||||
|
mockDeviceModel.findByIdAndUpdate.mockResolvedValue({ |
||||
|
...mockDevice, |
||||
|
fcmToken: 'updatedToken', |
||||
|
}) |
||||
|
|
||||
|
const result = await service.updateDevice(mockDeviceId, mockDeviceInput) |
||||
|
|
||||
|
expect(mockDeviceModel.findById).toHaveBeenCalledWith(mockDeviceId) |
||||
|
expect(mockDeviceModel.findByIdAndUpdate).toHaveBeenCalledWith( |
||||
|
mockDeviceId, |
||||
|
{ $set: mockDeviceInput }, |
||||
|
{ new: true }, |
||||
|
) |
||||
|
expect(result).toBeDefined() |
||||
|
}) |
||||
|
|
||||
|
it('should throw an error if device does not exist', async () => { |
||||
|
mockDeviceModel.findById.mockResolvedValue(null) |
||||
|
|
||||
|
await expect( |
||||
|
service.updateDevice(mockDeviceId, mockDeviceInput), |
||||
|
).rejects.toThrow(HttpException) |
||||
|
expect(mockDeviceModel.findById).toHaveBeenCalledWith(mockDeviceId) |
||||
|
expect(mockDeviceModel.findByIdAndUpdate).not.toHaveBeenCalled() |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
describe('deleteDevice', () => { |
||||
|
const mockDeviceId = 'device123' |
||||
|
const mockDevice = { _id: mockDeviceId, model: 'Pixel 6' } |
||||
|
|
||||
|
it('should return empty object when device exists', async () => { |
||||
|
mockDeviceModel.findById.mockResolvedValue(mockDevice) |
||||
|
|
||||
|
const result = await service.deleteDevice(mockDeviceId) |
||||
|
|
||||
|
expect(mockDeviceModel.findById).toHaveBeenCalledWith(mockDeviceId) |
||||
|
expect(result).toEqual({}) |
||||
|
}) |
||||
|
|
||||
|
it('should throw an error if device does not exist', async () => { |
||||
|
mockDeviceModel.findById.mockResolvedValue(null) |
||||
|
|
||||
|
await expect(service.deleteDevice(mockDeviceId)).rejects.toThrow( |
||||
|
HttpException, |
||||
|
) |
||||
|
expect(mockDeviceModel.findById).toHaveBeenCalledWith(mockDeviceId) |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
describe('sendSMS', () => { |
||||
|
const mockDeviceId = 'device123' |
||||
|
const mockDevice = { |
||||
|
_id: mockDeviceId, |
||||
|
enabled: true, |
||||
|
fcmToken: 'fcm-token', |
||||
|
user: 'user123', |
||||
|
} |
||||
|
const mockSmsInput: SendSMSInputDTO = { |
||||
|
message: 'Hello there', |
||||
|
recipients: ['+123456789'], |
||||
|
smsBody: 'Hello there', |
||||
|
receivers: ['+123456789'], |
||||
|
} |
||||
|
const mockSms = { |
||||
|
_id: 'sms123', |
||||
|
device: mockDeviceId, |
||||
|
message: mockSmsInput.message, |
||||
|
type: SMSType.SENT, |
||||
|
recipient: mockSmsInput.recipients[0], |
||||
|
status: 'pending', |
||||
|
} |
||||
|
const mockSmsBatch = { |
||||
|
_id: 'batch123', |
||||
|
device: mockDeviceId, |
||||
|
message: mockSmsInput.message, |
||||
|
recipientCount: 1, |
||||
|
status: 'pending', |
||||
|
} |
||||
|
const mockFcmResponse: BatchResponse = { |
||||
|
successCount: 1, |
||||
|
failureCount: 0, |
||||
|
responses: [], |
||||
|
} |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
mockDeviceModel.findById.mockResolvedValue(mockDevice) |
||||
|
mockSmsBatchModel.create.mockResolvedValue(mockSmsBatch) |
||||
|
mockSmsModel.create.mockResolvedValue(mockSms) |
||||
|
mockDeviceModel.findByIdAndUpdate.mockImplementation(() => ({ |
||||
|
exec: jest.fn().mockResolvedValue(true), |
||||
|
})) |
||||
|
mockSmsBatchModel.findByIdAndUpdate.mockImplementation(() => ({ |
||||
|
exec: jest.fn().mockResolvedValue(true), |
||||
|
})) |
||||
|
mockBillingService.canPerformAction.mockResolvedValue(true) |
||||
|
mockSmsQueueService.isQueueEnabled.mockReturnValue(false) |
||||
|
|
||||
|
// Fix the mock
|
||||
|
jest.spyOn(firebaseAdmin.messaging(), 'sendEach').mockResolvedValue(mockFcmResponse) |
||||
|
}) |
||||
|
|
||||
|
it('should send SMS successfully', async () => { |
||||
|
const result = await service.sendSMS(mockDeviceId, mockSmsInput) |
||||
|
|
||||
|
expect(mockDeviceModel.findById).toHaveBeenCalledWith(mockDeviceId) |
||||
|
expect(mockBillingService.canPerformAction).toHaveBeenCalledWith( |
||||
|
mockDevice.user.toString(), |
||||
|
'send_sms', |
||||
|
mockSmsInput.recipients.length, |
||||
|
) |
||||
|
expect(mockSmsBatchModel.create).toHaveBeenCalled() |
||||
|
expect(mockSmsModel.create).toHaveBeenCalled() |
||||
|
expect(firebaseAdmin.messaging().sendEach).toHaveBeenCalled() |
||||
|
expect(result).toEqual(mockFcmResponse) |
||||
|
}) |
||||
|
|
||||
|
it('should throw error if device is not enabled', async () => { |
||||
|
mockDeviceModel.findById.mockResolvedValue({ |
||||
|
...mockDevice, |
||||
|
enabled: false, |
||||
|
}) |
||||
|
|
||||
|
await expect( |
||||
|
service.sendSMS(mockDeviceId, mockSmsInput), |
||||
|
).rejects.toThrow(HttpException) |
||||
|
expect(mockDeviceModel.findById).toHaveBeenCalledWith(mockDeviceId) |
||||
|
expect(mockBillingService.canPerformAction).not.toHaveBeenCalled() |
||||
|
}) |
||||
|
|
||||
|
it('should throw error if message is blank', async () => { |
||||
|
await expect( |
||||
|
service.sendSMS(mockDeviceId, { ...mockSmsInput, message: '', smsBody: '' }), |
||||
|
).rejects.toThrow(HttpException) |
||||
|
}) |
||||
|
|
||||
|
it('should throw error if recipients are invalid', async () => { |
||||
|
await expect( |
||||
|
service.sendSMS(mockDeviceId, { ...mockSmsInput, recipients: [] }), |
||||
|
).rejects.toThrow(HttpException) |
||||
|
}) |
||||
|
|
||||
|
it('should queue SMS if queue is enabled', async () => { |
||||
|
mockSmsQueueService.isQueueEnabled.mockReturnValue(true) |
||||
|
mockSmsQueueService.addSendSmsJob.mockResolvedValue(true) |
||||
|
|
||||
|
const result = await service.sendSMS(mockDeviceId, mockSmsInput) |
||||
|
|
||||
|
expect(mockSmsQueueService.isQueueEnabled).toHaveBeenCalled() |
||||
|
expect(mockSmsQueueService.addSendSmsJob).toHaveBeenCalled() |
||||
|
expect(result).toHaveProperty('success', true) |
||||
|
expect(result).toHaveProperty('smsBatchId', mockSmsBatch._id) |
||||
|
}) |
||||
|
|
||||
|
it('should handle queue error properly', async () => { |
||||
|
mockSmsQueueService.isQueueEnabled.mockReturnValue(true) |
||||
|
mockSmsQueueService.addSendSmsJob.mockRejectedValue(new Error('Queue error')) |
||||
|
|
||||
|
await expect( |
||||
|
service.sendSMS(mockDeviceId, mockSmsInput), |
||||
|
).rejects.toThrow(HttpException) |
||||
|
|
||||
|
expect(mockSmsBatchModel.findByIdAndUpdate).toHaveBeenCalled() |
||||
|
expect(mockSmsModel.updateMany).toHaveBeenCalled() |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
describe('sendBulkSMS', () => { |
||||
|
const mockDeviceId = 'device123' |
||||
|
const mockDevice = { |
||||
|
_id: mockDeviceId, |
||||
|
enabled: true, |
||||
|
fcmToken: 'fcm-token', |
||||
|
user: 'user123', |
||||
|
} |
||||
|
const mockBulkSmsInput: SendBulkSMSInputDTO = { |
||||
|
messageTemplate: 'Hello {name}', |
||||
|
messages: [ |
||||
|
{ |
||||
|
message: 'Hello John', |
||||
|
recipients: ['+123456789'], |
||||
|
smsBody: 'Hello John', |
||||
|
receivers: ['+123456789'], |
||||
|
}, |
||||
|
{ |
||||
|
message: 'Hello Jane', |
||||
|
recipients: ['+987654321'], |
||||
|
smsBody: 'Hello Jane', |
||||
|
receivers: ['+987654321'], |
||||
|
}, |
||||
|
], |
||||
|
} |
||||
|
const mockSmsBatch = { |
||||
|
_id: 'batch123', |
||||
|
device: mockDeviceId, |
||||
|
message: mockBulkSmsInput.messageTemplate, |
||||
|
recipientCount: 2, |
||||
|
status: 'pending', |
||||
|
} |
||||
|
const mockSms = { |
||||
|
_id: 'sms123', |
||||
|
device: mockDeviceId, |
||||
|
message: 'Hello John', |
||||
|
type: SMSType.SENT, |
||||
|
recipient: '+123456789', |
||||
|
status: 'pending', |
||||
|
} |
||||
|
const mockFcmResponse: BatchResponse = { |
||||
|
successCount: 1, |
||||
|
failureCount: 0, |
||||
|
responses: [], |
||||
|
} |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
mockDeviceModel.findById.mockResolvedValue(mockDevice) |
||||
|
mockSmsBatchModel.create.mockResolvedValue(mockSmsBatch) |
||||
|
mockSmsModel.create.mockResolvedValue(mockSms) |
||||
|
mockDeviceModel.findByIdAndUpdate.mockImplementation(() => ({ |
||||
|
exec: jest.fn().mockResolvedValue(true), |
||||
|
})) |
||||
|
mockSmsBatchModel.findByIdAndUpdate.mockImplementation(() => ({ |
||||
|
exec: jest.fn().mockResolvedValue(true), |
||||
|
})) |
||||
|
mockBillingService.canPerformAction.mockResolvedValue(true) |
||||
|
mockSmsQueueService.isQueueEnabled.mockReturnValue(false) |
||||
|
|
||||
|
// Fix the mock
|
||||
|
jest.spyOn(firebaseAdmin.messaging(), 'sendEach').mockResolvedValue(mockFcmResponse) |
||||
|
}) |
||||
|
|
||||
|
it('should send bulk SMS successfully', async () => { |
||||
|
const result = await service.sendBulkSMS(mockDeviceId, mockBulkSmsInput) |
||||
|
|
||||
|
expect(mockDeviceModel.findById).toHaveBeenCalledWith(mockDeviceId) |
||||
|
expect(mockBillingService.canPerformAction).toHaveBeenCalledWith( |
||||
|
mockDevice.user.toString(), |
||||
|
'bulk_send_sms', |
||||
|
2, |
||||
|
) |
||||
|
expect(mockSmsBatchModel.create).toHaveBeenCalled() |
||||
|
expect(mockSmsModel.create).toHaveBeenCalled() |
||||
|
expect(firebaseAdmin.messaging().sendEach).toHaveBeenCalled() |
||||
|
expect(result).toHaveProperty('success', true) |
||||
|
}) |
||||
|
|
||||
|
it('should queue bulk SMS if queue is enabled', async () => { |
||||
|
mockSmsQueueService.isQueueEnabled.mockReturnValue(true) |
||||
|
mockSmsQueueService.addSendSmsJob.mockResolvedValue(true) |
||||
|
|
||||
|
const result = await service.sendBulkSMS(mockDeviceId, mockBulkSmsInput) |
||||
|
|
||||
|
expect(mockSmsQueueService.isQueueEnabled).toHaveBeenCalled() |
||||
|
expect(mockSmsQueueService.addSendSmsJob).toHaveBeenCalled() |
||||
|
expect(result).toHaveProperty('success', true) |
||||
|
expect(result).toHaveProperty('smsBatchId', mockSmsBatch._id) |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
describe('receiveSMS', () => { |
||||
|
const mockDeviceId = 'device123' |
||||
|
const mockDevice = { |
||||
|
_id: mockDeviceId, |
||||
|
user: 'user123', |
||||
|
} |
||||
|
const mockReceivedSmsData = { |
||||
|
message: 'Hello from test', |
||||
|
sender: '+123456789', |
||||
|
receivedAt: new Date(), |
||||
|
} |
||||
|
const mockSms = { |
||||
|
_id: 'sms123', |
||||
|
...mockReceivedSmsData, |
||||
|
device: mockDeviceId, |
||||
|
type: SMSType.RECEIVED, |
||||
|
} |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
mockDeviceModel.findById.mockResolvedValue(mockDevice) |
||||
|
mockSmsModel.create.mockResolvedValue(mockSms) |
||||
|
mockDeviceModel.findByIdAndUpdate.mockImplementation(() => ({ |
||||
|
exec: jest.fn().mockResolvedValue(true), |
||||
|
})) |
||||
|
mockBillingService.canPerformAction.mockResolvedValue(true) |
||||
|
mockWebhookService.deliverNotification.mockResolvedValue(true) |
||||
|
}) |
||||
|
|
||||
|
it('should receive SMS successfully', async () => { |
||||
|
const result = await service.receiveSMS(mockDeviceId, mockReceivedSmsData) |
||||
|
|
||||
|
expect(mockDeviceModel.findById).toHaveBeenCalledWith(mockDeviceId) |
||||
|
expect(mockBillingService.canPerformAction).toHaveBeenCalledWith( |
||||
|
mockDevice.user.toString(), |
||||
|
'receive_sms', |
||||
|
1, |
||||
|
) |
||||
|
expect(mockSmsModel.create).toHaveBeenCalled() |
||||
|
expect(mockDeviceModel.findByIdAndUpdate).toHaveBeenCalled() |
||||
|
expect(mockWebhookService.deliverNotification).toHaveBeenCalledWith({ |
||||
|
sms: mockSms, |
||||
|
user: mockDevice.user, |
||||
|
event: WebhookEvent.MESSAGE_RECEIVED, |
||||
|
}) |
||||
|
expect(result).toEqual(mockSms) |
||||
|
}) |
||||
|
|
||||
|
it('should throw error if device does not exist', async () => { |
||||
|
mockDeviceModel.findById.mockResolvedValue(null) |
||||
|
|
||||
|
await expect( |
||||
|
service.receiveSMS(mockDeviceId, mockReceivedSmsData), |
||||
|
).rejects.toThrow(HttpException) |
||||
|
}) |
||||
|
|
||||
|
it('should throw error if SMS data is invalid', async () => { |
||||
|
await expect( |
||||
|
service.receiveSMS(mockDeviceId, { ...mockReceivedSmsData, message: '' }), |
||||
|
).rejects.toThrow(HttpException) |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
describe('getReceivedSMS', () => { |
||||
|
const mockDeviceId = 'device123' |
||||
|
const mockDevice = { |
||||
|
_id: mockDeviceId, |
||||
|
} |
||||
|
const mockSmsData = [ |
||||
|
{ |
||||
|
_id: 'sms1', |
||||
|
message: 'Hello 1', |
||||
|
type: SMSType.RECEIVED, |
||||
|
sender: '+123456789', |
||||
|
receivedAt: new Date(), |
||||
|
}, |
||||
|
{ |
||||
|
_id: 'sms2', |
||||
|
message: 'Hello 2', |
||||
|
type: SMSType.RECEIVED, |
||||
|
sender: '+987654321', |
||||
|
receivedAt: new Date(), |
||||
|
}, |
||||
|
] |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
mockDeviceModel.findById.mockResolvedValue(mockDevice) |
||||
|
mockSmsModel.find.mockReturnValue({ |
||||
|
populate: jest.fn().mockReturnValue({ |
||||
|
lean: jest.fn().mockResolvedValue(mockSmsData), |
||||
|
}), |
||||
|
}) |
||||
|
mockSmsModel.countDocuments.mockResolvedValue(2) |
||||
|
}) |
||||
|
|
||||
|
it('should get received SMS with pagination', async () => { |
||||
|
const result = await service.getReceivedSMS(mockDeviceId, 1, 10) |
||||
|
|
||||
|
expect(mockDeviceModel.findById).toHaveBeenCalledWith(mockDeviceId) |
||||
|
expect(mockSmsModel.countDocuments).toHaveBeenCalledWith({ |
||||
|
device: mockDevice._id, |
||||
|
type: SMSType.RECEIVED, |
||||
|
}) |
||||
|
expect(mockSmsModel.find).toHaveBeenCalledWith( |
||||
|
{ |
||||
|
device: mockDevice._id, |
||||
|
type: SMSType.RECEIVED, |
||||
|
}, |
||||
|
null, |
||||
|
{ |
||||
|
sort: { receivedAt: -1 }, |
||||
|
limit: 10, |
||||
|
skip: 0, |
||||
|
}, |
||||
|
) |
||||
|
expect(result).toHaveProperty('data', mockSmsData) |
||||
|
expect(result).toHaveProperty('meta') |
||||
|
expect(result.meta).toHaveProperty('total', 2) |
||||
|
}) |
||||
|
|
||||
|
it('should throw error if device does not exist', async () => { |
||||
|
mockDeviceModel.findById.mockResolvedValue(null) |
||||
|
|
||||
|
await expect(service.getReceivedSMS(mockDeviceId)).rejects.toThrow( |
||||
|
HttpException, |
||||
|
) |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
describe('getMessages', () => { |
||||
|
const mockDeviceId = 'device123' |
||||
|
const mockDevice = { |
||||
|
_id: mockDeviceId, |
||||
|
} |
||||
|
const mockSmsData = [ |
||||
|
{ |
||||
|
_id: 'sms1', |
||||
|
message: 'Hello 1', |
||||
|
type: SMSType.SENT, |
||||
|
recipient: '+123456789', |
||||
|
createdAt: new Date(), |
||||
|
}, |
||||
|
{ |
||||
|
_id: 'sms2', |
||||
|
message: 'Hello 2', |
||||
|
type: SMSType.RECEIVED, |
||||
|
sender: '+987654321', |
||||
|
createdAt: new Date(), |
||||
|
}, |
||||
|
] |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
mockDeviceModel.findById.mockResolvedValue(mockDevice) |
||||
|
mockSmsModel.find.mockReturnValue({ |
||||
|
populate: jest.fn().mockReturnValue({ |
||||
|
lean: jest.fn().mockResolvedValue(mockSmsData), |
||||
|
}), |
||||
|
}) |
||||
|
mockSmsModel.countDocuments.mockResolvedValue(2) |
||||
|
}) |
||||
|
|
||||
|
it('should get all messages with pagination', async () => { |
||||
|
const result = await service.getMessages(mockDeviceId, '', 1, 10) |
||||
|
|
||||
|
expect(mockDeviceModel.findById).toHaveBeenCalledWith(mockDeviceId) |
||||
|
expect(mockSmsModel.countDocuments).toHaveBeenCalledWith({ |
||||
|
device: mockDevice._id, |
||||
|
}) |
||||
|
expect(mockSmsModel.find).toHaveBeenCalledWith( |
||||
|
{ |
||||
|
device: mockDevice._id, |
||||
|
}, |
||||
|
null, |
||||
|
{ |
||||
|
sort: { createdAt: -1 }, |
||||
|
limit: 10, |
||||
|
skip: 0, |
||||
|
}, |
||||
|
) |
||||
|
expect(result).toHaveProperty('data', mockSmsData) |
||||
|
expect(result).toHaveProperty('meta') |
||||
|
expect(result.meta).toHaveProperty('total', 2) |
||||
|
}) |
||||
|
|
||||
|
it('should get sent messages with pagination', async () => { |
||||
|
const result = await service.getMessages(mockDeviceId, 'sent', 1, 10) |
||||
|
|
||||
|
expect(mockSmsModel.countDocuments).toHaveBeenCalledWith({ |
||||
|
device: mockDevice._id, |
||||
|
type: SMSType.SENT, |
||||
|
}) |
||||
|
expect(mockSmsModel.find).toHaveBeenCalledWith( |
||||
|
{ |
||||
|
device: mockDevice._id, |
||||
|
type: SMSType.SENT, |
||||
|
}, |
||||
|
null, |
||||
|
expect.any(Object), |
||||
|
) |
||||
|
}) |
||||
|
|
||||
|
it('should get received messages with pagination', async () => { |
||||
|
const result = await service.getMessages(mockDeviceId, 'received', 1, 10) |
||||
|
|
||||
|
expect(mockSmsModel.countDocuments).toHaveBeenCalledWith({ |
||||
|
device: mockDevice._id, |
||||
|
type: SMSType.RECEIVED, |
||||
|
}) |
||||
|
expect(mockSmsModel.find).toHaveBeenCalledWith( |
||||
|
{ |
||||
|
device: mockDevice._id, |
||||
|
type: SMSType.RECEIVED, |
||||
|
}, |
||||
|
null, |
||||
|
expect.any(Object), |
||||
|
) |
||||
|
}) |
||||
|
|
||||
|
it('should throw error if device does not exist', async () => { |
||||
|
mockDeviceModel.findById.mockResolvedValue(null) |
||||
|
|
||||
|
await expect(service.getMessages(mockDeviceId)).rejects.toThrow( |
||||
|
HttpException, |
||||
|
) |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
describe('getStatsForUser', () => { |
||||
|
const mockUser = { |
||||
|
_id: 'user123', |
||||
|
name: 'Test User', |
||||
|
email: 'test@example.com', |
||||
|
password: 'password', |
||||
|
role: 'user', |
||||
|
createdAt: new Date(), |
||||
|
updatedAt: new Date() |
||||
|
} as unknown as User; |
||||
|
|
||||
|
const mockDevices = [ |
||||
|
{ |
||||
|
_id: 'device1', |
||||
|
sentSMSCount: 10, |
||||
|
receivedSMSCount: 5, |
||||
|
}, |
||||
|
{ |
||||
|
_id: 'device2', |
||||
|
sentSMSCount: 20, |
||||
|
receivedSMSCount: 15, |
||||
|
}, |
||||
|
] |
||||
|
const mockApiKeys = [ |
||||
|
{ _id: 'key1', name: 'API Key 1' }, |
||||
|
{ _id: 'key2', name: 'API Key 2' }, |
||||
|
] |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
mockDeviceModel.find.mockResolvedValue(mockDevices) |
||||
|
mockAuthService.getUserApiKeys.mockResolvedValue(mockApiKeys) |
||||
|
}) |
||||
|
|
||||
|
it('should return stats for user', async () => { |
||||
|
const result = await service.getStatsForUser(mockUser) |
||||
|
|
||||
|
expect(mockDeviceModel.find).toHaveBeenCalledWith({ user: mockUser._id }) |
||||
|
expect(mockAuthService.getUserApiKeys).toHaveBeenCalledWith(mockUser) |
||||
|
expect(result).toEqual({ |
||||
|
totalSentSMSCount: 30, |
||||
|
totalReceivedSMSCount: 20, |
||||
|
totalDeviceCount: 2, |
||||
|
totalApiKeyCount: 2, |
||||
|
}) |
||||
|
}) |
||||
|
}) |
||||
}) |
}) |
||||
@ -1,18 +0,0 @@ |
|||||
import { Test, TestingModule } from '@nestjs/testing' |
|
||||
import { UsersController } from './users.controller' |
|
||||
|
|
||||
describe('UsersController', () => { |
|
||||
let controller: UsersController |
|
||||
|
|
||||
beforeEach(async () => { |
|
||||
const module: TestingModule = await Test.createTestingModule({ |
|
||||
controllers: [UsersController], |
|
||||
}).compile() |
|
||||
|
|
||||
controller = module.get<UsersController>(UsersController) |
|
||||
}) |
|
||||
|
|
||||
it('should be defined', () => { |
|
||||
expect(controller).toBeDefined() |
|
||||
}) |
|
||||
}) |
|
||||
@ -1,18 +0,0 @@ |
|||||
import { Test, TestingModule } from '@nestjs/testing' |
|
||||
import { UsersService } from './users.service' |
|
||||
|
|
||||
describe('UsersService', () => { |
|
||||
let service: UsersService |
|
||||
|
|
||||
beforeEach(async () => { |
|
||||
const module: TestingModule = await Test.createTestingModule({ |
|
||||
providers: [UsersService], |
|
||||
}).compile() |
|
||||
|
|
||||
service = module.get<UsersService>(UsersService) |
|
||||
}) |
|
||||
|
|
||||
it('should be defined', () => { |
|
||||
expect(service).toBeDefined() |
|
||||
}) |
|
||||
}) |
|
||||
@ -1,56 +1,141 @@ |
|||||
|
|
||||
services: |
services: |
||||
web: |
|
||||
container_name: web |
|
||||
build: |
|
||||
context: ./web |
|
||||
dockerfile: Dockerfile |
|
||||
|
# MongoDB service |
||||
|
textbee-db: |
||||
|
container_name: textbee-db |
||||
|
image: mongo:latest |
||||
|
restart: always |
||||
|
environment: |
||||
|
- MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USER:-adminUser} |
||||
|
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASS:-adminPassword} |
||||
|
- MONGO_INITDB_DATABASE=textbee |
||||
|
# - MONGO_DB_USERNAME=${MONGO_USER:-textbeeUser} |
||||
|
# - MONGO_DB_PASSWORD=${MONGO_PASS:-textbeePassword} |
||||
|
volumes: |
||||
|
# - ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro |
||||
|
- mongodb_data:/data/db |
||||
ports: |
ports: |
||||
- "3000:3000" |
|
||||
depends_on: |
|
||||
- mongo |
|
||||
|
# only allow access from the same machine, and use port 27018 to avoid conflict with default mongo port 27017 |
||||
|
# - "127.0.0.1:${MONGO_PORT:-27018}:27017" |
||||
|
- "${MONGO_PORT:-27018}:27017" |
||||
|
networks: |
||||
|
- textbee-network |
||||
|
healthcheck: |
||||
|
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] |
||||
|
interval: 30s |
||||
|
timeout: 10s |
||||
|
retries: 3 |
||||
|
start_period: 20s |
||||
|
|
||||
|
# MongoDB Express (optional admin UI) |
||||
|
mongo-express: |
||||
|
container_name: textbee-mongo-express |
||||
|
image: mongo-express:latest |
||||
|
restart: always |
||||
|
ports: |
||||
|
- "${MONGO_EXPRESS_PORT:-8081}:8081" |
||||
environment: |
environment: |
||||
NODE_ENV: production |
|
||||
|
- ME_CONFIG_MONGODB_ADMINUSERNAME=${MONGO_ROOT_USER:-adminUser} |
||||
|
- ME_CONFIG_MONGODB_ADMINPASSWORD=${MONGO_ROOT_PASS:-adminPassword} |
||||
|
- ME_CONFIG_MONGODB_SERVER=textbee-db |
||||
|
depends_on: |
||||
|
textbee-db: |
||||
|
condition: service_healthy |
||||
|
networks: |
||||
|
- textbee-network |
||||
|
|
||||
api: |
|
||||
container_name: api |
|
||||
|
# NestJS API |
||||
|
textbee-api: |
||||
|
container_name: textbee-api |
||||
build: |
build: |
||||
context: ./api |
context: ./api |
||||
dockerfile: Dockerfile |
dockerfile: Dockerfile |
||||
target: prod |
|
||||
|
restart: always |
||||
ports: |
ports: |
||||
- "3005:3005" |
|
||||
depends_on: |
|
||||
- mongo |
|
||||
|
- "${PORT:-3001}:3001" |
||||
|
env_file: |
||||
|
- ./api/.env |
||||
environment: |
environment: |
||||
NODE_ENV: production |
|
||||
|
- PORT=${PORT:-3001} |
||||
|
# - MONGO_URI=${MONGO_URI:-mongodb://${MONGO_USER:-textbeeUser}:${MONGO_PASS:-textbeePassword}@textbee-db:27018/TextBee} |
||||
|
# - MONGO_URI=mongodb://adminUser:adminPassword@textbee-db:27018/textbee |
||||
|
# - FRONTEND_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:3000} |
||||
|
# - JWT_SECRET=${JWT_SECRET:-your_jwt_secret_here} |
||||
|
# - JWT_EXPIRATION=${JWT_EXPIRATION:-60d} |
||||
|
# - MAIL_HOST=${MAIL_HOST} |
||||
|
# - MAIL_PORT=${MAIL_PORT} |
||||
|
# - MAIL_USER=${MAIL_USER} |
||||
|
# - MAIL_PASS=${MAIL_PASS} |
||||
|
# - MAIL_FROM=${MAIL_FROM} |
||||
|
# - USE_SMS_QUEUE=${USE_SMS_QUEUE:-false} |
||||
|
# - REDIS_HOST=${REDIS_HOST:-redis} |
||||
|
# - REDIS_PORT=${REDIS_PORT:-6379} |
||||
|
# - FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID} |
||||
|
# - FIREBASE_PRIVATE_KEY_ID=${FIREBASE_PRIVATE_KEY_ID} |
||||
|
# - FIREBASE_PRIVATE_KEY=${FIREBASE_PRIVATE_KEY} |
||||
|
# - FIREBASE_CLIENT_EMAIL=${FIREBASE_CLIENT_EMAIL} |
||||
|
# - FIREBASE_CLIENT_ID=${FIREBASE_CLIENT_ID} |
||||
|
# - FIREBASE_CLIENT_C509_CERT_URL=${FIREBASE_CLIENT_C509_CERT_URL} |
||||
|
depends_on: |
||||
|
textbee-db: |
||||
|
condition: service_healthy |
||||
|
networks: |
||||
|
- textbee-network |
||||
|
|
||||
mongo: |
|
||||
container_name: mongo |
|
||||
image: mongo |
|
||||
|
# Next.js Web |
||||
|
textbee-web: |
||||
|
container_name: textbee-web |
||||
|
build: |
||||
|
context: ./web |
||||
|
dockerfile: Dockerfile |
||||
restart: always |
restart: always |
||||
ports: |
ports: |
||||
- "27017:27017" |
|
||||
|
- "${PORT:-3000}:3000" |
||||
|
env_file: |
||||
|
- ./web/.env |
||||
environment: |
environment: |
||||
MONGO_INITDB_ROOT_USERNAME: adminUser |
|
||||
MONGO_INITDB_ROOT_PASSWORD: adminPassword |
|
||||
MONGO_INITDB_DATABASE: TextBee |
|
||||
volumes: |
|
||||
- textbee-db-data:/data/db |
|
||||
# THe following scripts creates TextBee DB automatically, also the user which web and api are connecting with. |
|
||||
- ./mongo-init:/docker-entrypoint-initdb.d:ro |
|
||||
mongo-express: |
|
||||
container_name: mongo-ee |
|
||||
image: mongo-express |
|
||||
|
- PORT=${PORT:-3000} |
||||
|
# - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:3000} |
||||
|
- NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL:-http://localhost:3001/api/v1} |
||||
|
# - AUTH_SECRET=${AUTH_SECRET:-generate_a_secure_random_string_here} |
||||
|
# - DATABASE_URL=mongodb://adminUser:adminPassword@textbee-db:27018/textbee |
||||
|
# - DATABASE_URL=mongodb://adminUser:adminPassword@textbee-db:27018/textbee |
||||
|
# - MAIL_HOST=${MAIL_HOST} |
||||
|
# - MAIL_PORT=${MAIL_PORT} |
||||
|
# - MAIL_USER=${MAIL_USER} |
||||
|
# - MAIL_PASS=${MAIL_PASS} |
||||
|
# - MAIL_FROM=${MAIL_FROM} |
||||
|
# - ADMIN_EMAIL=${ADMIN_EMAIL} |
||||
|
# - NEXT_PUBLIC_GOOGLE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_CLIENT_ID} |
||||
|
# - NEXT_PUBLIC_TAWKTO_EMBED_URL=${NEXT_PUBLIC_TAWKTO_EMBED_URL} |
||||
|
depends_on: |
||||
|
- textbee-api |
||||
|
networks: |
||||
|
- textbee-network |
||||
|
|
||||
|
# Redis (if SMS queue is needed) |
||||
|
redis: |
||||
|
container_name: textbee-redis |
||||
|
image: redis:alpine |
||||
restart: always |
restart: always |
||||
ports: |
ports: |
||||
- "8081:8081" |
|
||||
environment: |
|
||||
ME_CONFIG_MONGODB_ADMINUSERNAME: adminUser |
|
||||
ME_CONFIG_MONGODB_ADMINPASSWORD: adminPassword |
|
||||
ME_CONFIG_MONGODB_URL: mongodb://adminUser:adminPassword@mongo:27017/ |
|
||||
ME_CONFIG_BASICAUTH: "false" |
|
||||
depends_on: |
|
||||
- mongo |
|
||||
|
- "${REDIS_PORT:-6379}:6379" |
||||
|
volumes: |
||||
|
- redis_data:/data |
||||
|
networks: |
||||
|
- textbee-network |
||||
|
command: redis-server --appendonly yes |
||||
|
healthcheck: |
||||
|
test: ["CMD", "redis-cli", "ping"] |
||||
|
interval: 30s |
||||
|
timeout: 10s |
||||
|
retries: 3 |
||||
|
start_period: 20s |
||||
|
|
||||
|
networks: |
||||
|
textbee-network: |
||||
|
driver: bridge |
||||
|
|
||||
volumes: |
volumes: |
||||
textbee-db-data: |
|
||||
|
mongodb_data: |
||||
|
redis_data: |
||||
@ -1,11 +0,0 @@ |
|||||
db = db.getSiblingDB("TextBee"); |
|
||||
|
|
||||
db.createUser({ |
|
||||
user: "textbeeUser", |
|
||||
pwd: "textbeePassword", |
|
||||
roles: [{ role: "readWrite", db: "TextBee" }] |
|
||||
}); |
|
||||
|
|
||||
db.init.insertOne({ createdBy: "seed" }); |
|
||||
|
|
||||
print("✅ TextBee DB initialized and user created."); |
|
||||
@ -0,0 +1,2 @@ |
|||||
|
node_modules |
||||
|
.git |
||||
@ -1,20 +1,82 @@ |
|||||
FROM node:18-alpine AS base |
|
||||
|
|
||||
# Stage 1: Install web dependencies |
|
||||
FROM base AS web-deps |
|
||||
|
# Stage 1: Dependencies |
||||
|
FROM node:22-alpine AS deps |
||||
WORKDIR /app |
WORKDIR /app |
||||
COPY .env ./.env |
|
||||
|
|
||||
|
# Install pnpm and required OpenSSL dependencies |
||||
|
RUN apk add --no-cache openssl openssl-dev |
||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate |
||||
|
|
||||
|
# Copy package.json and pnpm-lock.yaml |
||||
COPY package.json pnpm-lock.yaml ./ |
COPY package.json pnpm-lock.yaml ./ |
||||
RUN corepack enable pnpm && pnpm install --frozen-lockfile |
|
||||
|
|
||||
# Stage 2: Build the web application |
|
||||
FROM base AS web-builder |
|
||||
ENV NODE_ENV=production |
|
||||
EXPOSE 3000 |
|
||||
|
# Install dependencies |
||||
|
RUN pnpm install --frozen-lockfile |
||||
|
|
||||
|
# Stage 2: Builder |
||||
|
FROM node:22-alpine AS builder |
||||
WORKDIR /app |
WORKDIR /app |
||||
|
|
||||
|
# Install pnpm and required OpenSSL dependencies |
||||
|
RUN apk add --no-cache openssl openssl-dev |
||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate |
||||
|
|
||||
|
# Copy dependencies from deps stage |
||||
|
COPY --from=deps /app/node_modules ./node_modules |
||||
|
|
||||
|
# Copy all files |
||||
COPY . . |
COPY . . |
||||
COPY --from=web-deps /app/node_modules ./node_modules |
|
||||
COPY --from=web-deps /app/.env .env |
|
||||
RUN corepack enable pnpm && pnpm run vercel-build |
|
||||
|
|
||||
CMD ["pnpm", "start"] |
|
||||
|
# Set environment variables for building |
||||
|
ENV NEXT_TELEMETRY_DISABLED 1 |
||||
|
|
||||
|
# Generate prisma client - make sure it exists |
||||
|
RUN pnpm prisma generate |
||||
|
|
||||
|
# Build the application |
||||
|
RUN pnpm build |
||||
|
|
||||
|
# Stage 3: Production runner |
||||
|
FROM node:22-alpine AS runner |
||||
|
WORKDIR /app |
||||
|
|
||||
|
# Install pnpm and required OpenSSL dependencies |
||||
|
RUN apk add --no-cache openssl openssl-dev |
||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate |
||||
|
|
||||
|
# Set environment variables |
||||
|
ENV NODE_ENV production |
||||
|
ENV NEXT_TELEMETRY_DISABLED 1 |
||||
|
ENV CONTAINER_RUNTIME docker |
||||
|
|
||||
|
# Add a non-root user to run the app |
||||
|
RUN addgroup --system --gid 1001 nodejs && \ |
||||
|
adduser --system --uid 1001 nextjs && \ |
||||
|
chown -R nextjs:nodejs /app |
||||
|
|
||||
|
# Copy necessary files for the standalone app |
||||
|
COPY --from=builder --chown=nextjs:nodejs /app/next.config.js ./ |
||||
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public |
||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ |
||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static |
||||
|
|
||||
|
# Copy Prisma schema and generate client during runtime |
||||
|
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma |
||||
|
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./ |
||||
|
COPY --from=builder --chown=nextjs:nodejs /app/pnpm-lock.yaml ./ |
||||
|
|
||||
|
# Install only production dependencies, including Prisma, and generate Prisma client |
||||
|
RUN pnpm install --prod --frozen-lockfile && \ |
||||
|
pnpm prisma generate |
||||
|
|
||||
|
# Switch to non-root user |
||||
|
USER nextjs |
||||
|
|
||||
|
# Expose the port the app will run on |
||||
|
ENV PORT 3000 |
||||
|
EXPOSE ${PORT} |
||||
|
|
||||
|
# Health check to verify app is running |
||||
|
# HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ |
||||
|
# CMD wget -q -O - http://localhost:${PORT}/api/health || exit 1 |
||||
|
|
||||
|
CMD ["node", "server.js"] |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue