Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
name: Recipe App CI/CD Pipeline

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
test-and-deploy:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: |
backend/package-lock.json
frontend/package-lock.json

- name: Install backend dependencies
run: |
cd backend
npm ci

- name: Install frontend dependencies
run: |
cd frontend
npm ci

- name: Run backend tests
run: |
cd backend
npm test

- name: Run frontend tests
run: |
cd frontend
npm test

- name: Authenticate to Google Cloud
if: success()
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}
project_id: ${{ secrets.GCP_PROJECT_ID }}

- name: Set up Google Cloud CLI
if: success()
uses: google-github-actions/setup-gcloud@v2

- name: Configure Docker authentication
if: success()
run: |
gcloud auth configure-docker --quiet
gcloud config set project ${{ secrets.GCP_PROJECT_ID }}

- name: Build and push backend image
if: success()
run: |
cd backend
docker build -t gcr.io/${{ secrets.GCP_PROJECT_ID }}/recipe-backend:${{ github.sha }} .
docker push gcr.io/${{ secrets.GCP_PROJECT_ID }}/recipe-backend:${{ github.sha }}

- name: Build and push frontend image
if: success()
run: |
cd frontend
BACKEND_URL=https://recipe-backend-${{ secrets.GCP_PROJECT_ID }}.run.app
docker build --build-arg VITE_API_URL=$BACKEND_URL -t gcr.io/${{ secrets.GCP_PROJECT_ID }}/recipe-frontend:${{ github.sha }} .
docker push gcr.io/${{ secrets.GCP_PROJECT_ID }}/recipe-frontend:${{ github.sha }}

- name: Deploy backend to Cloud Run
if: success()
run: |
gcloud run deploy recipe-backend \
--image gcr.io/${{ secrets.GCP_PROJECT_ID }}/recipe-backend:${{ github.sha }} \
--region ${{ secrets.GCP_REGION }} \
--platform managed \
--allow-unauthenticated \
--port 5000 \
--memory 512Mi \
--cpu 1 \
--set-env-vars NODE_ENV=production \
--no-use-google-auth || \
gcloud run deploy recipe-backend \
--image gcr.io/${{ secrets.GCP_PROJECT_ID }}/recipe-backend:${{ github.sha }} \
--region ${{ secrets.GCP_REGION }} \
--platform managed \
--allow-unauthenticated \
--port 5000 \
--memory 512Mi \
--cpu 1 \
--set-env-vars NODE_ENV=production

- name: Deploy frontend to Cloud Run
if: success()
run: |
gcloud run deploy recipe-frontend \
--image gcr.io/${{ secrets.GCP_PROJECT_ID }}/recipe-frontend:${{ github.sha }} \
--region ${{ secrets.GCP_REGION }} \
--platform managed \
--allow-unauthenticated \
--port 80 \
--memory 512Mi \
--cpu 1 \
--no-use-google-auth || \
gcloud run deploy recipe-frontend \
--image gcr.io/${{ secrets.GCP_PROJECT_ID }}/recipe-frontend:${{ github.sha }} \
--region ${{ secrets.GCP_REGION }} \
--platform managed \
--allow-unauthenticated \
--port 80 \
--memory 512Mi \
--cpu 1

- name: Get deployed URLs
if: success()
run: |
echo "Backend URL: $(gcloud run services describe recipe-backend --region=${{ secrets.GCP_REGION }} --format='value(status.url)')"
echo "Frontend URL: $(gcloud run services describe recipe-frontend --region=${{ secrets.GCP_REGION }} --format='value(status.url)')"
13 changes: 13 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
FROM node:16

WORKDIR /app

COPY package*.json ./

RUN npm install

COPY . .

EXPOSE 3000

CMD ["npm", "start"]
12 changes: 12 additions & 0 deletions backend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
node_modules
npm-debug.log
recipes.db
*.db
.env
.git
.gitignore
README.md
backend/
test/
*.test.js
.dockerignore
29 changes: 29 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
FROM node:18-alpine

WORKDIR /app

# Copy package files first for better Docker layer caching
COPY package*.json ./

# Install dependencies
RUN npm ci --only=production

# Copy source code
COPY . .

# Create directory for SQLite database
RUN mkdir -p /app/data

# Set environment variables
ENV NODE_ENV=production
ENV PORT=5000

# Expose port
EXPOSE 5000

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "const http = require('http'); const options = { host: 'localhost', port: 5000, path: '/api/health', timeout: 2000 }; const req = http.request(options, (res) => { if (res.statusCode === 200) { process.exit(0); } else { process.exit(1); } }); req.on('error', () => process.exit(1)); req.end();"

