diff --git a/.env.prod b/.env.prod new file mode 100644 index 0000000..3304d85 --- /dev/null +++ b/.env.prod @@ -0,0 +1 @@ +MYSQL_ROOT_PASSWORD=root \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 350b13a..c66acf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,14 +8,14 @@ on: branches: - main -jobs: +jobs: build-test: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - + # frontend setup - name: Set up Node uses: actions/setup-node@v4 with: @@ -25,11 +25,25 @@ jobs: working-directory: ./client run: npm install + # backend setup - name: Set up PHP uses: shivammathur/setup-php@v2 with: php-version: '8.2' + extensions: mbstring, pdo, pdo_sqlite + coverage: none + ini-values: "pdo_sqlite.journal_mode=WAL" - name: Install backend deps working-directory: ./server run: composer install --no-interaction + + - name: Setup backend environment + working-directory: ./server + run: | + cp .env.example .env + php artisan key:generate + + - name: Run backend tests + working-directory: ./server + run: php artisan test --no-coverage diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c4a9c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +./.env.prod \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8bc952f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MohammadRostom + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 8ee02f2..ab89ddd 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,23 @@ -

FlowPilot

- -

- Copilot Logo -

- + +
+
+
+ +

- Flowpilot is a one of a kind n8n workflow generator.Generation of production ready, deployable and testable workflows is what Flowpilot strives in. + Flowpilot is a one of a kind n8n workflow generator.Generation of production ready, deployable and testable workflows is what Flowpilot strives in.Flowpilot helps coders + and none coders alike.Its designed to specifically understand your intent and translate it + into a production ready workflow.

- -

Architecture

- +
+
+ +
- - +
+ React LogoFrontend

React

+ Laravel LogoBackend

Laravel

+ React Logo +

Database

+

MySQL

+
-

Communication Flow

- +

General Communication Flow

FlowPilot Communication Flow Diagram

-

High-level data flow between the frontend, backend and database.

- +
+
+
+

Copilot Communication Flow

FlowPilot Communication Flow Diagram

-

n8n Generation workflow between the frontend, backend, generation module, LLM, vector-db and an open SSE connection.

+ +

Sequence Diagram

+Start + +

ER Diagram

+Start + +
+
+
+ -

System Design

+
+ +
+
+

AI Copilot

+

An intelligent workflow generation assistant that uses LLM technology to understand user intent and automatically generate production-ready n8n workflows.

+
    +
  • Real-time streaming workflow generation with progress tracking
  • +
  • Multi-stage generation pipeline with user feedback
  • +
  • Workflow history and chat management
  • +
  • Trace and debugging support for generated workflows
  • +
  • Server-sent events (SSE) connection for live updates
  • +
+
+
+ AI Copilot Demo +
+
+
+
+

Community Hub

+

A collaborative platform for automation builders to share workflows, tips, and integrations with the n8n community.

+
    +
  • Infinite scroll feed of community posts and workflows
  • +
  • Create and share posts with the community
  • +
  • Browse and discover shared workflows by other users
  • +
  • User profile cards with avatar and metadata
  • +
  • Engage with other builders in the n8n ecosystem
  • +
+
+
+ Community Hub Demo +
+
+ +
+
+

User Profiles

+

Comprehensive user profile system with detailed information and engagement statistics across the platform.

+
    +
  • Customizable avatars and profile pictures
  • +
  • Display user workflows and community posts
  • +
  • View follower and following lists
  • +
  • Track engagement metrics (likes, imports on workflows/posts)
  • +
  • Manage profile settings and preferences
  • +
  • Download workflow content and history
  • +
+
+
+ User Profiles Demo +
+
+
+
+
+
+ +
+
+
+ Copilot Demo 1 + Copilot Demo 2 +
+
+
+
+ +
+
+ +

+ This project follows a structured Git workflow designed to keep the codebase clean, + scalable, and production-ready at all times. +

+ +
+ +

Branching Model

-

Sequence Diagram

- - + + - - - + + + + + + +
- React Logo -

Frontend

-

React

-
- Laravel Logo -

Backend

-

Laravel

-
BranchPurpose
- React Logo -

Frontend

-

React

-
- Laravel Logo -

Backend

-

Laravel

-
mainProduction-ready, always stable and deployable
devIntegration branch where completed features are merged
-

ER Diagram

+ +

No direct commits are made to main.

+ +--- + +

Branch Types

+ +

All work is created from dev using task-based branches:

+ + + + + + + + + + + + +
PrefixUsage
feature/New features
fix/Bug fixes
hotfix/Urgent production fixes
refactor/Code improvements
chore/Tooling, config, dependencies
docs/Documentation updates
+ +

Naming format: type/short-description

+ +Examples: +```bash +feature/user-auth +fix/login-validation +refactor/api-layer +docs/readme-update +``` + +--- + +

Commit Philosophy

+ +Each commit should represent one logical change. + +We follow the Conventional Commits standard: + +``` +type(scope): short description +``` + +Examples: +```bash +feat(auth): add JWT login endpoint +fix(api): prevent crash on null response +refactor(db): extract query builder +docs(readme): add deployment guide +``` + +--- + +

Development Loop

+ +Here’s the exact workflow cycle used in this project: + +```bash +# Start from dev +git checkout dev +git pull + +# Create a feature branch +git checkout -b feature/follow-system + +# Work and commit in small logical steps +git commit -m "feat(db): add follows table" +git commit -m "feat(api): add follow endpoints" + +# Merge back into dev +git checkout dev +git merge feature/follow-system +git push +``` + +When the release is stable: + +```bash +# Release to production +git checkout main +git pull +git merge dev +git push + +# Sync dev with production +git checkout dev +git merge main +git push +``` + +--- + +

Why This Workflow?

+ +This structure ensures: + +- Clean and readable commit history +- Safe collaboration and easier debugging +- `main` is always deployable +- Features never break production +- Scalable long-term development + +--- +

- FlowPilot Communication Flow Diagram -

\ No newline at end of file + In short: build on dev, ship from main, and keep commits clean and meaningful. +

+
+
+
+ +
+

This project was built using a test-driven and validation-first mindset. +Every major feature was developed alongside tests to ensure reliability, prevent regressions, and maintain production-grade stability as the system evolved.

+
+

Unit tests

+ +
+
+ Testing 1 + Testing 2 + Testing 3 + Testing 4 + Testing 5 + Testing 6 + Testing 7 + Testing 8 +
+
+ + +

MIT LICENSE

