diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cee2f4..cff925a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: jobs: - build-and-test: + test-and-build: runs-on: ubuntu-latest services: @@ -30,7 +30,7 @@ jobs: npm ci --prefix client - name: Run tests - run: npm test + run: NODE_OPTIONS="--experimental-vm-modules" npm test env: MONGO_URI: mongodb://localhost:27017/test_db JWT_SECRET: ${{ secrets.JWT_SECRET }} @@ -41,6 +41,7 @@ jobs: GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + BCRYPT_SALT_ROUNDS: 1 - name: Build (Backend & Frontend) run: npm run build diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 49a1853..d8898a6 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -6,13 +6,63 @@ on: workflow_dispatch: jobs: - deploy: + run-tests: runs-on: self-hosted - steps: - uses: actions/checkout@v4 - name: Set up Environment + run: | + echo "PORT=${{ vars.PORT || 3000 }}" > .env + echo "NODE_ENV=test" >> .env + echo "MONGO_URI=mongodb://admin:market_watch_pass@127.0.0.1:27017/test_db?authSource=admin" >> .env + echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> .env + echo "REFRESH_TOKEN_SECRET=${{ secrets.REFRESH_TOKEN_SECRET }}" >> .env + echo "OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}" >> .env + echo "GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }}" >> .env + echo "GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}" >> .env + echo "GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}" >> .env + + - name: Use Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Run Tests on Host + run: | + npm install + docker compose up -d mongodb + sleep 15 + MONGO_URI=mongodb://admin:market_watch_pass@127.0.0.1:27017/test_db?authSource=admin \ + GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }} \ + OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }} \ + JWT_SECRET=${{ secrets.JWT_SECRET }} \ + REFRESH_TOKEN_SECRET=${{ secrets.REFRESH_TOKEN_SECRET }} \ + NODE_OPTIONS=--experimental-vm-modules \ + BCRYPT_SALT_ROUNDS=1 npm test + docker compose stop mongodb + + - name: Upload Test Report + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-report + path: test-report.html + retention-days: 1 + + deploy-app: + needs: run-tests + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - name: Download Test Report + uses: actions/download-artifact@v4 + with: + name: test-report + path: . + + - name: Set up Production Environment run: | echo "PORT=${{ vars.PORT || 3000 }}" > .env echo "NODE_ENV=${{ vars.NODE_ENV || 'production' }}" >> .env @@ -27,9 +77,10 @@ jobs: echo "GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}" >> .env echo "GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}" >> .env - - - name: Build and Restart with Docker Compose + - name: Build and Restart App Container run: | - docker-compose down - docker-compose up --build -d + docker compose down + docker compose up --build -d docker system prune -f + sleep 10 # Wait for container to fully start + docker exec market_watch_app sh -c "BCRYPT_SALT_ROUNDS=1 npm test" diff --git a/.github/workflows/sync-main.yml b/.github/workflows/sync-main.yml index d5cb8ac..cfd2a66 100644 --- a/.github/workflows/sync-main.yml +++ b/.github/workflows/sync-main.yml @@ -5,11 +5,14 @@ on: branches: [ "production" ] workflow_dispatch: +permissions: + contents: write + pull-requests: write + jobs: sync-to-main: runs-on: ubuntu-latest # ONLY run if the push to production did NOT come from main - # This avoids the "Main -> Prod -> Main" circular loop if: github.event.head_commit.author.name != 'github-actions' && !contains(github.event.head_commit.message, 'main') steps: @@ -18,15 +21,25 @@ jobs: fetch-depth: 0 token: ${{ secrets.PR_TOKEN || secrets.GITHUB_TOKEN }} - - name: Sync changes to main + - name: Create PR to sync production back to main + env: + GH_TOKEN: ${{ secrets.PR_TOKEN || secrets.GITHUB_TOKEN }} run: | - git config --global user.name 'github-actions[bot]' - git config --global user.email 'github-actions[bot]@users.noreply.github.com' + git fetch origin main production - git checkout main - git fetch origin production + # Check if there are differences + if git diff --quiet origin/main..origin/production; then + echo "No differences between production and main. Skipping." + exit 0 + fi - # Use allow-unrelated-histories to bridge the gap between branches if they diverged - git merge origin/production --allow-unrelated-histories -m "🤖 Sync hotfix from production to main" + # Create a sync branch from production + git checkout -b sync/prod-to-main origin/production - git push origin main + # Create PR using GitHub CLI + gh pr create \ + --base main \ + --head sync/prod-to-main \ + --title "🤖 Sync: Production hotfixes back to main" \ + --body "Automated PR to sync hotfix changes from production back to main." \ + || echo "PR already exists or nothing to sync." diff --git a/.gitignore b/.gitignore index c94eb5c..1c13236 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,5 @@ PAGINATION_COMPLETE.md CLEANUP_SUMMARY.md # PDF documents (specs, mockups — local only) -*.pdf +# Test reports +test-report.html diff --git a/Dockerfile b/Dockerfile index 9d3625c..e41de39 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,15 +33,12 @@ RUN npm install --omit=dev # Copy compiled backend COPY --from=build /app/dist ./dist -# Copy test report (generated during build test step if added to workflow, or copied if exists) -COPY --from=build /app/test-report.html ./test-report.html +# Copy test report (generated by GitHub Actions Host Runner) +COPY ./test-report.html ./test-report.html # Copy compiled frontend COPY --from=build /app/client/dist ./client/dist -# Copy test report (if generated) -COPY --from=build /app/test-report.html ./test-report.html - # Copy other necessary files (like uploads or assets) COPY src/uploads ./src/uploads diff --git a/ai_test_err.txt b/ai_test_err.txt new file mode 100644 index 0000000..7622c09 Binary files /dev/null and b/ai_test_err.txt differ diff --git a/all_tests_out.txt b/all_tests_out.txt new file mode 100644 index 0000000..776dd5e Binary files /dev/null and b/all_tests_out.txt differ diff --git a/client/src/components/Avatar.tsx b/client/src/components/Avatar.tsx new file mode 100644 index 0000000..d82ff5e --- /dev/null +++ b/client/src/components/Avatar.tsx @@ -0,0 +1,81 @@ +import { useState, useEffect } from 'react'; +import { Image } from 'react-bootstrap'; +import { User } from 'lucide-react'; +import { getImageUrl } from '../services/api'; + +interface AvatarProps { + src?: string | null; + username?: string; + size?: number; + className?: string; + border?: boolean; +} + +const Avatar = ({ src, username, size = 40, className = '', border = false }: AvatarProps) => { + const [error, setError] = useState(false); + + // Reset error when source changes + useEffect(() => { + setError(false); + }, [src]); + + // Reset error if src changes + const hasImage = src && + src !== 'null' && + src !== 'undefined' && + src !== '' && + !src.includes('undefined') && + !src.includes('null'); + + if (hasImage && !error) { + return ( +
+ setError(true)} + alt={username || 'User avatar'} + /> +
+ ); + } + + // Fallback: Initials or Icon with premium dynamic colors + const initials = username ? username[0].toUpperCase() : null; + + // Generate a consistent color based on username + const colors = [ + { bg: 'rgba(13, 110, 253, 0.1)', text: '#0d6efd' }, // Blue + { bg: 'rgba(102, 16, 242, 0.1)', text: '#6610f2' }, // Indigo + { bg: 'rgba(111, 66, 193, 0.1)', text: '#6f42c1' }, // Purple + { bg: 'rgba(214, 51, 132, 0.1)', text: '#d63384' }, // Pink + { bg: 'rgba(253, 126, 20, 0.1)', text: '#fd7e14' }, // Orange + { bg: 'rgba(25, 135, 84, 0.1)', text: '#198754' } // Green + ]; + + const colorIndex = username ? username.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % colors.length : 0; + const selectedColor = colors[colorIndex]; + + return ( +
+ {initials ? initials : } +
+ ); +}; + +export default Avatar; diff --git a/client/src/components/CommentSection.tsx b/client/src/components/CommentSection.tsx index 4e2af8e..baf6d82 100644 --- a/client/src/components/CommentSection.tsx +++ b/client/src/components/CommentSection.tsx @@ -1,9 +1,9 @@ import { useState, useEffect, type FormEvent } from 'react'; -import { Card, Button, Form, Image, Spinner, Dropdown } from 'react-bootstrap'; +import { Card, Button, Form, Spinner, Dropdown } from 'react-bootstrap'; import { useAuth } from '../context/AuthContext'; import { getComments, createComment, deleteComment, updateComment, type Comment } from '../services/commentService'; import { Send, MoreVertical, Trash2, Edit2, X, Check } from 'lucide-react'; -import { getImageUrl } from '../services/api'; +import Avatar from './Avatar'; interface CommentSectionProps { postId: string; @@ -104,20 +104,7 @@ const CommentSection = ({ postId }: CommentSectionProps) => {
- {user?.image ? ( - - ) : ( -
- {user?.username?.[0] || 'U'} -
- )} + {
{comments.map(comment => (
- {comment.owner?.image ? ( - - ) : ( -
- {comment.owner?.username?.[0]?.toUpperCase() || 'U'} -
- )} +
diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 36da468..5632fb1 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -1,38 +1,9 @@ -import { useState } from 'react'; import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; -import { Container, Navbar, Nav, Dropdown, Image } from 'react-bootstrap'; -import { LogOut, Search, PlusCircle, Home, TrendingUp, User, Bell } from 'lucide-react'; -import { getImageUrl } from '../services/api'; +import { Container, Navbar, Nav, Dropdown } from 'react-bootstrap'; +import { Bell, Home, LogOut, PlusCircle, Search, TrendingUp } from 'lucide-react'; -const ProfileAvatar = ({ user }: { user: any }) => { - const [imageError, setImageError] = useState(false); - - // Check if the image string is valid - const hasValidImageStr = user?.image && user.image !== 'null' && user.image !== 'undefined'; - - if (hasValidImageStr && !imageError) { - return ( - setImageError(true)} - /> - ); - } - - // Fallback UI - return ( -
- -
- ); -}; +import Avatar from './Avatar'; const Layout = () => { const { user, logout } = useAuth(); @@ -85,18 +56,23 @@ const Layout = () => { )} -