# Start the application
CMD ["npm", "start"]
15 changes: 15 additions & 0 deletions backend/backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM node:18-alpine

WORKDIR /app

COPY package*.json ./

RUN npm install

COPY . .

RUN mkdir -p /app/data

EXPOSE 5000

CMD ["npm", "start"]
168 changes: 168 additions & 0 deletions backend/backend/test/api.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
const request = require('supertest');
const app = require('../../server');

// Set test environment
process.env.NODE_ENV = 'test';

describe('Recipe API Tests', () => {
describe('GET /api/recipes', () => {
test('should return all recipes (empty or populated)', async () => {
const response = await request(app)
.get('/api/recipes')
.expect(200);

expect(Array.isArray(response.body)).toBe(true);
// Database can be empty initially, so just check it's an array
expect(response.body.length).toBeGreaterThanOrEqual(0);
});

test('should return recipes with correct structure', async () => {
// First, ensure we have at least one recipe
const newRecipe = {
name: 'Structure Test Recipe',
ingredients: 'Test ingredient',
instructions: 'Test instructions',
cookTime: '15 minutes'
};

await request(app)
.post('/api/recipes')
.send(newRecipe)
.expect(201);

const response = await request(app)
.get('/api/recipes')
.expect(200);

expect(response.body.length).toBeGreaterThan(0);
const recipe = response.body[0];
expect(recipe).toHaveProperty('id');
expect(recipe).toHaveProperty('name');
expect(recipe).toHaveProperty('ingredients');
expect(recipe).toHaveProperty('instructions');
expect(recipe).toHaveProperty('cookTime');
});
});

describe('POST /api/recipes', () => {
test('should create a new recipe', async () => {
const newRecipe = {
name: 'Test Recipe',
ingredients: 'Test ingredient 1\nTest ingredient 2',
instructions: 'Test instructions',
cookTime: '30 minutes'
};

const response = await request(app)
.post('/api/recipes')
.send(newRecipe)
.expect(201);

expect(response.body).toHaveProperty('id');
expect(response.body.name).toBe(newRecipe.name);
expect(response.body.ingredients).toBe(newRecipe.ingredients);
expect(response.body.instructions).toBe(newRecipe.instructions);
expect(response.body.cookTime).toBe(newRecipe.cookTime);
});

test('should return 400 for invalid recipe data', async () => {
const invalidRecipe = {
name: '', // Empty name should fail
ingredients: '',
instructions: '',
cookTime: ''
};

const response = await request(app)
.post('/api/recipes')
.send(invalidRecipe)
.expect(400);

expect(response.body).toHaveProperty('error', 'All fields are required');
});
});

describe('GET /api/recipes/:id', () => {
test('should return a specific recipe', async () => {
// First create a recipe to ensure we have one to fetch
const newRecipe = {
name: 'Specific Recipe Test',
ingredients: 'Test ingredient',
instructions: 'Test instructions',
cookTime: '25 minutes'
};

const createResponse = await request(app)
.post('/api/recipes')
.send(newRecipe)
.expect(201);

const recipeId = createResponse.body.id;

const response = await request(app)
.get(`/api/recipes/${recipeId}`)
.expect(200);

expect(response.body).toHaveProperty('id', recipeId);
expect(response.body).toHaveProperty('name', newRecipe.name);
});

test('should return 404 for non-existent recipe', async () => {
const response = await request(app)
.get('/api/recipes/999')
.expect(404);

expect(response.body).toHaveProperty('error', 'Recipe not found');
});
});

describe('DELETE /api/recipes/:id', () => {
test('should delete an existing recipe', async () => {
// First create a recipe to delete
const newRecipe = {
name: 'Recipe to Delete',
ingredients: 'Ingredient 1',
instructions: 'Instructions',
cookTime: '20 minutes'
};

const createResponse = await request(app)
.post('/api/recipes')
.send(newRecipe)
.expect(201);

const recipeId = createResponse.body.id;

// Then delete it
const deleteResponse = await request(app)
.delete(`/api/recipes/${recipeId}`)
.expect(200);

expect(deleteResponse.body).toHaveProperty('message', 'Recipe deleted successfully');

// Verify it's deleted
await request(app)
.get(`/api/recipes/${recipeId}`)
.expect(404);
});

test('should return 404 for deleting non-existent recipe', async () => {
const response = await request(app)
.delete('/api/recipes/999')
.expect(404);

expect(response.body).toHaveProperty('error', 'Recipe not found');
});
});

describe('GET /api/health', () => {
test('should return health status', async () => {
const response = await request(app)
.get('/api/health')
.expect(200);

expect(response.body).toHaveProperty('status', 'healthy');
expect(response.body).toHaveProperty('timestamp');
});
});
});
Loading