+![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg) diff --git a/client/Dockerfile.dev b/client/Dockerfile.dev new file mode 100644 index 0000000..9e0c55d --- /dev/null +++ b/client/Dockerfile.dev @@ -0,0 +1,6 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install +EXPOSE 3000 +CMD ["npm", "run", "dev"] diff --git a/client/DockerFile b/client/Dockerfile.prod similarity index 100% rename from client/DockerFile rename to client/Dockerfile.prod diff --git a/client/package.json b/client/package.json index a09ea8b..a2eec0d 100644 --- a/client/package.json +++ b/client/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "docker:dev": "docker compose -f docker-compose.dev.yml up --build", + "docker:prod": "docker compose -f docker-compose.prod.yml up --build -d" }, "dependencies": { "@hookform/resolvers": "^5.2.2", diff --git a/client/src/App.tsx b/client/src/App.tsx index 115aab1..d25e41a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -32,14 +32,8 @@ import SettingsPage from './Pages/Settings/Settings' // objectives for today: /** - * Use hook to send copilot requests - * Authenticate all requests - * Clean copilot backend - * Clean copilot frontend + * * Create Tests for everything except copilot - * Create CI CD - * Deploy - * Tommorrow we need to start langchain giving us 3/4 days headstart so today everything must be perfectly done and dusted */ diff --git a/client/src/Pages/community/Community.tsx b/client/src/Pages/community/Community.tsx index eb292c5..10f6d64 100644 --- a/client/src/Pages/community/Community.tsx +++ b/client/src/Pages/community/Community.tsx @@ -8,11 +8,14 @@ import CreatePostModal from "./components/CreatePostModal"; import { adaptCommunityPost } from "./adapters/CommunityPostAdapter"; import { useAuth } from "../../context/useAuth"; import { Spinner } from "../components/Spinner"; +import { useCreatePost } from "./hook/useCreatePost"; const CommunityPage: React.FC = () => { const { user, loading } = useAuth(); const [showModal, setShowModal] = useState(false); + const createPost = useCreatePost(); + const handleStartPost = () => setShowModal(true); @@ -21,7 +24,7 @@ const CommunityPage: React.FC = () => { } const { - posts, + pages, isPending, isFetchingMore, hasMore, @@ -58,9 +61,11 @@ const CommunityPage: React.FC = () => { ))} - {posts.map((post) => ( - - ))} + {pages.map((page) => + page.data.map((post) => ( + + )) + )}
@@ -102,6 +107,7 @@ const CommunityPage: React.FC = () => { setShowModal(false)} + createPost={createPost} /> ); diff --git a/client/src/Pages/community/community.css b/client/src/Pages/community/community.css index f33d9f9..b71e040 100644 --- a/client/src/Pages/community/community.css +++ b/client/src/Pages/community/community.css @@ -541,6 +541,7 @@ body { /* Post title */ .post-title { margin-top: 14px; + margin-bottom: 14px; font-size: 18px; font-weight: 700; color: #f5b642; diff --git a/client/src/Pages/community/components/CommentItem.tsx b/client/src/Pages/community/components/CommentItem.tsx index 146fa6c..2dc8a4d 100644 --- a/client/src/Pages/community/components/CommentItem.tsx +++ b/client/src/Pages/community/components/CommentItem.tsx @@ -2,7 +2,6 @@ import { useToggleCommentLike } from "../hook/useToggleCommentLike"; const CommentItem: React.FC<{ comment: any }> = ({ comment }) => { const toggleLike = useToggleCommentLike(comment.post_id); - console.log(comment); return (
{comment.user?.photo_url ? ( diff --git a/client/src/Pages/community/components/CreatePostModal.tsx b/client/src/Pages/community/components/CreatePostModal.tsx index d784910..5ce38be 100644 --- a/client/src/Pages/community/components/CreatePostModal.tsx +++ b/client/src/Pages/community/components/CreatePostModal.tsx @@ -4,33 +4,32 @@ import { useCreatePost } from "../hook/useCreatePost"; type Props = { isOpen: boolean; onClose: () => void; + createPost: ReturnType; }; -const CreatePostModal: React.FC = ({ isOpen, onClose }) => { +const CreatePostModal: React.FC = ({ isOpen, onClose, createPost }) => { const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [file, setFile] = useState(null); const [image, setImage] = useState(null); - const createPost = useCreatePost(); - if (!isOpen) return null; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - createPost.mutate({ - title, - description, - file: file ?? undefined, - image: image ?? undefined, - }); - setTitle(""); setDescription(""); setFile(null); setImage(null); onClose(); + + createPost.mutate({ + title, + description, + file: file ?? undefined, + image: image ?? undefined, + }); }; const handleImageDrop = (e: React.DragEvent) => { @@ -49,7 +48,6 @@ const CreatePostModal: React.FC = ({ isOpen, onClose }) => {

Create New Post

- {/* LEFT COLUMN: Image */}
= ({ isOpen, onClose }) => {
- {/* RIGHT COLUMN: Form */}
)} -
{post.title &&

{post.title}

} diff --git a/client/src/Pages/community/components/PostCardSkeleton.tsx b/client/src/Pages/community/components/PostCardSkeleton.tsx index bab5832..be74aae 100644 --- a/client/src/Pages/community/components/PostCardSkeleton.tsx +++ b/client/src/Pages/community/components/PostCardSkeleton.tsx @@ -8,8 +8,7 @@ export const PostCardSkeleton = () =>{
- -
+
diff --git a/client/src/Pages/community/hook/useCreatePost.ts b/client/src/Pages/community/hook/useCreatePost.ts index 399effe..84da499 100644 --- a/client/src/Pages/community/hook/useCreatePost.ts +++ b/client/src/Pages/community/hook/useCreatePost.ts @@ -3,6 +3,8 @@ import { api } from "../../../api/client"; import { returnDataFormat } from "../../utils/returnApiDataFormat"; import { useToast } from "../../../context/toastContext"; import { ToastMessage } from "../../components/toast/toast.types"; +import { useAuth } from "../../../context/useAuth"; +import type { PostDto } from "../types"; interface CreatePostInputs { title: string; @@ -14,19 +16,43 @@ interface CreatePostInputs { export const useCreatePost = () => { const queryClient = useQueryClient(); const { showToast } = useToast(); + const { user } = useAuth(); return useMutation({ mutationFn: async (inputs: CreatePostInputs) => createNewPost(inputs), - onError: (_err, _newPost, context: any) => { - if (context?.previousPosts) { - queryClient.setQueryData(["posts"], context.previousPosts); - } + onMutate: async (inputs: CreatePostInputs) => { + await queryClient.cancelQueries({ queryKey: ["community-posts"] }); + + const previousData = queryClient.getQueryData(["community-posts"]); + + const optimisticPost: PostDto = { + id: Date.now(), // Temporary ID + author: `${user?.first_name} ${user?.last_name}`.trim(), + username: user?.email || "User", + avatar: user?.profile_pic || null, + title: inputs.title, + content: inputs.description || "", + photo: "", // Will be updated after server response + likes: 0, + comments: 0, + exports: 0, + liked_by_me: false, + }; + + queryClient.setQueryData( + ["community-posts"], + prependOptimisticPost(optimisticPost) + ); + + return { previousData }; }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["posts"] }); - showToast("Post released successfully" , ToastMessage.SUCCESS); + onError: (_err, _inputs, context: any) => { + if(context?.previousData){ + queryClient.setQueryData(["community-posts"], context.previousData); + } + showToast("Failed to create post", ToastMessage.ERROR); }, }); }; @@ -52,3 +78,22 @@ const createNewPost = async (inputs : CreatePostInputs) =>{ return returnDataFormat(resp); } + + +const prependOptimisticPost = + (optimisticPost: PostDto) => + (old: any) => { + if (!old) return old; + + return { + ...old, + pages: old.pages.map((page: any, index: number) => + index === 0 + ? { + ...page, + data: [optimisticPost, ...page.data], + } + : page + ), + }; + }; diff --git a/client/src/Pages/community/hook/useFetchPosts.ts b/client/src/Pages/community/hook/useFetchPosts.ts index 5702195..f76003f 100644 --- a/client/src/Pages/community/hook/useFetchPosts.ts +++ b/client/src/Pages/community/hook/useFetchPosts.ts @@ -22,6 +22,7 @@ export function useFetchPosts() { query.data?.pages.flatMap((page) => page.data) ?? []; return { + pages: query.data?.pages ?? [], posts, isPending: query.isPending, isFetchingMore: query.isFetchingNextPage, diff --git a/client/src/Pages/community/hook/useToggleLike.ts b/client/src/Pages/community/hook/useToggleLike.ts index e3cc54b..0602832 100644 --- a/client/src/Pages/community/hook/useToggleLike.ts +++ b/client/src/Pages/community/hook/useToggleLike.ts @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "../../../api/client"; import { returnDataFormat } from "../../utils/returnApiDataFormat"; -export function useToggleLike() { +export function useToggleLike(userId?: string | number) { const queryClient = useQueryClient(); return useMutation({ @@ -10,27 +10,44 @@ export function useToggleLike() { onMutate: async () => { await queryClient.cancelQueries({ queryKey: ["community-posts"] }); + if (userId) { + await queryClient.cancelQueries({ queryKey: ["profile-details", userId] }); + } }, onSuccess: (data, postId) => { queryClient.setQueryData(["community-posts"], (old: any) => { if (!old) return old; - return liveUpdateOnSuccess(old , postId , data); }); + + if (userId) { + queryClient.setQueryData(["profile-details", userId], (old: any) => { + if (!old?.posts?.items) return old; + + return { + ...old, + posts: { + ...old.posts, + items: old.posts.items.map((post: any) => + post.id === postId + ? { ...post, likes: data.likes, liked_by_me: data.liked } + : post + ), + }, + }; + }); + } }, }); } - - const toggleLike = async (postId : number)=>{ const response = await api.post(`auth/community/toggleLike/${postId}`); return returnDataFormat(response); } - const liveUpdateOnSuccess = (old : any , postId : number , data : any) =>{ return { ...old, diff --git a/client/src/Pages/copilot/hooks/data/streamResponse.ts b/client/src/Pages/copilot/hooks/data/streamResponse.ts index 078cb0a..b641c1d 100644 --- a/client/src/Pages/copilot/hooks/data/streamResponse.ts +++ b/client/src/Pages/copilot/hooks/data/streamResponse.ts @@ -44,6 +44,23 @@ export const streamCopilotQuestion = ( evt.close(); }); + evt.addEventListener("postWorkflowSuccess", (e) => { + try { + const data = JSON.parse(e.data); + showToast(data.message ?? "Workflow posted successfully to n8n instance.", "success"); + }catch{ + showToast("Workflow posted successfully to n8n instance.", "success"); + } + }); + + evt.addEventListener("postWorkflowFailed", (e) => { + try { + const data = JSON.parse(e.data); + showToast(data.message ?? "Failed to post workflow to n8n instance.", "error"); + }catch{ + showToast("Failed to post workflow to n8n instance.", "error"); + } + }); evt.onerror = () =>{ onError?.(); diff --git a/client/src/Pages/profile/Profile.tsx b/client/src/Pages/profile/Profile.tsx index 51b520d..1516bd5 100644 --- a/client/src/Pages/profile/Profile.tsx +++ b/client/src/Pages/profile/Profile.tsx @@ -27,7 +27,7 @@ const ProfilePage: React.FC = () => { isPending, } = useProfileQuery(userId); - const { mutate: downloadHistory, isPending: isDownloading } = + const { mutate: downloadHistory, isPending: isDownloading, downloadingId } = useDownloadHistory(); const { mutate: followUser } = useFollowUser(); @@ -95,20 +95,22 @@ const ProfilePage: React.FC = () => {
{tab === "posts" ? ( - + ) : ( isOwnProfile ? ( downloadHistory(url)} + onDownload={(url: string, id: string | number) => downloadHistory(url, id)} isDownloading={isDownloading} + downloadingId={downloadingId} /> ) : ( isBeingFollowed?.isFollowing && ( downloadHistory(url)} + onDownload={(url: string, id: string | number) => downloadHistory(url, id)} isDownloading={isDownloading} + downloadingId={downloadingId} /> ) ) diff --git a/client/src/Pages/profile/components/PostsList.tsx b/client/src/Pages/profile/components/PostsList.tsx index bea51e9..64d47dd 100644 --- a/client/src/Pages/profile/components/PostsList.tsx +++ b/client/src/Pages/profile/components/PostsList.tsx @@ -7,9 +7,10 @@ import { SortType } from "../types"; type Props = { posts: any[]; sortBy: SortType; + userId?: string | number; }; -const PostsList: React.FC = ({ posts, sortBy }) => { +const PostsList: React.FC = ({ posts, sortBy, userId }) => { const sortedPosts = useMemo(() => { const list = [...posts]; if (sortBy === SortType.LIKES) { @@ -36,6 +37,7 @@ const PostsList: React.FC = ({ posts, sortBy }) => { post={adaptListPost(p)} showHeader={false} showActions={true} + userId={userId} /> ))} diff --git a/client/src/Pages/profile/components/WorkflowList.tsx b/client/src/Pages/profile/components/WorkflowList.tsx index 9194e17..7f0edc0 100644 --- a/client/src/Pages/profile/components/WorkflowList.tsx +++ b/client/src/Pages/profile/components/WorkflowList.tsx @@ -3,11 +3,12 @@ import { Loader2, Download } from "lucide-react"; type Props = { workflows: any[]; - onDownload: (url: string) => void; + onDownload: (url: string, id: string | number) => void; isDownloading?: boolean; + downloadingId?: string | number | null; }; -const WorkflowsList: React.FC = ({ workflows, onDownload , isDownloading }) => { +const WorkflowsList: React.FC = ({ workflows, onDownload , isDownloading, downloadingId }) => { if (!workflows || workflows.length === 0) { return
No workflows / history available.
; } @@ -15,26 +16,31 @@ const WorkflowsList: React.FC = ({ workflows, onDownload , isDownloading return (
    - {workflows.map((w: any) => ( -
  • -
    - {w.id ?? w.filename ?? "history"} - - - {new Date(w.created_at ?? w.date ?? "").toLocaleString()} -
    + {workflows.map((w: any) => { + const workflowId = w.id ?? w.filename ?? `${Math.random()}`; + const isThisItemDownloading = downloadingId === workflowId && isDownloading; + + return ( +
  • +
    + {workflowId} + - + {new Date(w.created_at ?? w.date ?? "").toLocaleString()} +
    - -
  • - ))} + + + ); + })}
); diff --git a/client/src/Pages/profile/hook/useFollowUser.ts b/client/src/Pages/profile/hook/useFollowUser.ts index e4aa1b3..7de24b3 100644 --- a/client/src/Pages/profile/hook/useFollowUser.ts +++ b/client/src/Pages/profile/hook/useFollowUser.ts @@ -10,45 +10,89 @@ export const useFollowUser = () =>{ const queryClient = useQueryClient(); const { showToast } = useToast(); return useMutation({ - mutationFn: (param : FollowUserParam) => followUser(param), + mutationFn: (userId : FollowUserParam) => followUser(userId), onMutate: async (userId) => { + if (!userId) return; + await queryClient.cancelQueries({ queryKey: ["is-being-followed", userId] }); + await queryClient.cancelQueries({ queryKey: ["profile-details", userId] }); - const previous = queryClient.getQueryData(["is-being-followed", userId]); + // Get previous data for rollback + const previousFollowStatus = queryClient.getQueryData(["is-being-followed", userId]); + const previousProfile = queryClient.getQueryData(["profile-details", userId]); + // Update follow status queryClient.setQueryData(["is-being-followed", userId], (old: any) => { if (!old) return old; return { - ...old, - isFollowing: !old.isFollowing, + ...old, + isFollowing: !old.isFollowing, }; }); - return { previous }; + // Update followers count in profile + queryClient.setQueryData(["profile-details", userId], (old: any) => { + if (!old) return old; + const currentFollowStatus = queryClient.getQueryData(["is-being-followed", userId]); + const isNowFollowing = currentFollowStatus?.isFollowing; + + const followers = old.followers || []; + const authUser = queryClient.getQueryData(["profile-details", "me"])?.user; + + if (isNowFollowing && authUser) { + // Add current user to followers if following + return { + ...old, + followers: [ + { + id: authUser.id, + full_name: `${authUser.first_name} ${authUser.last_name}`.trim(), + photo_url: authUser.photo_url, + email: authUser.email, + }, + ...followers, + ], + }; + } else { + // Remove current user from followers if unfollowing + return { + ...old, + followers: followers.filter((f: any) => f.id !== authUser?.id), + }; + } + }); + + return { previousFollowStatus, previousProfile }; }, onError: (err, userId, context) => { + if (!userId) return; queryClient.setQueryData( ["is-being-followed", userId], - context?.previous + context?.previousFollowStatus + ); + queryClient.setQueryData( + ["profile-details", userId], + context?.previousProfile ); handleApiError(err , showToast); }, onSuccess: (_, userId) => { - if (!userId) return; + if (!userId) return; - queryClient.invalidateQueries({ - queryKey: ["is-being-followed", userId], - }); + queryClient.invalidateQueries({ + queryKey: ["is-being-followed", userId], + }); - queryClient.invalidateQueries({ - queryKey: ["profile-details", userId], - }); + queryClient.invalidateQueries({ + queryKey: ["profile-details", userId], + }); }, }) } -const followUser = async (param: FollowUserParam) =>{ - const res = api.post(`auth/profile/follow/${param}`); +const followUser = async (userId: FollowUserParam) =>{ + if (!userId) throw new Error("User ID is required"); + const res = await api.post(`auth/profile/follow/${userId}`); return returnDataFormat(res); } \ No newline at end of file diff --git a/client/src/Pages/profile/hook/useGetDownloadContent.ts b/client/src/Pages/profile/hook/useGetDownloadContent.ts index 566c8db..4b7497f 100644 --- a/client/src/Pages/profile/hook/useGetDownloadContent.ts +++ b/client/src/Pages/profile/hook/useGetDownloadContent.ts @@ -1,17 +1,38 @@ import { useMutation } from "@tanstack/react-query" import { api } from "../../../api/client"; +import { useState } from "react"; export const useDownloadHistory = () => { - return useMutation({ - mutationFn: downloadHistoryRequest, + const [downloadingId, setDownloadingId] = useState(null); + + const mutation = useMutation({ + mutationFn: (data: { url: string; id: string | number }) => { + setDownloadingId(data.id); + return downloadHistoryRequest(data.url); + }, onSuccess: (blobData) => { createDownloadFile(blobData) }, + onSettled: () => { + setDownloadingId(null); + }, }); + + return { + mutate: (url: string, id: string | number) => mutation.mutate({ url, id }), + isPending: mutation.isPending, + downloadingId, + }; }; const downloadHistoryRequest = async (url: string) => { - const res = await api.get("auth/profile" + url, { + // Check if URL is absolute (contains http/https) + const isAbsoluteUrl = url.startsWith('http://') || url.startsWith('https://'); + + // If absolute URL, use it directly; otherwise, prepend the auth/profile path + const endpoint = isAbsoluteUrl ? url : "auth/profile" + url; + + const res = await api.get(endpoint, { responseType: "blob", }); diff --git a/client/src/Pages/profile/profile.css b/client/src/Pages/profile/profile.css index 376a162..8584410 100644 --- a/client/src/Pages/profile/profile.css +++ b/client/src/Pages/profile/profile.css @@ -236,7 +236,6 @@ } .post-header { display:flex; justify-content:space-between; align-items:center; gap:12px; } -.post-title { margin:0; font-size:16px; } .post-body { margin:8px 0; color: rgba(255,255,255,0.9); } .post-stats { display:flex; gap:12px; font-size:13px; opacity:0.95; } diff --git a/client/src/assets/README-Diagrams/SD-1.png b/client/src/assets/README-Diagrams/SD-1.png deleted file mode 100644 index b46fddb..0000000 Binary files a/client/src/assets/README-Diagrams/SD-1.png and /dev/null differ diff --git a/client/src/assets/README-Diagrams/SD-2.png b/client/src/assets/README-Diagrams/SD-2.png deleted file mode 100644 index 7de96ed..0000000 Binary files a/client/src/assets/README-Diagrams/SD-2.png and /dev/null differ diff --git a/client/src/assets/README-Diagrams/SD-3.png b/client/src/assets/README-Diagrams/SD-3.png deleted file mode 100644 index 06656cb..0000000 Binary files a/client/src/assets/README-Diagrams/SD-3.png and /dev/null differ diff --git a/client/src/assets/README-Diagrams/SD-4.png b/client/src/assets/README-Diagrams/SD-4.png deleted file mode 100644 index 01d1d21..0000000 Binary files a/client/src/assets/README-Diagrams/SD-4.png and /dev/null differ diff --git a/client/src/styles/Copilot.css b/client/src/styles/Copilot.css index 80d1a64..855a63c 100644 --- a/client/src/styles/Copilot.css +++ b/client/src/styles/Copilot.css @@ -750,6 +750,10 @@ word-wrap: break-word; } +.trace-block li{ + margin-left: 12px; +} + .kv, .kv-value, .typed-line { max-width: 100%; white-space: pre-wrap; diff --git a/client/vite.config.ts b/client/vite.config.ts index 8b0f57b..4da55a1 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -4,4 +4,14 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + server: { + host: true, + port: 3000, + proxy: { + '/api': { + target: 'http://backend_nginx:80', + changeOrigin: true, + }, + }, + }, }) diff --git a/docker-compose.yml b/docker-compose.dev.yml similarity index 60% rename from docker-compose.yml rename to docker-compose.dev.yml index c6508c3..0eb487a 100644 --- a/docker-compose.yml +++ b/docker-compose.dev.yml @@ -1,18 +1,30 @@ version: "3.9" services: + frontend: + build: + context: ./client + dockerfile: Dockerfile.dev + ports: + - "3000:3000" + volumes: + - ./client:/app + - node_modules:/app/node_modules + environment: + - CHOKIDAR_USEPOLLING=true + command: npm run dev + backend: build: context: ./server - container_name: laravel_backend volumes: - ./server:/var/www - depends_on: - - db + environment: + APP_ENV: local + APP_DEBUG: "true" backend_nginx: image: nginx:alpine - container_name: laravel_nginx ports: - "8000:80" volumes: @@ -21,26 +33,12 @@ services: depends_on: - backend - frontend: - build: - context: ./client - container_name: react_frontend - ports: - - "3000:80" - - - - db: image: mysql:8.0 - container_name: mysql_db environment: MYSQL_DATABASE: laravel MYSQL_ROOT_PASSWORD: root - ports: - - "3306:3306" - volumes: - - db_data:/var/lib/mysql - volumes: - db_data: + node_modules: +# run it with: docker compose -f docker-compose.dev.yml up --build + diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..71847e1 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,43 @@ +version: "3.9" + +services: + frontend: + build: + context: ./client + dockerfile: Dockerfile.prod + ports: + - "80:80" + restart: always + + backend: + build: + context: ./server + environment: + APP_ENV: production + APP_DEBUG: "false" + restart: always + + backend_nginx: + image: nginx:alpine + ports: + - "8000:80" + volumes: + - ./server/nginx.conf:/etc/nginx/conf.d/default.conf + depends_on: + - backend + restart: always + + db: + env_file: + - .env.prod + image: mysql:8.0 + environment: + MYSQL_DATABASE: laravel + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + volumes: + - db_data:/var/lib/mysql + restart: always + +volumes: + db_data: +#run it with : docker compose -f docker-compose.prod.yml up --build -d diff --git a/client/src/assets/README-Diagrams/ER-Diagram.png b/readme/README-Diagrams/ER-Diagram.png similarity index 100% rename from client/src/assets/README-Diagrams/ER-Diagram.png rename to readme/README-Diagrams/ER-Diagram.png diff --git a/client/src/assets/README-Diagrams/GeneratorFlow.png b/readme/README-Diagrams/GeneratorFlow.png similarity index 100% rename from client/src/assets/README-Diagrams/GeneratorFlow.png rename to readme/README-Diagrams/GeneratorFlow.png diff --git a/client/src/assets/README-Diagrams/High-LevelWorkflow.png b/readme/README-Diagrams/High-LevelWorkflow.png similarity index 100% rename from client/src/assets/README-Diagrams/High-LevelWorkflow.png rename to readme/README-Diagrams/High-LevelWorkflow.png diff --git a/readme/README-Diagrams/NSD1.png b/readme/README-Diagrams/NSD1.png new file mode 100644 index 0000000..4951e08 Binary files /dev/null and b/readme/README-Diagrams/NSD1.png differ diff --git a/readme/README-Diagrams/mysql-removebg-preview.png b/readme/README-Diagrams/mysql-removebg-preview.png new file mode 100644 index 0000000..b6e7452 Binary files /dev/null and b/readme/README-Diagrams/mysql-removebg-preview.png differ diff --git a/readme/cards/Arch.svg b/readme/cards/Arch.svg new file mode 100644 index 0000000..ce48129 --- /dev/null +++ b/readme/cards/Arch.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/readme/cards/demo.svg b/readme/cards/demo.svg new file mode 100644 index 0000000..0fa82a0 --- /dev/null +++ b/readme/cards/demo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/readme/cards/deployment.svg b/readme/cards/deployment.svg new file mode 100644 index 0000000..2b42afe --- /dev/null +++ b/readme/cards/deployment.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/readme/cards/developmentAndTesting.svg b/readme/cards/developmentAndTesting.svg new file mode 100644 index 0000000..c38e5c3 --- /dev/null +++ b/readme/cards/developmentAndTesting.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/readme/cards/github.svg b/readme/cards/github.svg new file mode 100644 index 0000000..cdd795e --- /dev/null +++ b/readme/cards/github.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/readme/cards/projectHighlights.svg b/readme/cards/projectHighlights.svg new file mode 100644 index 0000000..208df14 --- /dev/null +++ b/readme/cards/projectHighlights.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/readme/cards/projectOverview.svg b/readme/cards/projectOverview.svg new file mode 100644 index 0000000..d7a10e4 --- /dev/null +++ b/readme/cards/projectOverview.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/readme/cards/systemDesign.svg b/readme/cards/systemDesign.svg new file mode 100644 index 0000000..dcbaf74 --- /dev/null +++ b/readme/cards/systemDesign.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/readme/cards/title.svg b/readme/cards/title.svg new file mode 100644 index 0000000..e6274e0 --- /dev/null +++ b/readme/cards/title.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/readme/demo/Demo-Community.png b/readme/demo/Demo-Community.png new file mode 100644 index 0000000..400ba1c Binary files /dev/null and b/readme/demo/Demo-Community.png differ diff --git a/readme/demo/Demo-Copilot.png b/readme/demo/Demo-Copilot.png new file mode 100644 index 0000000..5e54e9f Binary files /dev/null and b/readme/demo/Demo-Copilot.png differ diff --git a/readme/demo/Demo-Profile.png b/readme/demo/Demo-Profile.png new file mode 100644 index 0000000..51108f8 Binary files /dev/null and b/readme/demo/Demo-Profile.png differ diff --git a/readme/demo/demo-copilot-1.gif b/readme/demo/demo-copilot-1.gif new file mode 100644 index 0000000..8ae7420 Binary files /dev/null and b/readme/demo/demo-copilot-1.gif differ diff --git a/readme/demo/demo-copilot-2.gif b/readme/demo/demo-copilot-2.gif new file mode 100644 index 0000000..afb83b0 Binary files /dev/null and b/readme/demo/demo-copilot-2.gif differ diff --git a/readme/testing/testing-1.png b/readme/testing/testing-1.png new file mode 100644 index 0000000..ba99b78 Binary files /dev/null and b/readme/testing/testing-1.png differ diff --git a/readme/testing/testing-2.png b/readme/testing/testing-2.png new file mode 100644 index 0000000..90c51bd Binary files /dev/null and b/readme/testing/testing-2.png differ diff --git a/readme/testing/testing-3.png b/readme/testing/testing-3.png new file mode 100644 index 0000000..4bddaee Binary files /dev/null and b/readme/testing/testing-3.png differ diff --git a/readme/testing/testing-4.png b/readme/testing/testing-4.png new file mode 100644 index 0000000..abc0237 Binary files /dev/null and b/readme/testing/testing-4.png differ diff --git a/readme/testing/testing-5.png b/readme/testing/testing-5.png new file mode 100644 index 0000000..856f6a4 Binary files /dev/null and b/readme/testing/testing-5.png differ diff --git a/readme/testing/testing-6.png b/readme/testing/testing-6.png new file mode 100644 index 0000000..3e211a2 Binary files /dev/null and b/readme/testing/testing-6.png differ diff --git a/readme/testing/testing-7.png b/readme/testing/testing-7.png new file mode 100644 index 0000000..9e83d10 Binary files /dev/null and b/readme/testing/testing-7.png differ diff --git a/readme/testing/testing-8.png b/readme/testing/testing-8.png new file mode 100644 index 0000000..4264798 Binary files /dev/null and b/readme/testing/testing-8.png differ diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..a92b22b --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,4 @@ +vendor +node_modules +public/storage +.env \ No newline at end of file diff --git a/server/DockerFile b/server/DockerFile index 989ec55..31f53b8 100644 --- a/server/DockerFile +++ b/server/DockerFile @@ -26,12 +26,19 @@ WORKDIR /var/www COPY . . # Install Laravel dependencies -# RUN composer install --no-interaction --prefer-dist --optimize-autoloader (mounted in docker-compose so no need to build it twice) +RUN composer install --no-dev --optimize-autoloader # Set permissions -RUN chown -R www-data:www-data /var/www -RUN chmod -R 755 /var/www/storage /var/www/bootstrap/cache +RUN mkdir -p /var/www/storage /var/www/bootstrap/cache \ + && chown -R www-data:www-data /var/www \ + && chmod -R 755 /var/www/storage /var/www/bootstrap/cache + EXPOSE 9000 CMD ["php-fpm"] + +# in prod run :RUN php artisan config:clear \ +# && php artisan config:cache \ +# && php artisan route:cache \ +# && php artisan view:cache diff --git a/server/app/Http/Controllers/FollowerController.php b/server/app/Http/Controllers/FollowerController.php index 5779af0..063d63f 100644 --- a/server/app/Http/Controllers/FollowerController.php +++ b/server/app/Http/Controllers/FollowerController.php @@ -11,8 +11,8 @@ class FollowerController extends Controller{ public function followUser(Request $request , int $toBeFollowed){ $userId = $request->user()->id; - ProfileService::toggeleFollow($userId, $toBeFollowed); - return $this->successResponse([] , "User followed successfully"); + $result = ProfileService::toggeleFollow($userId, $toBeFollowed); + return $this->successResponse($result , "User followed successfully"); } public function isFollowed(Request $request , int $toBeChecked){ diff --git a/server/app/Http/Controllers/UserCopilotHistoryController.php b/server/app/Http/Controllers/UserCopilotHistoryController.php index 0bcd86f..bdf0f50 100644 --- a/server/app/Http/Controllers/UserCopilotHistoryController.php +++ b/server/app/Http/Controllers/UserCopilotHistoryController.php @@ -5,17 +5,15 @@ use App\Models\UserCopilotHistory; use App\Models\Message; use App\Http\Controllers\Controller; +use App\Service\UserCopilotHistoryService; use App\Service\UserService; -use Exception; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Log; class UserCopilotHistoryController extends Controller{ public function index(Request $request){ $userId = $request->user()->id; - $histories = UserService::getChatHistory($userId); - + $histories = UserCopilotHistoryService::getUserHistories($userId); return $this->successResponse([ 'histories' => $histories, ]); @@ -23,13 +21,8 @@ public function index(Request $request){ public function show(Request $request , UserCopilotHistory $userCopilotHistory){ $userId = $request->user()->id; - if ($userCopilotHistory->user_id !== $userId) { - return $this->errorResponse('History not found', [], 404); - } - $userCopilotHistory->load(['messages' => function ($query) { - $query->orderBy('created_at'); - }]); + $userCopilotHistory = UserCopilotHistoryService::getUserCopilotHistoryDetials($userId , $userCopilotHistory); return $this->successResponse([ 'history' => $userCopilotHistory, @@ -38,30 +31,14 @@ public function show(Request $request , UserCopilotHistory $userCopilotHistory){ public function destroy(Request $request , UserCopilotHistory $userCopilotHistory){ $userId = $request->user()->id; - if ($userCopilotHistory->user_id !== $userId) { - return $this->errorResponse('History not found', [], 404); - } - - Message::where('history_id', $userCopilotHistory->id)->delete(); - - $userCopilotHistory->delete(); + UserCopilotHistoryService::deleteHistory($userId , $userCopilotHistory); return $this->successResponse([], 'History deleted'); } public function download(Request $request , UserCopilotHistory $history){ - if ($history->user_id !== $request->user()->id()){ - abort(403); - } - - $lastMessage = $history->messages() - ->latest('created_at') - ->first(); - - if (!$lastMessage || !$lastMessage->ai_response) { - abort(404, 'No AI response found'); - } - + $userId = $request->user()->id; + $lastMessage = UserCopilotHistoryService::getDownloadableContent($userId, $history); return response()->json( $lastMessage->ai_response, 200, diff --git a/server/app/Models/Message.php b/server/app/Models/Message.php index 87dc932..0c9ca24 100644 --- a/server/app/Models/Message.php +++ b/server/app/Models/Message.php @@ -2,9 +2,12 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Message extends Model{ + use HasFactory; + protected $table = "messages"; protected $fillable = [ diff --git a/server/app/Service/AuthService.php b/server/app/Service/AuthService.php index 17855ee..b53b9a8 100644 --- a/server/app/Service/AuthService.php +++ b/server/app/Service/AuthService.php @@ -111,28 +111,6 @@ public static function unlinkGoogle(User $user): void{ } public static function linkN8nAccount(Model $user , array $data){ - /** @var Response */ - $response = Http::withHeaders([ - 'X-N8N-API-KEY' => $data["api_key"], - ])->get(rtrim($data["base_url"], '/') . '/api/v1/workflows'); - - $contentType = $response->header('Content-Type'); - - if (!str_contains($contentType, 'application/json')) { - throw new UserFacingException('Invalid n8n response.Consider using a different api key'); - } - - $body = $response->json(); - - if (!isset($body['data']) || !is_array($body['data'])) { - throw new UserFacingException('Invalid n8n API response.Consider using a different api key'); - } - - - if (!$response->successful()) { - throw new UserFacingException("Failed to connect to n8n"); - } - $user->n8n_base_url = $data["base_url"]; $user->n8n_api_key = $data["api_key"]; $user->save(); @@ -172,12 +150,13 @@ private static function authenticationReturnFormat(array | Model $user , string private static function createToken(User $user): string{ $now = time(); + $expirationTime = (int)env('TOKEN_EXPIRATION_TIME', 604800); // 7 days default $payload = [ 'iss' => config('app.url'), 'sub' => $user->id, 'iat' => $now, - 'exp' => $now + env('TOKEN_EXPIRATION_TIME'), // 7 days + 'exp' => $now + $expirationTime, ]; return JWT::encode($payload, self::getJwtSecret(), 'HS256'); diff --git a/server/app/Service/Copilot/GetAnswer.php b/server/app/Service/Copilot/GetAnswer.php index 4768fce..145ec96 100644 --- a/server/app/Service/Copilot/GetAnswer.php +++ b/server/app/Service/Copilot/GetAnswer.php @@ -20,10 +20,10 @@ public static function execute(array $messages , ?callable $stream = null){ $error = self::initializeError($stream); try{ - $analysis = AnalyzeIntent::analyze($messages , $stage , $trace);// clean - $points = GetPoints::execute($analysis , $stage , $trace);// clean - $finalPoints = RankingFlows::rank($analysis, $points , $stage);// clean - $workflow = LLMService::generateAnswer($analysis, $finalPoints , $stage , $trace);// clean + $analysis = AnalyzeIntent::analyze($messages , $stage , $trace); + $points = GetPoints::execute($analysis , $stage , $trace); + $finalPoints = RankingFlows::rank($analysis, $points , $stage); + $workflow = LLMService::generateAnswer($analysis, $finalPoints , $stage , $trace); $validateWorkflowService = new ValidateFlowLogicService(); $workflow = $validateWorkflowService->execute($workflow , $analysis , $finalPoints ,$stage , $trace); @@ -47,7 +47,7 @@ private static function initializeStage($stream){ return fn($name) => $stream && $stream("stage", $name);// shorthand (sends chunks were events = 'stage' and payload is the name of the event) } - private static function initializeTrace($stream){ + public static function initializeTrace($stream){ return fn($type, $payload) => $stream && $stream("trace", [// shortand (sends more complex chunks where payload can be anything and events hold the type of the event themselves) "type" => $type, "payload" => $payload diff --git a/server/app/Service/Copilot/LLMService.php b/server/app/Service/Copilot/LLMService.php index a993e45..c16f2e3 100644 --- a/server/app/Service/Copilot/LLMService.php +++ b/server/app/Service/Copilot/LLMService.php @@ -176,13 +176,13 @@ public static function generateWorkflowQdrantPayload(string $json , string $ques "model" => "gpt-4.1-mini", "temperature" => 0, "messages" => [ - ["role"=>"system","content"=>"You are an n8n workflow analyzer"], + ["role"=>"system","content"=>"You are an n8n workflow analyzer, You must return only json data as per the user's request."], ["role"=>"user","content"=>$prompt] ] ]); - + Log::debug("Workflow metadata response" , ["response" => $response->json()]); $metaData = trim($response->json("choices.0.message.content")); - $metaDataDecoded = json_decode($metaData, true); + $metaDataDecoded = json_decode($metaData, true); if (json_last_error() !== JSON_ERROR_NONE) { Log::error('Failed to decode metadata JSON', [ diff --git a/server/app/Service/Copilot/PostWorkflow.php b/server/app/Service/Copilot/PostWorkflow.php new file mode 100644 index 0000000..ff48a68 --- /dev/null +++ b/server/app/Service/Copilot/PostWorkflow.php @@ -0,0 +1,67 @@ +n8n_api_key || !$user->n8n_base_url){ + return; + } + + try { + $url = rtrim($user->n8n_base_url, '/') . '/rest/workflows'; + $api_key = $user->n8n_api_key; + + $workflow_json = json_encode($workflow); + + self::postUsingCurl($url, $api_key, $workflow_json , $trace); + + } catch (\Throwable $e) { + throw new Exception("Failed to post workflow: " . $e->getMessage()); + } + + } + + private static function postUsingCurl(string $url, string $api_key,string $workflow_json , ?callable $trace){ + + $ch = curl_init($url); + self::setCurlOptions($ch, $api_key, $workflow_json); + + $response = curl_exec($ch); + $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + self::handleCurlResponse($ch, $httpcode, $response , $trace); + + curl_close($ch); + } + + private static function setCurlOptions($ch , string $api_key, string $workflow_json){ + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST"); + curl_setopt($ch, CURLOPT_POSTFIELDS, $workflow_json); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, array( + 'Content-Type: application/json', + 'X-N8N-API-KEY: ' . $api_key, + 'Content-Length: ' . strlen($workflow_json) + )); + } + + private static function handleCurlResponse($ch,int $httpcode, $response , ?callable $trace){ + if($httpcode == 200 || $httpcode == 201){ + $trace && $trace("postWorkflowSuccess" ,["message" => 'Workflow posted successfully to n8n instance.']); + } + + if(curl_errno($ch)){ + $trace && $trace("postWorkflowFailed" ,["message" => 'Failed to post workflow, please check your credentials and n8n instance status.']); + }else{ + throw new Exception('Failed to post workflow, HTTP Status Code: ' . $httpcode . ', Response: ' . $response); + } + } +} diff --git a/server/app/Service/Copilot/SaveWorkflow.php b/server/app/Service/Copilot/SaveWorkflow.php index 8bc1f32..5749d96 100644 --- a/server/app/Service/Copilot/SaveWorkflow.php +++ b/server/app/Service/Copilot/SaveWorkflow.php @@ -11,7 +11,8 @@ class SaveWorkflow{ public static function save($requestForm){ - $json = json_encode($requestForm->input('workflow')); + $json = $requestForm->input('workflow'); + if(!$json){ throw new Exception("Workflow given not correct json"); } @@ -51,10 +52,10 @@ public static function save($requestForm){ } } - private static function buildPayload(string $json , string $question): array { - $metaData = LLMService::generateWorkflowQdrantPayload($json , $question); // description, tags, notes, category + private static function buildPayload(array $json , string $question): array { + $metaData = LLMService::generateWorkflowQdrantPayload(json_encode($json , JSON_UNESCAPED_SLASHES) , $question); // description, tags, notes, category - $decoded_workflow = json_decode($json , true); + $decoded_workflow = $json; if (!is_array($decoded_workflow)) { throw new \RuntimeException("Invalid workflow JSON passed to buildPayload"); } diff --git a/server/app/Service/UserCopilotHistoryService.php b/server/app/Service/UserCopilotHistoryService.php new file mode 100644 index 0000000..33d37b3 --- /dev/null +++ b/server/app/Service/UserCopilotHistoryService.php @@ -0,0 +1,59 @@ + function ($query) { + $query->orderBy('created_at'); + }]) + ->where('user_id', $userId) + ->orderByDesc('created_at') + ->get(); + } + + public static function getUserCopilotHistoryDetials(int $userId , Model $userCopilotHistory){ + if($userCopilotHistory->user_id !== $userId){ + throw new Exception('History not found'); + } + + $userCopilotHistory->load(['messages' => function ($query) { + $query->orderBy('created_at'); + }]); + + return $userCopilotHistory; + } + + public static function deleteHistory(int $userId, Model $userCopilotHistory){ + if ($userCopilotHistory->user_id !== $userId) { + throw new Exception('History not found'); + } + + Message::where('history_id', $userCopilotHistory->id)->delete(); + + $userCopilotHistory->delete(); + } + + public static function getDownloadableContent(int $userId , Model $history){ + if($history->user_id !== $userId){ + abort(403); + } + + $lastMessage = $history->messages() + ->latest('created_at') + ->first(); + + if (!$lastMessage || !$lastMessage->ai_response) { + abort(404, 'No AI response found'); + } + + return $lastMessage; + } + +} diff --git a/server/app/Service/UserService.php b/server/app/Service/UserService.php index 541392d..8cc08df 100644 --- a/server/app/Service/UserService.php +++ b/server/app/Service/UserService.php @@ -7,18 +7,29 @@ use App\Models\User; use App\Models\UserCopilotHistory; use App\Service\Copilot\GetAnswer; +use App\Service\Copilot\PostWorkflow; use App\Service\Copilot\SaveWorkflow; use Exception; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; class UserService{ - public static function getCopilotAnswer(array $messages, ?int $userId , ?int $historyId = null , ?callable $stream = null): array{ + public static function getCopilotAnswer(array $messages, int $userId , ?int $historyId = null , ?callable $stream = null): array{ + + $user = User::find($userId); + if (!$user) { + throw new Exception("User not found"); + } $answer = GetAnswer::execute($messages , $stream); if(!$answer) throw new Exception("Failed to generate n8n workflow"); - $history = self::handleHistoryManagement($userId , $historyId , $messages , $answer); + + $history = self::handleHistoryManagement($user->id , $historyId , $messages , $answer); + if(!$history) throw new Exception("Failed to save copilot history"); + + PostWorkflow::postWorkflow($answer , $user , $stream); return [ 'answer' => $answer, @@ -98,16 +109,7 @@ public static function saveCopilotHistories( $newMessage->save(); } - - public static function getChatHistory(int $userId){ - return UserCopilotHistory::with(['messages' => function ($query) { - $query->orderBy('created_at'); - }]) - ->where('user_id', $userId) - ->orderByDesc('created_at') - ->get(); - } - + public static function getFriends(string $name , int $userId){ if(empty($name)){ throw new Exception("Name is empty"); @@ -186,7 +188,10 @@ public static function returnSseHeaders(){ public static function returnFinalWorkflowResult($result){ echo "event: result\n"; echo "data: " . json_encode($result) . "\n\n"; - ob_flush(); flush(); + if(ob_get_level() > 0){ + ob_flush(); + } + flush(); } public static function initializeStream(){ diff --git a/server/config/database.php b/server/config/database.php index c57fa63..6c5b4c0 100644 --- a/server/config/database.php +++ b/server/config/database.php @@ -38,8 +38,8 @@ 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), 'busy_timeout' => null, - 'journal_mode' => null, - 'synchronous' => null, + 'journal_mode' => env('APP_ENV') === 'testing' ? 'WAL' : null, + 'synchronous' => env('APP_ENV') === 'testing' ? 0 : null, 'transaction_mode' => 'DEFERRED', ], diff --git a/server/database/factories/MessageFactory.php b/server/database/factories/MessageFactory.php new file mode 100644 index 0000000..b420f8b --- /dev/null +++ b/server/database/factories/MessageFactory.php @@ -0,0 +1,26 @@ + UserCopilotHistory::factory(), + 'user_message' => fake()->sentence(), + 'ai_response' => [ + 'blocks' => [ + [ + 'type' => 'paragraph', + 'data' => ['text' => fake()->paragraph()], + ], + ], + ], + 'ai_model' => fake()->randomElement(['gpt-4', 'gpt-4-mini', 'gpt-3.5-turbo']), + ]; + } +} diff --git a/server/database/factories/PostCommentFactory.php b/server/database/factories/PostCommentFactory.php index c9f65c3..ac06b79 100644 --- a/server/database/factories/PostCommentFactory.php +++ b/server/database/factories/PostCommentFactory.php @@ -10,7 +10,10 @@ class PostCommentFactory extends Factory{// can create comments even if users an public function definition(): array{ return [ + 'user_id' => User::factory(), + 'post_id' => UserPost::factory(), 'content' => fake()->sentence(), + 'likes' => fake()->numberBetween(0, 100), ]; } } diff --git a/server/database/factories/UserCopilotHistoryFactory.php b/server/database/factories/UserCopilotHistoryFactory.php index 35b3940..ccd526e 100644 --- a/server/database/factories/UserCopilotHistoryFactory.php +++ b/server/database/factories/UserCopilotHistoryFactory.php @@ -9,10 +9,7 @@ class UserCopilotHistoryFactory extends Factory{ public function definition(): array { return [ - 'question' => fake()->sentence(), - 'ai_model' => 'gpt-4-mini', - 'ai_description' => fake()->paragraph(), - 'response' => fake()->paragraph()// for now + 'user_id' => User::factory(), ]; } } diff --git a/server/database/factories/UserPostFactory.php b/server/database/factories/UserPostFactory.php index 4a6f24d..1a37436 100644 --- a/server/database/factories/UserPostFactory.php +++ b/server/database/factories/UserPostFactory.php @@ -9,6 +9,7 @@ class UserPostFactory extends Factory{// only create posts if you have some user public function definition(): array{ return [ + 'user_id' => User::factory(), 'title' => fake()->title(), 'description' => fake()->sentence(), 'photo_url' => fake()->imageUrl(), diff --git a/server/database/migrations/2026_01_25_125042_change_json_content_to_json.php b/server/database/migrations/2026_01_25_125042_change_json_content_to_json.php new file mode 100644 index 0000000..ccc2716 --- /dev/null +++ b/server/database/migrations/2026_01_25_125042_change_json_content_to_json.php @@ -0,0 +1,28 @@ +json('json_content')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('user_posts', function (Blueprint $table) { + $table->text('json_content')->change(); + }); + } +}; diff --git a/server/database/migrations/2026_01_25_125649_change_description_content_to_text.php b/server/database/migrations/2026_01_25_125649_change_description_content_to_text.php new file mode 100644 index 0000000..218b6a2 --- /dev/null +++ b/server/database/migrations/2026_01_25_125649_change_description_content_to_text.php @@ -0,0 +1,28 @@ +text('description')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('user_posts', function (Blueprint $table) { + $table->string('description')->nullable()->change(); + }); + } +}; diff --git a/server/database/migrations/2026_01_25_221543_change_n8n_api_key_to_text_in_users_table.php b/server/database/migrations/2026_01_25_221543_change_n8n_api_key_to_text_in_users_table.php new file mode 100644 index 0000000..fc9eebc --- /dev/null +++ b/server/database/migrations/2026_01_25_221543_change_n8n_api_key_to_text_in_users_table.php @@ -0,0 +1,28 @@ +text('n8n_api_key')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->string('n8n_api_key')->nullable()->change(); + }); + } +}; diff --git a/server/phpunit.xml b/server/phpunit.xml index d703241..fcbe864 100644 --- a/server/phpunit.xml +++ b/server/phpunit.xml @@ -25,11 +25,16 @@ + + + + + diff --git a/server/tests/Feature/ExampleTest.php b/server/tests/Feature/ExampleTest.php deleted file mode 100644 index 8364a84..0000000 --- a/server/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,19 +0,0 @@ -get('/'); - - $response->assertStatus(200); - } -} diff --git a/server/tests/Feature/n8nGenerator.php b/server/tests/Feature/n8nGenerator.php new file mode 100644 index 0000000..e69de29 diff --git a/server/tests/Unit/AnalyzeIntentTest.php b/server/tests/Unit/AnalyzeIntentTest.php new file mode 100644 index 0000000..1d3cc0e --- /dev/null +++ b/server/tests/Unit/AnalyzeIntentTest.php @@ -0,0 +1,81 @@ +assertEquals("gmailnodev25", $result); + } + + /** + * Test that normalizeNodes removes duplicates and normalizes multiple nodes + */ + public function test_normalize_nodes_removes_duplicates(): void + { + $input = [ + "Gmail Node", + "GMAIL NODE", + "Gmail-Node", + "Slack API", + "slack-api" + ]; + + $result = AnalyzeIntent::normalizeNodes($input); + + // Should only have 2 unique entries: gmailnode and slackapi + $this->assertCount(2, $result); + $this->assertContains("gmailnode", $result); + $this->assertContains("slackapi", $result); + } + + /** + * Test that buildWorkflowEmbeddingQuery constructs proper query string + */ + public function test_build_workflow_embedding_query_with_all_fields(): void + { + $analysis = [ + "intent" => "Send emails to contacts", + "trigger" => "new-record", + "nodes" => ["gmail", "database"] + ]; + + $question = "How do I send emails when a new record is added?"; + $result = AnalyzeIntent::buildWorkflowEmbeddingQuery($analysis, $question); + + $this->assertStringContainsString("Send emails to contacts", $result); + $this->assertStringContainsString("Triggered by new-record", $result); + $this->assertStringContainsString("gmail", $result); + $this->assertStringContainsString("database", $result); + $this->assertStringContainsString($question, $result); + } + + /** + * Test that buildWorkflowEmbeddingQuery handles missing optional fields + */ + public function test_build_workflow_embedding_query_with_minimal_fields(): void + { + $analysis = [ + "intent" => "Process data", + "trigger" => "", + "nodes" => [] + ]; + + $question = "Process incoming data"; + $result = AnalyzeIntent::buildWorkflowEmbeddingQuery($analysis, $question); + + $this->assertStringContainsString("Process data", $result); + $this->assertStringContainsString($question, $result); + $this->assertStringNotContainsString("Triggered by", $result); + } +} diff --git a/server/tests/Unit/AuthServiceTest.php b/server/tests/Unit/AuthServiceTest.php new file mode 100644 index 0000000..b8a340f --- /dev/null +++ b/server/tests/Unit/AuthServiceTest.php @@ -0,0 +1,354 @@ + 'John', + 'lastName' => 'Doe', + 'email' => 'john@example.com', + 'password' => 'password123', + ]; + + $result = AuthService::createUser($userData); + + $this->assertArrayHasKey('token', $result); + $this->assertArrayHasKey('user', $result); + $this->assertEquals('john@example.com', $result['user']['email']); + $this->assertEquals('John', $result['user']['first_name']); + $this->assertEquals('Doe', $result['user']['last_name']); + + $this->assertDatabaseHas('users', [ + 'email' => 'john@example.com', + 'first_name' => 'John', + 'last_name' => 'Doe', + ]); + } + + /** + * Test user creation from Google login + */ + public function test_create_user_from_google(): void + { + $userData = [ + 'firstName' => 'Jane', + 'lastName' => 'Smith', + 'email' => 'jane@example.com', + 'password' => 'password123', + ]; + + $result = AuthService::createUser($userData, 1); + + $this->assertArrayHasKey('token', $result); + $this->assertArrayHasKey('user', $result); + $this->assertEquals('jane@example.com', $result['user']['email']); + + $user = User::where('email', 'jane@example.com')->first(); + $this->assertNull($user->password); + } + + /** + * Test successful login with valid credentials + */ + public function test_login_with_valid_credentials(): void + { + $password = 'testpassword123'; + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'password' => Hash::make($password), + ]); + + $credentials = [ + 'email' => 'test@example.com', + 'password' => $password, + ]; + + $result = AuthService::login($credentials); + + $this->assertArrayHasKey('token', $result); + $this->assertArrayHasKey('user', $result); + $this->assertEquals($user->id, $result['user']['id']); + $this->assertEquals('test@example.com', $result['user']['email']); + } + + /** + * Test login with non-existent user + */ + public function test_login_with_non_existent_user(): void + { + $credentials = [ + 'email' => 'nonexistent@example.com', + 'password' => 'password123', + ]; + + $this->expectException(UserFacingException::class); + $this->expectExceptionMessage('Invalid credentials'); + + AuthService::login($credentials); + } + + /** + * Test login with incorrect password + */ + public function test_login_with_incorrect_password(): void + { + User::factory()->create([ + 'email' => 'test@example.com', + 'password' => Hash::make('correctpassword'), + ]); + + $credentials = [ + 'email' => 'test@example.com', + 'password' => 'wrongpassword', + ]; + + $this->expectException(UserFacingException::class); + $this->expectExceptionMessage('Invalid credentials'); + + AuthService::login($credentials); + } + + /** + * Test login with Google account that has no password + */ + public function test_login_with_google_account_no_password(): void + { + User::factory()->create([ + 'email' => 'google@example.com', + 'password' => null, + ]); + + $credentials = [ + 'email' => 'google@example.com', + 'password' => 'anypassword', + ]; + + $this->expectException(UserFacingException::class); + $this->expectExceptionMessage('This account uses Google login. Please continue with Google'); + + AuthService::login($credentials); + } + + /** + * Test set password for new user (no existing password) + */ + public function test_set_password_for_new_user(): void + { + $user = User::factory()->create([ + 'password' => null, + ]); + + $data = [ + 'new_password' => 'newpassword123', + ]; + + AuthService::setPassword($user, $data); + + $this->assertTrue(Hash::check('newpassword123', $user->password)); + } + + /** + * Test set password for existing user with correct current password + */ + public function test_set_password_with_correct_current_password(): void + { + $currentPassword = 'currentpassword123'; + $user = User::factory()->create([ + 'password' => Hash::make($currentPassword), + ]); + + $data = [ + 'current_password' => $currentPassword, + 'new_password' => 'newpassword456', + ]; + + AuthService::setPassword($user, $data); + $user->refresh(); + + $this->assertTrue(Hash::check('newpassword456', $user->password)); + } + + /** + * Test set password with incorrect current password + */ + public function test_set_password_with_incorrect_current_password(): void + { + $user = User::factory()->create([ + 'password' => Hash::make('correctpassword'), + ]); + + $data = [ + 'current_password' => 'wrongpassword', + 'new_password' => 'newpassword', + ]; + + $this->expectException(UserFacingException::class); + $this->expectExceptionMessage('Current password is incorrect'); + + AuthService::setPassword($user, $data); + } + + /** + * Test set password with missing current password when user has password + */ + public function test_set_password_missing_current_password(): void + { + $user = User::factory()->create([ + 'password' => Hash::make('existingpassword'), + ]); + + $data = [ + 'current_password' => null, + 'new_password' => 'newpassword', + ]; + + $this->expectException(UserFacingException::class); + $this->expectExceptionMessage('Current password is incorrect'); + + AuthService::setPassword($user, $data); + } + + /** + * Test successful unlink Google account + */ + public function test_unlink_google_account_with_password(): void + { + $user = User::factory()->create([ + 'google_id' => 'google789', + 'password' => Hash::make('password123'), + ]); + + AuthService::unlinkGoogle($user); + $user->refresh(); + + $this->assertNull($user->google_id); + } + + /** + * Test unlink Google account without password + */ + public function test_unlink_google_account_without_password(): void + { + $user = User::factory()->create([ + 'google_id' => 'google789', + 'password' => null, + ]); + + $this->expectException(UserFacingException::class); + $this->expectExceptionMessage('Set a password before unlinking Google'); + + AuthService::unlinkGoogle($user); + } + + /** + * Test successful N8N account linking + */ + public function test_link_n8n_account_successfully(): void + { + $user = User::factory()->create(); + + Http::fake([ + 'http://localhost:5678/api/v1/workflows' => Http::response([ + 'data' => [ + ['id' => 1, 'name' => 'Workflow 1'], + ], + ]), + ]); + + $data = [ + 'api_key' => 'valid_api_key', + 'base_url' => 'http://localhost:5678', + ]; + + AuthService::linkN8nAccount($user, $data); + $user->refresh(); + + $this->assertEquals('valid_api_key', $user->n8n_api_key); + $this->assertEquals('http://localhost:5678', $user->n8n_base_url); + } + + /** + * Test JWT token is valid and contains correct payload + */ + public function test_generated_token_is_valid(): void + { + $user = User::factory()->create(['id' => 1]); + + $credentials = [ + 'email' => $user->email, + 'password' => 'password123', + ]; + + $user->update(['password' => Hash::make('password123')]); + $result = AuthService::login($credentials); + + $token = $result['token']; + $decoded = JWT::decode($token, new Key(env('JWT_SECRET'), 'HS256')); + + $this->assertEquals(1, $decoded->sub); + $this->assertEquals(config('app.url'), $decoded->iss); + } + + /** + * Test authentication return format structure + */ + public function test_authentication_return_format(): void + { + $user = User::factory()->create([ + 'first_name' => 'Test', + 'last_name' => 'User', + 'email' => 'test@example.com', + ]); + + $password = 'password123'; + $user->update(['password' => Hash::make($password)]); + + $credentials = [ + 'email' => 'test@example.com', + 'password' => $password, + ]; + + $result = AuthService::login($credentials); + + $this->assertArrayHasKey('token', $result); + $this->assertArrayHasKey('user', $result); + $this->assertArrayHasKey('id', $result['user']); + $this->assertArrayHasKey('first_name', $result['user']); + $this->assertArrayHasKey('last_name', $result['user']); + $this->assertArrayHasKey('email', $result['user']); + + $this->assertEquals($user->id, $result['user']['id']); + $this->assertEquals('Test', $result['user']['first_name']); + $this->assertEquals('User', $result['user']['last_name']); + $this->assertEquals('test@example.com', $result['user']['email']); + } + + /** + * Helper method to mock Google verification + */ + private function mockGoogleVerification(array $payload): void + { + $mock = $this->mock(Google_Client::class); + $mock->shouldReceive('verifyIdToken') + ->andReturn($payload); + } +} diff --git a/server/tests/Unit/ExampleTest.php b/server/tests/Unit/ExampleTest.php deleted file mode 100644 index 5773b0c..0000000 --- a/server/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,16 +0,0 @@ -assertTrue(true); - } -} diff --git a/server/tests/Unit/GetPointsTest.php b/server/tests/Unit/GetPointsTest.php new file mode 100644 index 0000000..49d2fcd --- /dev/null +++ b/server/tests/Unit/GetPointsTest.php @@ -0,0 +1,129 @@ +getMethod('parseNodeVersion'); + $method->setAccessible(true); + + $result = $method->invoke(null, "GmailV2"); + + $this->assertEquals("Gmail", $result['base']); + $this->assertEquals(2, $result['version']); + } + + /** + * Test parseNodeVersion handles unversioned class names + */ + public function test_parse_node_version_without_version(): void + { + $reflection = new \ReflectionClass(GetPoints::class); + $method = $reflection->getMethod('parseNodeVersion'); + $method->setAccessible(true); + + $result = $method->invoke(null, "SlackLegacy"); + + $this->assertEquals("SlackLegacy", $result['base']); + $this->assertEquals(0, $result['version']); + } + + /** + * Test keepLatestVersions keeps only the highest version of each node + */ + public function test_keep_latest_versions_filters_old_versions(): void + { + $reflection = new \ReflectionClass(GetPoints::class); + $method = $reflection->getMethod('keepLatestVersions'); + $method->setAccessible(true); + + $hits = [ + [ + 'payload' => [ + 'class_name' => 'GmailV1', + 'node_id' => 'gmail-v1' + ] + ], + [ + 'payload' => [ + 'class_name' => 'GmailV3', + 'node_id' => 'gmail-v3' + ] + ], + [ + 'payload' => [ + 'class_name' => 'GmailV2', + 'node_id' => 'gmail-v2' + ] + ], + ]; + + $result = $method->invoke(null, $hits); + + // Should only keep GmailV3 + $this->assertCount(1, $result); + $this->assertEquals("GmailV3", $result[0]['payload']['class_name']); + } + + /** + * Test filterByAdaptiveScore returns top scorer when best >= 0.6 + */ + public function test_filter_by_adaptive_score_high_confidence(): void + { + // Mock HTTP facade to prevent actual calls + Http::fake(); + + $reflection = new \ReflectionClass(GetPoints::class); + $method = $reflection->getMethod('filterByAdaptiveScore'); + $method->setAccessible(true); + + $hits = [ + ['score' => 0.85], + ['score' => 0.65], + ['score' => 0.55], + ['score' => 0.45], + ]; + + $result = $method->invoke(null, $hits, 8); + + // With ratio 0.35 for 0.85, threshold = 0.85 * 0.35 = 0.2975 + // Should keep all >= 0.2975 + $this->assertGreaterThan(1, count($result)); + $this->assertEquals(0.85, $result[0]['score']); + } + + /** + * Test filterByAdaptiveScore returns only top 1 when best < 0.25 + */ + public function test_filter_by_adaptive_score_low_confidence(): void + { + // Mock HTTP facade to prevent actual calls + Http::fake(); + + $reflection = new \ReflectionClass(GetPoints::class); + $method = $reflection->getMethod('filterByAdaptiveScore'); + $method->setAccessible(true); + + $hits = [ + ['score' => 0.20], + ['score' => 0.15], + ['score' => 0.10], + ]; + + $result = $method->invoke(null, $hits, 8); + + // Should only keep top 1 when best < 0.25 + $this->assertCount(1, $result); + $this->assertEquals(0.20, $result[0]['score']); + } +} diff --git a/server/tests/Unit/N8nGeneratorTest.php b/server/tests/Unit/N8nGeneratorTest.php new file mode 100644 index 0000000..2fc5316 --- /dev/null +++ b/server/tests/Unit/N8nGeneratorTest.php @@ -0,0 +1,130 @@ +assertEquals("", $result); + } + + /** + * Test buildWorkflowContext with single workflow + */ + public function test_buildWorkflowContext_with_single_workflow(): void + { + $flows = [ + [ + 'workflow' => 'Send Email Workflow', + 'nodes_used' => ['Gmail', 'HTTP'], + 'node_count' => 2, + 'raw' => ['id' => 1, 'name' => 'test'] + ] + ]; + + $result = WorkflowGeneration::buildWorkflowContext($flows); + + $this->assertStringContainsString('Workflow 1', $result); + $this->assertStringContainsString('Send Email Workflow', $result); + $this->assertStringContainsString('Gmail', $result); + $this->assertStringContainsString('HTTP', $result); + $this->assertStringContainsString('Node Count: 2', $result); + } + + /** + * Test buildWorkflowContext with multiple workflows + */ + public function test_buildWorkflowContext_with_multiple_workflows(): void + { + $flows = [ + [ + 'workflow' => 'First Workflow', + 'nodes_used' => ['Node1'], + 'node_count' => 1, + 'raw' => ['id' => 1] + ], + [ + 'workflow' => 'Second Workflow', + 'nodes_used' => ['Node2', 'Node3'], + 'node_count' => 2, + 'raw' => ['id' => 2] + ] + ]; + + $result = WorkflowGeneration::buildWorkflowContext($flows); + + $this->assertStringContainsString('Workflow 1', $result); + $this->assertStringContainsString('Workflow 2', $result); + $this->assertStringContainsString('First Workflow', $result); + $this->assertStringContainsString('Second Workflow', $result); + $this->assertStringContainsString('Node1', $result); + $this->assertStringContainsString('Node2', $result); + } + + /** + * Test buildSchemasContext with empty schemas array + */ + public function test_buildSchemasContext_with_empty_schemas(): void + { + $schemas = []; + + $result = WorkflowGeneration::buildSchemasContext($schemas); + + $this->assertStringContainsString('You may ONLY use the following n8n node operations', $result); + $this->assertStringContainsString('Every operation below is valid, ranked, and schema-verified', $result); + $this->assertStringContainsString('Do NOT invent nodes, resources, operations, or fields', $result); + } + + /** + * Test buildSchemasContext with schema operations + */ + public function test_buildSchemasContext_with_schema_operations(): void + { + $schemas = [ + [ + 'schema' => [ + 'node' => 'Gmail', + 'resource' => 'Email', + 'operation' => 'Send', + 'display' => 'Send Email', + 'description' => 'Sends an email via Gmail', + 'fields' => [ + ['name' => 'to', 'type' => 'string', 'required' => true], + ['name' => 'subject', 'type' => 'string', 'required' => true] + ], + 'inputs' => [ + ['name' => 'Email Data', 'type' => 'object'] + ], + 'outputs' => [ + ['name' => 'Message ID', 'type' => 'string'] + ] + ] + ] + ]; + + $result = WorkflowGeneration::buildSchemasContext($schemas); + + $this->assertStringContainsString('NODE: Gmail', $result); + $this->assertStringContainsString('OPERATION: Email → Send', $result); + $this->assertStringContainsString('LABEL: Send Email', $result); + $this->assertStringContainsString('DESCRIPTION: Sends an email via Gmail', $result); + $this->assertStringContainsString('FIELDS:', $result); + $this->assertStringContainsString('to (string, required)', $result); + $this->assertStringContainsString('subject (string, required)', $result); + $this->assertStringContainsString('INPUTS:', $result); + $this->assertStringContainsString('Email Data', $result); + $this->assertStringContainsString('OUTPUTS:', $result); + $this->assertStringContainsString('Message ID', $result); + } +} \ No newline at end of file diff --git a/server/tests/Unit/PostCommentServiceTest.php b/server/tests/Unit/PostCommentServiceTest.php new file mode 100644 index 0000000..1d40795 --- /dev/null +++ b/server/tests/Unit/PostCommentServiceTest.php @@ -0,0 +1,475 @@ +create(); + $post = UserPost::factory()->create(); + + $comment = PostCommentService::postComment( + $user->id, + 'This is a test comment', + $post->id + ); + + $this->assertInstanceOf(PostComment::class, $comment); + $this->assertEquals('This is a test comment', $comment->content); + $this->assertEquals($user->id, $comment->user_id); + $this->assertEquals($post->id, $comment->post_id); + $this->assertEquals(0, $comment->likes); + + $this->assertDatabaseHas('post_comments', [ + 'user_id' => $user->id, + 'post_id' => $post->id, + 'content' => 'This is a test comment', + 'likes' => 0, + ]); + } + + /** + * Test posting a comment with empty content + */ + public function test_post_comment_with_empty_content(): void + { + $user = User::factory()->create(); + $post = UserPost::factory()->create(); + + $this->expectException(UserFacingException::class); + $this->expectExceptionMessage('Comment is empty'); + + PostCommentService::postComment($user->id, '', $post->id); + } + + /** + * Test posting a comment with null content + */ + public function test_post_comment_with_null_content(): void + { + $user = User::factory()->create(); + $post = UserPost::factory()->create(); + + $this->expectException(UserFacingException::class); + $this->expectExceptionMessage('Comment is empty'); + + PostCommentService::postComment($user->id, "", $post->id); + } + + /** + * Test posting a comment with long content + */ + public function test_post_comment_with_long_content(): void + { + $user = User::factory()->create(); + $post = UserPost::factory()->create(); + + $longContent = str_repeat('This is a long comment. ', 50); + + $comment = PostCommentService::postComment($user->id, $longContent, $post->id); + + $this->assertEquals($longContent, $comment->content); + } + + /** + * Test getting comments for a post + */ + public function test_get_comments_for_post(): void + { + $post = UserPost::factory()->create(); + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + PostComment::factory()->create([ + 'post_id' => $post->id, + 'user_id' => $user1->id, + 'content' => 'First comment', + 'likes' => 5, + ]); + + PostComment::factory()->create([ + 'post_id' => $post->id, + 'user_id' => $user2->id, + 'content' => 'Second comment', + 'likes' => 3, + ]); + + $comments = PostCommentService::getComments($post->id); + + $this->assertCount(2, $comments); + $this->assertEquals('First comment', $comments[0]->content); + $this->assertEquals('Second comment', $comments[1]->content); + } + + /** + * Test getting comments ordered by likes descending + */ + public function test_get_comments_ordered_by_likes_descending(): void + { + $post = UserPost::factory()->create(); + $user = User::factory()->create(); + + PostComment::factory()->create([ + 'post_id' => $post->id, + 'user_id' => $user->id, + 'content' => 'Less liked', + 'likes' => 2, + ]); + + PostComment::factory()->create([ + 'post_id' => $post->id, + 'user_id' => $user->id, + 'content' => 'More liked', + 'likes' => 10, + ]); + + PostComment::factory()->create([ + 'post_id' => $post->id, + 'user_id' => $user->id, + 'content' => 'Middle liked', + 'likes' => 5, + ]); + + $comments = PostCommentService::getComments($post->id); + + $this->assertEquals(10, $comments[0]->likes); + $this->assertEquals(5, $comments[1]->likes); + $this->assertEquals(2, $comments[2]->likes); + } + + /** + * Test getting comments with user relationship loaded + */ + public function test_get_comments_with_user_relationship(): void + { + $post = UserPost::factory()->create(); + $user = User::factory()->create([ + 'first_name' => 'John', + 'last_name' => 'Doe', + ]); + + PostComment::factory()->create([ + 'post_id' => $post->id, + 'user_id' => $user->id, + 'content' => 'Test comment', + ]); + + $comments = PostCommentService::getComments($post->id); + + $this->assertCount(1, $comments); + $this->assertNotNull($comments[0]->user); + $this->assertEquals('John', $comments[0]->user->first_name); + $this->assertEquals('Doe', $comments[0]->user->last_name); + } + + /** + * Test getting comments for post with no comments + */ + public function test_get_comments_for_post_with_no_comments(): void + { + $post = UserPost::factory()->create(); + + $comments = PostCommentService::getComments($post->id); + + $this->assertCount(0, $comments); + } + + /** + * Test getting comments only returns comments for specified post + */ + public function test_get_comments_returns_only_post_comments(): void + { + $post1 = UserPost::factory()->create(); + $post2 = UserPost::factory()->create(); + $user = User::factory()->create(); + + PostComment::factory()->create([ + 'post_id' => $post1->id, + 'user_id' => $user->id, + 'content' => 'Comment on post 1', + ]); + + PostComment::factory()->create([ + 'post_id' => $post2->id, + 'user_id' => $user->id, + 'content' => 'Comment on post 2', + ]); + + $comments = PostCommentService::getComments($post1->id); + + $this->assertCount(1, $comments); + $this->assertEquals('Comment on post 1', $comments[0]->content); + } + + /** + * Test adding a like to a comment + */ + public function test_toggle_comment_like_adds_new_like(): void + { + $user = User::factory()->create(); + $comment = PostComment::factory()->create(['likes' => 5]); + + $result = PostCommentService::toggleCommentLike($user->id, $comment->id); + + $this->assertTrue($result['liked']); + $this->assertEquals(6, $result['likes']); + + $this->assertDatabaseHas('comment_likes', [ + 'comment_id' => $comment->id, + 'user_id' => $user->id, + ]); + } + + /** + * Test removing a like from a comment + */ + public function test_toggle_comment_like_removes_existing_like(): void + { + $user = User::factory()->create(); + $comment = PostComment::factory()->create(['likes' => 5]); + + CommentsLike::create([ + 'comment_id' => $comment->id, + 'user_id' => $user->id, + ]); + + $result = PostCommentService::toggleCommentLike($user->id, $comment->id); + + $this->assertFalse($result['liked']); + $this->assertEquals(4, $result['likes']); + + $this->assertDatabaseMissing('comment_likes', [ + 'comment_id' => $comment->id, + 'user_id' => $user->id, + ]); + } + + /** + * Test toggle like increments likes counter + */ + public function test_toggle_like_increments_likes_counter(): void + { + $user = User::factory()->create(); + $comment = PostComment::factory()->create(['likes' => 0]); + + PostCommentService::toggleCommentLike($user->id, $comment->id); + + $comment->refresh(); + $this->assertEquals(1, $comment->likes); + } + + /** + * Test toggle like decrements likes counter + */ + public function test_toggle_like_decrements_likes_counter(): void + { + $user = User::factory()->create(); + $comment = PostComment::factory()->create(['likes' => 3]); + + CommentsLike::create([ + 'comment_id' => $comment->id, + 'user_id' => $user->id, + ]); + + PostCommentService::toggleCommentLike($user->id, $comment->id); + + $comment->refresh(); + $this->assertEquals(2, $comment->likes); + } + + /** + * Test multiple users can like the same comment + */ + public function test_multiple_users_can_like_same_comment(): void + { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + $user3 = User::factory()->create(); + $comment = PostComment::factory()->create(['likes' => 0]); + + PostCommentService::toggleCommentLike($user1->id, $comment->id); + PostCommentService::toggleCommentLike($user2->id, $comment->id); + PostCommentService::toggleCommentLike($user3->id, $comment->id); + + $comment->refresh(); + $this->assertEquals(3, $comment->likes); + + $this->assertDatabaseHas('comment_likes', [ + 'comment_id' => $comment->id, + 'user_id' => $user1->id, + ]); + $this->assertDatabaseHas('comment_likes', [ + 'comment_id' => $comment->id, + 'user_id' => $user2->id, + ]); + $this->assertDatabaseHas('comment_likes', [ + 'comment_id' => $comment->id, + 'user_id' => $user3->id, + ]); + } + + /** + * Test user can like and unlike the same comment + */ + public function test_user_can_like_and_unlike_same_comment(): void + { + $user = User::factory()->create(); + $comment = PostComment::factory()->create(['likes' => 0]); + + // Like the comment + $result1 = PostCommentService::toggleCommentLike($user->id, $comment->id); + $this->assertTrue($result1['liked']); + $this->assertEquals(1, $result1['likes']); + + // Unlike the comment + $result2 = PostCommentService::toggleCommentLike($user->id, $comment->id); + $this->assertFalse($result2['liked']); + $this->assertEquals(0, $result2['likes']); + + // Like again + $result3 = PostCommentService::toggleCommentLike($user->id, $comment->id); + $this->assertTrue($result3['liked']); + $this->assertEquals(1, $result3['likes']); + } + + /** + * Test toggle like returns fresh comment likes count + */ + public function test_toggle_like_returns_fresh_likes_count(): void + { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + $comment = PostComment::factory()->create(['likes' => 2]); + + CommentsLike::create([ + 'comment_id' => $comment->id, + 'user_id' => $user1->id, + ]); + + CommentsLike::create([ + 'comment_id' => $comment->id, + 'user_id' => $user2->id, + ]); + + // Toggle like for user2 (remove) + $result = PostCommentService::toggleCommentLike($user2->id, $comment->id); + + $this->assertFalse($result['liked']); + $this->assertEquals(1, $result['likes']); + } + + /** + * Test toggle like is transactional + */ + public function test_toggle_like_uses_database_transaction(): void + { + $user = User::factory()->create(); + $comment = PostComment::factory()->create(['likes' => 0]); + + $result = PostCommentService::toggleCommentLike($user->id, $comment->id); + + // Verify both operations succeeded + $this->assertTrue($result['liked']); + $this->assertEquals(1, $result['likes']); + + $this->assertDatabaseHas('comment_likes', [ + 'comment_id' => $comment->id, + 'user_id' => $user->id, + ]); + + $comment->refresh(); + $this->assertEquals(1, $comment->likes); + } + + /** + * Test posting multiple comments by same user + */ + public function test_post_multiple_comments_by_same_user(): void + { + $user = User::factory()->create(); + $post = UserPost::factory()->create(); + + $comment1 = PostCommentService::postComment($user->id, 'First comment', $post->id); + $comment2 = PostCommentService::postComment($user->id, 'Second comment', $post->id); + + $this->assertNotEquals($comment1->id, $comment2->id); + $this->assertEquals($user->id, $comment1->user_id); + $this->assertEquals($user->id, $comment2->user_id); + + $this->assertDatabaseHas('post_comments', [ + 'user_id' => $user->id, + 'post_id' => $post->id, + 'content' => 'First comment', + ]); + + $this->assertDatabaseHas('post_comments', [ + 'user_id' => $user->id, + 'post_id' => $post->id, + 'content' => 'Second comment', + ]); + } + + /** + * Test posting comments by different users on same post + */ + public function test_post_comments_by_different_users_on_same_post(): void + { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + $post = UserPost::factory()->create(); + + $comment1 = PostCommentService::postComment($user1->id, 'User 1 comment', $post->id); + $comment2 = PostCommentService::postComment($user2->id, 'User 2 comment', $post->id); + + $comments = PostCommentService::getComments($post->id); + + $this->assertCount(2, $comments); + $this->assertContains('User 1 comment', $comments->pluck('content')->toArray()); + $this->assertContains('User 2 comment', $comments->pluck('content')->toArray()); + } + + /** + * Test comment with special characters + */ + public function test_post_comment_with_special_characters(): void + { + $user = User::factory()->create(); + $post = UserPost::factory()->create(); + + $specialContent = "This is a comment with special chars: @#$%^&*()_+-=[]{}|;:',.<>?/~`"; + + $comment = PostCommentService::postComment($user->id, $specialContent, $post->id); + + $this->assertEquals($specialContent, $comment->content); + } + + /** + * Test comment with unicode characters + */ + public function test_post_comment_with_unicode_characters(): void + { + $user = User::factory()->create(); + $post = UserPost::factory()->create(); + + $unicodeContent = "This comment has emojis 🚀 and unicode: café, naïve, résumé"; + + $comment = PostCommentService::postComment($user->id, $unicodeContent, $post->id); + + $this->assertEquals($unicodeContent, $comment->content); + } +} diff --git a/server/tests/Unit/UserCopilotHistoryServiceTest.php b/server/tests/Unit/UserCopilotHistoryServiceTest.php new file mode 100644 index 0000000..42a9a9f --- /dev/null +++ b/server/tests/Unit/UserCopilotHistoryServiceTest.php @@ -0,0 +1,303 @@ +create(); + $user2 = User::factory()->create(); + + UserCopilotHistory::factory()->create(['user_id' => $user1->id]); + UserCopilotHistory::factory()->create(['user_id' => $user1->id]); + UserCopilotHistory::factory()->create(['user_id' => $user2->id]); + + $user1Histories = UserCopilotHistoryService::getUserHistories($user1->id); + $user2Histories = UserCopilotHistoryService::getUserHistories($user2->id); + + $this->assertCount(2, $user1Histories); + $this->assertCount(1, $user2Histories); + } + + /** + * Test getting histories with messages loaded + */ + public function test_get_user_histories_loads_messages(): void + { + $user = User::factory()->create(); + $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]); + + Message::factory()->create(['history_id' => $history->id]); + Message::factory()->create(['history_id' => $history->id]); + + $histories = UserCopilotHistoryService::getUserHistories($user->id); + + $this->assertCount(1, $histories); + $this->assertTrue($histories[0]->relationLoaded('messages')); + $this->assertCount(2, $histories[0]->messages); + } + + /** + * Test messages are ordered by created_at ascending + */ + public function test_get_user_histories_messages_ordered_by_created_at(): void + { + $user = User::factory()->create(); + $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]); + + $message1 = Message::factory()->create(['history_id' => $history->id]); + $message2 = Message::factory()->create(['history_id' => $history->id]); + $message3 = Message::factory()->create(['history_id' => $history->id]); + + $histories = UserCopilotHistoryService::getUserHistories($user->id); + $messages = $histories[0]->messages; + + $this->assertEquals($message1->id, $messages[0]->id); + $this->assertEquals($message2->id, $messages[1]->id); + $this->assertEquals($message3->id, $messages[2]->id); + } + + /** + * Test getting empty histories for user with no histories + */ + public function test_get_user_histories_with_no_histories(): void + { + $user = User::factory()->create(); + + $histories = UserCopilotHistoryService::getUserHistories($user->id); + + $this->assertCount(0, $histories); + } + + /** + * Test getting copilot history details successfully + */ + public function test_get_user_copilot_history_details_successfully(): void + { + $user = User::factory()->create(); + $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]); + + Message::factory()->create(['history_id' => $history->id]); + Message::factory()->create(['history_id' => $history->id]); + + $result = UserCopilotHistoryService::getUserCopilotHistoryDetials($user->id, $history); + + $this->assertEquals($history->id, $result->id); + $this->assertTrue($result->relationLoaded('messages')); + $this->assertCount(2, $result->messages); + } + + /** + * Test getting history details for unauthorized user + */ + public function test_get_user_copilot_history_details_unauthorized(): void + { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + $history = UserCopilotHistory::factory()->create(['user_id' => $user1->id]); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('History not found'); + + UserCopilotHistoryService::getUserCopilotHistoryDetials($user2->id, $history); + } + + /** + * Test getting history details loads messages ordered + */ + public function test_get_user_copilot_history_details_loads_ordered_messages(): void + { + $user = User::factory()->create(); + $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]); + + $message1 = Message::factory()->create(['history_id' => $history->id]); + $message2 = Message::factory()->create(['history_id' => $history->id]); + $message3 = Message::factory()->create(['history_id' => $history->id]); + + $result = UserCopilotHistoryService::getUserCopilotHistoryDetials($user->id, $history); + + $this->assertEquals($message1->id, $result->messages[0]->id); + $this->assertEquals($message2->id, $result->messages[1]->id); + $this->assertEquals($message3->id, $result->messages[2]->id); + } + + /** + * Test getting history details with no messages + */ + public function test_get_user_copilot_history_details_with_no_messages(): void + { + $user = User::factory()->create(); + $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]); + + $result = UserCopilotHistoryService::getUserCopilotHistoryDetials($user->id, $history); + + $this->assertCount(0, $result->messages); + } + + /** + * Test deleting history successfully + */ + public function test_delete_history_successfully(): void + { + $user = User::factory()->create(); + $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]); + + Message::factory()->create(['history_id' => $history->id]); + Message::factory()->create(['history_id' => $history->id]); + + UserCopilotHistoryService::deleteHistory($user->id, $history); + + $this->assertDatabaseMissing('user_copilot_histories', ['id' => $history->id]); + $this->assertDatabaseMissing('messages', ['history_id' => $history->id]); + } + + /** + * Test deleting history removes all related messages + */ + public function test_delete_history_removes_all_messages(): void + { + $user = User::factory()->create(); + $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]); + + $message1 = Message::factory()->create(['history_id' => $history->id]); + $message2 = Message::factory()->create(['history_id' => $history->id]); + $message3 = Message::factory()->create(['history_id' => $history->id]); + + UserCopilotHistoryService::deleteHistory($user->id, $history); + + $this->assertDatabaseMissing('messages', ['id' => $message1->id]); + $this->assertDatabaseMissing('messages', ['id' => $message2->id]); + $this->assertDatabaseMissing('messages', ['id' => $message3->id]); + } + + /** + * Test deleting history for unauthorized user + */ + public function test_delete_history_unauthorized(): void + { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + $history = UserCopilotHistory::factory()->create(['user_id' => $user1->id]); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('History not found'); + + UserCopilotHistoryService::deleteHistory($user2->id, $history); + } + + /** + * Test deleting history does not delete other user's messages + */ + public function test_delete_history_does_not_delete_other_histories(): void + { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + $history1 = UserCopilotHistory::factory()->create(['user_id' => $user1->id]); + $history2 = UserCopilotHistory::factory()->create(['user_id' => $user2->id]); + + Message::factory()->create(['history_id' => $history1->id]); + Message::factory()->create(['history_id' => $history2->id]); + + UserCopilotHistoryService::deleteHistory($user1->id, $history1); + + $this->assertDatabaseMissing('user_copilot_histories', ['id' => $history1->id]); + $this->assertDatabaseHas('user_copilot_histories', ['id' => $history2->id]); + } + + /** + * Test getting downloadable content successfully + */ + public function test_get_downloadable_content_successfully(): void + { + $user = User::factory()->create(); + $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]); + + $aiResponse = ['response' => 'This is the AI response']; + $message = Message::factory()->create([ + 'history_id' => $history->id, + 'ai_response' => $aiResponse, + 'user_message' => 'Test message', + ]); + + $result = UserCopilotHistoryService::getDownloadableContent($user->id, $history); + + $this->assertEquals($message->id, $result->id); + $this->assertEquals($aiResponse, $result->ai_response); + } + + /** + * Test getting downloadable content without messages + */ + public function test_get_downloadable_content_without_messages(): void + { + $user = User::factory()->create(); + $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]); + + $this->expectException(\Exception::class); + + UserCopilotHistoryService::getDownloadableContent($user->id, $history); + } + + /** + * Test getting downloadable content with array ai_response + */ + public function test_get_downloadable_content_with_array_ai_response(): void + { + $user = User::factory()->create(); + $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]); + + $aiResponse = [ + 'blocks' => [ + ['type' => 'paragraph', 'data' => ['text' => 'Sample response']], + ], + ]; + + Message::factory()->create([ + 'history_id' => $history->id, + 'ai_response' => $aiResponse, + ]); + + $result = UserCopilotHistoryService::getDownloadableContent($user->id, $history); + + $this->assertEquals($aiResponse, $result->ai_response); + $this->assertIsArray($result->ai_response); + } + + /** + * Test getting downloadable content returns message object + */ + public function test_get_downloadable_content_returns_message_object(): void + { + $user = User::factory()->create(); + $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]); + + $message = Message::factory()->create([ + 'history_id' => $history->id, + 'ai_response' => ['response' => 'Test'], + 'user_message' => 'User query', + 'ai_model' => 'gpt-4', + ]); + + $result = UserCopilotHistoryService::getDownloadableContent($user->id, $history); + + $this->assertInstanceOf(Message::class, $result); + $this->assertEquals('User query', $result->user_message); + $this->assertEquals('gpt-4', $result->ai_model); + } +} diff --git a/server/tests/Unit/ValidateFlowLogicServiceTest.php b/server/tests/Unit/ValidateFlowLogicServiceTest.php new file mode 100644 index 0000000..f209c70 --- /dev/null +++ b/server/tests/Unit/ValidateFlowLogicServiceTest.php @@ -0,0 +1,100 @@ +service = new ValidateFlowLogicService(); + } + /** + * Test that fingerprint is consistent for the same workflow + */ + public function test_fingerprint_consistency(): void + { + $workflow = [ + 'nodes' => [ + ['type' => 'Gmail', 'name' => 'email_node'], + ['type' => 'Slack', 'name' => 'slack_node'] + ], + 'connections' => [ + 'email_node' => ['main' => [[['node' => 'slack_node']]]] + ] + ]; + + // Use reflection to call private method + $reflection = new \ReflectionClass(ValidateFlowLogicService::class); + $method = $reflection->getMethod('fingerprintWorkflow'); + $method->setAccessible(true); + + $fingerprint1 = $method->invoke($this->service, $workflow); + $fingerprint2 = $method->invoke($this->service, $workflow); + + $this->assertEquals($fingerprint1, $fingerprint2); + } + + /** + * Test that different workflows produce different fingerprints + */ + public function test_fingerprint_differs_for_different_workflows(): void + { + $workflow1 = [ + 'nodes' => [ + ['type' => 'Gmail', 'name' => 'email_node'] + ], + 'connections' => [] + ]; + + $workflow2 = [ + 'nodes' => [ + ['type' => 'Slack', 'name' => 'slack_node'] + ], + 'connections' => [] + ]; + + $reflection = new \ReflectionClass(ValidateFlowLogicService::class); + $method = $reflection->getMethod('fingerprintWorkflow'); + $method->setAccessible(true); + + $fingerprint1 = $method->invoke($this->service, $workflow1); + $fingerprint2 = $method->invoke($this->service, $workflow2); + + $this->assertNotEquals($fingerprint1, $fingerprint2); + } + + /** + * Test that best workflow is tracked correctly + */ + public function test_best_workflow_tracking(): void + { + $workflow1 = ['nodes' => [['type' => 'Gmail', 'name' => 'node1']], 'connections' => []]; + $workflow2 = ['nodes' => [['type' => 'Slack', 'name' => 'node2']], 'connections' => []]; + + $reflection = new \ReflectionClass(ValidateFlowLogicService::class); + + // Get the updateBestWorkflow method + $updateMethod = $reflection->getMethod('updateBestWorkflow'); + $updateMethod->setAccessible(true); + + // First workflow with score 0.7 + $updateMethod->invoke($this->service, $workflow1, 0.7); + + // Second workflow with score 0.85 (should become best) + $updateMethod->invoke($this->service, $workflow2, 0.85); + + // Verify best workflow property + $bestWorkflowProperty = $reflection->getProperty('bestWorkflow'); + $bestWorkflowProperty->setAccessible(true); + $bestWorkflow = $bestWorkflowProperty->getValue($this->service); + + $this->assertEquals($workflow2, $bestWorkflow); + } +}