diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6b1f237..096fec0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,4 +1,4 @@ -name: Deploy Next.js to Raspberry Pi +name: CI and Deploy to Raspberry Pi on: push: @@ -8,11 +8,43 @@ on: - preview jobs: - deploy: - runs-on: self-hosted # Der Runner sollte auf dem Raspberry Pi laufen - + test_and_build: + runs-on: ubuntu-latest steps: - - name: Checkout Code + - name: Check Out Code + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v3 + with: + node-version: '22' + + - name: Install Dependencies + run: npm install + + - name: Run Tests + run: npm run test + + - name: Log in to GHCR + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin + + - name: Build and Push Multi-Arch Docker Image + run: | + IMAGE_NAME="ghcr.io/${{ github.repository_owner }}/my-nextjs-app:${{ github.ref_name }}" + IMAGE_NAME=$(echo "$IMAGE_NAME" | tr '[:upper:]' '[:lower:]') + docker buildx create --use + docker buildx build \ + --platform linux/arm64,linux/amd64 \ + -t "$IMAGE_NAME" \ + --push \ + . + + deploy: + runs-on: self-hosted + needs: test_and_build + steps: + - name: Check Out Code uses: actions/checkout@v4 - name: Set Environment Variables @@ -28,52 +60,59 @@ jobs: echo "PORT=4002" >> $GITHUB_ENV fi - - name: Build Docker Image + - name: Log in to GHCR run: | - IMAGE_NAME="my-nextjs-app:$DEPLOY_ENV" - docker build -t $IMAGE_NAME . + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin + + - name: Pull Docker Image + run: | + IMAGE_NAME="ghcr.io/${{ github.repository_owner }}/my-nextjs-app:${{ github.ref_name }}" + IMAGE_NAME=$(echo "$IMAGE_NAME" | tr '[:upper:]' '[:lower:]') + docker pull "$IMAGE_NAME" - name: Deploy on Raspberry Pi (Zero-Downtime) run: | + IMAGE_NAME="ghcr.io/${{ github.repository_owner }}/my-nextjs-app:${{ github.ref_name }}" + IMAGE_NAME=$(echo "$IMAGE_NAME" | tr '[:upper:]' '[:lower:]') CONTAINER_NAME="nextjs-$DEPLOY_ENV" - IMAGE_NAME="my-nextjs-app:$DEPLOY_ENV" NEW_CONTAINER_NAME="$CONTAINER_NAME-new" - NETWORK_NAME="big-bear-ghost_ghost-network" # Remove existing temporary container, if any if [ "$(docker ps -aq -f name=$NEW_CONTAINER_NAME)" ]; then - echo "Removing existing new container ($NEW_CONTAINER_NAME)..." docker rm -f "$NEW_CONTAINER_NAME" || true fi - # Start the new container on a temporary internal port - docker run -d --name "$NEW_CONTAINER_NAME" --network $NETWORK_NAME -p 40000:3000 \ + # Start new container on a temporary internal port + docker run -d --name "$NEW_CONTAINER_NAME" -p 40000:3000 \ -e GHOST_API_KEY="${{ secrets.GHOST_API_KEY }}" \ - -e NEXT_PUBLIC_BASE_URL="https://dki.one" \ - $IMAGE_NAME + -e NEXT_PUBLIC_BASE_URL="${{ secrets.NEXT_PUBLIC_BASE_URL }}" \ + -e MY_EMAIL="${{ secrets.MY_EMAIL }}" \ + -e MY_PASSWORD="${{ secrets.MY_PASSWORD }}" \ + -e GHOST_API_URL="${{ secrets.GHOST_API_URL }}" \ + "$IMAGE_NAME" - # Wait to ensure the new container is running + # Wait for the new container to start sleep 10 - # Check if the new container is running successfully - if [ "$(docker inspect --format='{{.State.Running}}' $NEW_CONTAINER_NAME)" == "true" ]; then - # Stop and remove the old container, if any + if [ "$(docker inspect --format='{{.State.Running}}' $NEW_CONTAINER_NAME)" = "true" ]; then + # Stop/remove the old container if [ "$(docker ps -aq -f name=$CONTAINER_NAME)" ]; then docker stop "$CONTAINER_NAME" || true - docker rm "$CONTAINER_NAME" || true + docker rm "$CONTAINER_NAME" || true fi - # Stop and remove the temporary new container - docker stop "$NEW_CONTAINER_NAME" || true - docker rm "$NEW_CONTAINER_NAME" || true - - # Start the container with the desired name and port - docker run -d --name "$CONTAINER_NAME" --network $NETWORK_NAME -p $PORT:3000 \ + # Replace the new container with final name/port + docker stop "$NEW_CONTAINER_NAME" || true + docker rm "$NEW_CONTAINER_NAME" || true + docker run -d --name "$CONTAINER_NAME" -p $PORT:3000 \ -e GHOST_API_KEY="${{ secrets.GHOST_API_KEY }}" \ - -e NEXT_PUBLIC_BASE_URL="https://dki.one" \ - $IMAGE_NAME + -e NEXT_PUBLIC_BASE_URL="${{ secrets.NEXT_PUBLIC_BASE_URL }}" \ + -e MY_EMAIL="${{ secrets.MY_EMAIL }}" \ + -e MY_PASSWORD="${{ secrets.MY_PASSWORD }}" \ + -e GHOST_API_URL="${{ secrets.GHOST_API_URL }}" \ + "$IMAGE_NAME" else echo "New container failed to start." - docker logs $NEW_CONTAINER_NAME + docker logs "$NEW_CONTAINER_NAME" exit 1 fi diff --git a/app/__tests__/__mocks__/mock-fetch-img.ts b/app/__tests__/__mocks__/mock-fetch-img.ts new file mode 100644 index 0000000..a035362 --- /dev/null +++ b/app/__tests__/__mocks__/mock-fetch-img.ts @@ -0,0 +1,12 @@ +export function mockFetch(data: Record) { + return jest.fn().mockImplementation(() => + Promise.resolve({ + ok: true, + headers: { + get: jest.fn().mockReturnValue('image/jpeg'), + }, + arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(8)), + json: () => data, + }), + ); +} \ No newline at end of file diff --git a/app/__tests__/__mocks__/mock-fetch-sitemap.ts b/app/__tests__/__mocks__/mock-fetch-sitemap.ts new file mode 100644 index 0000000..24201f6 --- /dev/null +++ b/app/__tests__/__mocks__/mock-fetch-sitemap.ts @@ -0,0 +1,12 @@ +export function mockFetch(responseText: string) { + return jest.fn().mockImplementation((url: string) => { + // Check if the URL being requested is our sitemap endpoint. + if (url.includes('/api/sitemap')) { + return Promise.resolve({ + ok: true, + text: () => Promise.resolve(responseText), + }); + } + return Promise.reject(new Error(`Unknown URL: ${url}`)); + }); +} \ No newline at end of file diff --git a/app/__tests__/__mocks__/mock-fetch.ts b/app/__tests__/__mocks__/mock-fetch.ts new file mode 100644 index 0000000..0107910 --- /dev/null +++ b/app/__tests__/__mocks__/mock-fetch.ts @@ -0,0 +1,8 @@ +export function mockFetch(data: Record) { + return jest.fn().mockImplementation(() => { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(data), + }); + }); +} \ No newline at end of file diff --git a/app/__tests__/__mocks__/nodemailer.js b/app/__tests__/__mocks__/nodemailer.js new file mode 100644 index 0000000..8507a21 --- /dev/null +++ b/app/__tests__/__mocks__/nodemailer.js @@ -0,0 +1,9 @@ +/** + * Jest Mock + * ./__mocks__/nodemailer.js + **/ +import nodemailer from 'nodemailer'; +import { getMockFor } from 'nodemailer-mock'; + +const nodemailermock = getMockFor(nodemailer); +export default nodemailermock; \ No newline at end of file diff --git a/app/__tests__/api/email.test.tsx b/app/__tests__/api/email.test.tsx new file mode 100644 index 0000000..5ecbd75 --- /dev/null +++ b/app/__tests__/api/email.test.tsx @@ -0,0 +1,83 @@ +import { POST } from '@/app/api/email/route'; +import { NextRequest, NextResponse } from 'next/server'; +import nodemailermock from '@/app/__tests__/__mocks__/nodemailer'; + +jest.mock('next/server', () => ({ + NextResponse: { + json: jest.fn(), + }, +})); + +beforeEach(() => { + nodemailermock.mock.reset(); + process.env.MY_EMAIL = 'test@dki.one'; + process.env.MY_PASSWORD = 'test-password'; +}); + +describe('POST /api/email', () => { + it('should send an email', async () => { + const mockRequest = { + json: jest.fn().mockResolvedValue({ + email: 'test@example.com', + name: 'Test User', + message: 'Hello!', + }), + } as unknown as NextRequest; + + await POST(mockRequest); + + expect(NextResponse.json).toHaveBeenCalledWith({ message: 'Email sent' }); + + const sentEmails = nodemailermock.mock.getSentMail(); + expect(sentEmails.length).toBe(1); + expect(sentEmails[0].to).toBe('test@dki.one'); + expect(sentEmails[0].text).toBe('Hello!\n\n' + 'test@example.com'); + }); + + it('should return an error if EMAIL or PASSWORD is missing', async () => { + delete process.env.MY_EMAIL; + delete process.env.MY_PASSWORD; + + const mockRequest = { + json: jest.fn().mockResolvedValue({ + email: 'test@example.com', + name: 'Test User', + message: 'Hello!', + }), + } as unknown as NextRequest; + + await POST(mockRequest); + + expect(NextResponse.json).toHaveBeenCalledWith({ error: 'Missing EMAIL or PASSWORD' }, { status: 500 }); + }); + + it('should return an error if request body is invalid', async () => { + const mockRequest = { + json: jest.fn().mockResolvedValue({ + email: '', + name: 'Test User', + message: 'Test message', + }), + } as unknown as NextRequest; + + await POST(mockRequest); + + expect(NextResponse.json).toHaveBeenCalledWith({ error: 'Invalid request body' }, { status: 400 }); + }); + + it('should return an error if sending email fails', async () => { + nodemailermock.mock.setShouldFail(true); + + const mockRequest = { + json: jest.fn().mockResolvedValue({ + email: 'test@example.com', + name: 'Test User', + message: 'Hello!', + }), + } as unknown as NextRequest; + + await POST(mockRequest); + + expect(NextResponse.json).toHaveBeenCalledWith({ error: 'Failed to send email' }, { status: 500 }); + }); +}); diff --git a/app/__tests__/api/fetchAllProjects.test.tsx b/app/__tests__/api/fetchAllProjects.test.tsx new file mode 100644 index 0000000..9824748 --- /dev/null +++ b/app/__tests__/api/fetchAllProjects.test.tsx @@ -0,0 +1,57 @@ +import { GET } from '@/app/api/fetchAllProjects/route'; +import { NextResponse } from 'next/server'; +import { mockFetch } from '@/app/__tests__/__mocks__/mock-fetch'; + +jest.mock('next/server', () => ({ + NextResponse: { + json: jest.fn(), + }, +})); + +describe('GET /api/fetchAllProjects', () => { + beforeAll(() => { + process.env.GHOST_API_URL = 'http://localhost:2368'; + process.env.GHOST_API_KEY = 'some-key'; + global.fetch = mockFetch({ + posts: [ + { + id: '67ac8dfa709c60000117d312', + title: 'Just Doing Some Testing', + meta_description: 'Hello bla bla bla bla', + slug: 'just-doing-some-testing', + updated_at: '2025-02-13T14:25:38.000+00:00', + }, + { + id: '67aaffc3709c60000117d2d9', + title: 'Blockchain Based Voting System', + meta_description: 'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.', + slug: 'blockchain-based-voting-system', + updated_at: '2025-02-13T16:54:42.000+00:00', + }, + ], + }); + }); + + it('should return a list of projects', async () => { + await GET(); + + expect(NextResponse.json).toHaveBeenCalledWith({ + posts: [ + { + id: '67ac8dfa709c60000117d312', + title: 'Just Doing Some Testing', + meta_description: 'Hello bla bla bla bla', + slug: 'just-doing-some-testing', + updated_at: '2025-02-13T14:25:38.000+00:00', + }, + { + id: '67aaffc3709c60000117d2d9', + title: 'Blockchain Based Voting System', + meta_description: 'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.', + slug: 'blockchain-based-voting-system', + updated_at: '2025-02-13T16:54:42.000+00:00', + }, + ], + }); + }); +}); \ No newline at end of file diff --git a/app/__tests__/api/fetchImage.test.tsx b/app/__tests__/api/fetchImage.test.tsx new file mode 100644 index 0000000..52620da --- /dev/null +++ b/app/__tests__/api/fetchImage.test.tsx @@ -0,0 +1,53 @@ +import { GET } from '@/app/api/fetchImage/route'; +import { NextRequest } from 'next/server'; +import { mockFetch } from '@/app/__tests__/__mocks__/mock-fetch-img'; + +jest.mock('next/server', () => { + class NextResponseClass { + body: unknown; + init: unknown; + constructor(body: unknown, init?: unknown) { + this.body = body; + this.init = init; + } + static json(body: unknown, init?: unknown) { + return new NextResponseClass(body, init); + } + static from(body: unknown, init?: unknown) { + return new NextResponseClass(body, init); + } + } + return { NextResponse: NextResponseClass }; +}); + +global.fetch = mockFetch({ + ok: true, + headers: { + get: jest.fn().mockReturnValue('image/jpeg'), + }, + arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(8)), +}); + +describe('GET /api/fetchImage', () => { + it('should return an error if no image URL is provided', async () => { + const mockRequest = { + url: 'http://localhost/api/fetchImage', + } as unknown as NextRequest; + + const response = await GET(mockRequest); + + expect(response.body).toEqual({ error: 'Missing URL parameter' }); + expect(response.init.status).toBe(400); + }); + + it('should fetch an image if URL is provided', async () => { + const mockRequest = { + url: 'http://localhost/api/fetchImage?url=https://example.com/image.jpg', + } as unknown as NextRequest; + + const response = await GET(mockRequest); + + expect(response.body).toBeDefined(); + expect(response.init.headers['Content-Type']).toBe('image/jpeg'); + }); +}); \ No newline at end of file diff --git a/app/__tests__/api/fetchProject.test.tsx b/app/__tests__/api/fetchProject.test.tsx new file mode 100644 index 0000000..57d74f8 --- /dev/null +++ b/app/__tests__/api/fetchProject.test.tsx @@ -0,0 +1,47 @@ +import { GET } from '@/app/api/fetchProject/route'; +import { NextRequest, NextResponse } from 'next/server'; +import { mockFetch } from '@/app/__tests__/__mocks__/mock-fetch'; + +jest.mock('next/server', () => ({ + NextResponse: { + json: jest.fn(), + }, +})); + +describe('GET /api/fetchProject', () => { + beforeAll(() => { + process.env.GHOST_API_URL = 'http://localhost:2368'; + process.env.GHOST_API_KEY = 'some-key'; + global.fetch = mockFetch({ + posts: [ + { + id: '67aaffc3709c60000117d2d9', + title: 'Blockchain Based Voting System', + meta_description: 'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.', + slug: 'blockchain-based-voting-system', + updated_at: '2025-02-13T16:54:42.000+00:00', + }, + ], + }); + }); + + it('should fetch a project by slug', async () => { + const mockRequest = { + url: 'http://localhost/api/fetchProject?slug=blockchain-based-voting-system', + } as unknown as NextRequest; + + await GET(mockRequest); + + expect(NextResponse.json).toHaveBeenCalledWith({ + posts: [ + { + id: '67aaffc3709c60000117d2d9', + title: 'Blockchain Based Voting System', + meta_description: 'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.', + slug: 'blockchain-based-voting-system', + updated_at: '2025-02-13T16:54:42.000+00:00', + }, + ], + }); + }); +}); \ No newline at end of file diff --git a/app/__tests__/api/og.test.tsx b/app/__tests__/api/og.test.tsx new file mode 100644 index 0000000..e339158 --- /dev/null +++ b/app/__tests__/api/og.test.tsx @@ -0,0 +1,14 @@ +import { GET } from '@/app/api/og/route'; +import { ImageResponse } from 'next/og'; + +jest.mock('next/og', () => ({ + ImageResponse: jest.fn(), +})); + +describe('GET /api/og', () => { + it('should return an Open Graph image', async () => { + await GET(); + + expect(ImageResponse).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/app/__tests__/api/sitemap.test.tsx b/app/__tests__/api/sitemap.test.tsx new file mode 100644 index 0000000..f97a5ed --- /dev/null +++ b/app/__tests__/api/sitemap.test.tsx @@ -0,0 +1,44 @@ +import { GET } from '@/app/api/sitemap/route'; +import { mockFetch } from '@/app/__tests__/__mocks__/mock-fetch'; + +jest.mock('next/server', () => ({ + NextResponse: jest.fn().mockImplementation((body, init) => ({ body, init })), +})); + +describe('GET /api/sitemap', () => { + beforeAll(() => { + process.env.GHOST_API_URL = 'http://localhost:2368'; + process.env.GHOST_API_KEY = 'test-api-key'; + process.env.NEXT_PUBLIC_BASE_URL = 'https://dki.one'; + global.fetch = mockFetch({ + posts: [ + { + id: '67ac8dfa709c60000117d312', + title: 'Just Doing Some Testing', + meta_description: 'Hello bla bla bla bla', + slug: 'just-doing-some-testing', + updated_at: '2025-02-13T14:25:38.000+00:00', + }, + { + id: '67aaffc3709c60000117d2d9', + title: 'Blockchain Based Voting System', + meta_description: 'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.', + slug: 'blockchain-based-voting-system', + updated_at: '2025-02-13T16:54:42.000+00:00', + }, + ], + }); + }); + + it('should return a sitemap', async () => { + const response = await GET(); + + expect(response.body).toContain(''); + expect(response.body).toContain('https://dki.one/'); + expect(response.body).toContain('https://dki.one/legal-notice'); + expect(response.body).toContain('https://dki.one/privacy-policy'); + expect(response.body).toContain('https://dki.one/projects/just-doing-some-testing'); + expect(response.body).toContain('https://dki.one/projects/blockchain-based-voting-system'); + expect(response.init.headers['Content-Type']).toBe('application/xml'); + }); +}); \ No newline at end of file diff --git a/app/__tests__/components/Contact.test.tsx b/app/__tests__/components/Contact.test.tsx new file mode 100644 index 0000000..a853957 --- /dev/null +++ b/app/__tests__/components/Contact.test.tsx @@ -0,0 +1,29 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import Contact from '@/app/components/Contact'; +import '@testing-library/jest-dom'; + +// Mock the fetch function +global.fetch = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve({ message: 'Email sent' }), + }) +) as jest.Mock; + +describe('Contact', () => { + it('renders the contact form', () => { + render(); + expect(screen.getByPlaceholderText('Name')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Email')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Message')).toBeInTheDocument(); + }); + + it('submits the form', async () => { + render(); + fireEvent.change(screen.getByPlaceholderText('Name'), { target: { value: 'John Doe' } }); + fireEvent.change(screen.getByPlaceholderText('Email'), { target: { value: 'john@example.com' } }); + fireEvent.change(screen.getByPlaceholderText('Message'), { target: { value: 'Hello!' } }); + fireEvent.click(screen.getByText('Send')); + + expect(await screen.findByText('Email sent')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/app/__tests__/components/Footer.test.tsx b/app/__tests__/components/Footer.test.tsx new file mode 100644 index 0000000..24cf2a6 --- /dev/null +++ b/app/__tests__/components/Footer.test.tsx @@ -0,0 +1,10 @@ +import { render, screen } from '@testing-library/react'; +import Footer from '@/app/components/Footer'; +import '@testing-library/jest-dom'; + +describe('Footer', () => { + it('renders the footer', () => { + render(