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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
quotevote-backend/compose.yml
quotevote-backend/dockerfile
session_state.md
graphify-out/
graphify-out/

# Local Cursor IDE rules / agent config (machine-specific)
.cursor/
4 changes: 2 additions & 2 deletions quotevote-backend/app/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ async function startServer() {
getBuddyList: [BuddyWithPresence!]!
getRoster: [Roster!]!

# Action reactions
actionReactions(actionId: ID!): [Reaction!]!
# Action reactions (String! — matches hosted/legacy API; ID! breaks clients)
actionReactions(actionId: String!): [Reaction!]!

# Admin / reports
getBotReportedUsers(sortBy: String, limit: Int): [User!]!
Expand Down
14 changes: 14 additions & 0 deletions quotevote-frontend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Quote.Vote API — local development
# Copy to `.env.local` and adjust for your machine. Never commit `.env.local`.
NEXT_PUBLIC_SERVER_URL=http://localhost:4000
NEXT_PUBLIC_GRAPHQL_ENDPOINT=http://localhost:4000/graphql

# Optional: point at hosted staging/production instead of local backend
# NEXT_PUBLIC_SERVER_URL=https://api.quote.vote
# NEXT_PUBLIC_GRAPHQL_ENDPOINT=https://api.quote.vote/graphql

# WebSocket is auto-derived by getGraphqlWsServerUrl():
# ws://localhost:4000/graphql (local)
# wss://api.quote.vote/graphql (hosted)
# Login is a REST endpoint (not GraphQL):
# POST {NEXT_PUBLIC_SERVER_URL}/auth/login → { username, password } → { token }
8 changes: 0 additions & 8 deletions quotevote-frontend/.env.local

This file was deleted.

3 changes: 3 additions & 0 deletions quotevote-frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ yarn-error.log*

# env files (can opt-in for committing if needed)
.env
.env*.local
!.env.example
!.env.e2e.example

# vercel
.vercel
Expand Down
9 changes: 7 additions & 2 deletions quotevote-frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,19 @@ The application will be available at [http://localhost:3000](http://localhost:30

### Environment Variables

Create a `.env.local` file in the root directory with the following variable:
Copy the example env file and adjust as needed:

```bash
cp .env.example .env.local
```

```env
# GraphQL endpoint URL (must be prefixed with NEXT_PUBLIC_)
NEXT_PUBLIC_SERVER_URL=http://localhost:4000
NEXT_PUBLIC_GRAPHQL_ENDPOINT=http://localhost:4000/graphql
```

The server URL is automatically derived from the GraphQL endpoint by removing the `/graphql` suffix.
`.env.local` is gitignored — keep machine-specific overrides (including hosted API URLs) there only.

## 🎯 Path Aliases

Expand Down
144 changes: 144 additions & 0 deletions quotevote-frontend/e2e/featured-posts.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* RC1-004 — Featured posts should navigate to post detail page
*
* Clicking a featured post card on the homepage should open the
* corresponding post detail route.
*/
import { test, expect, type Page } from "@playwright/test";

const FEATURED_POSTS = [
{
__typename: "Post",
_id: "feat-e2e-1",
userId: "user-1",
groupId: "general",
title: "E2E Featured Post One",
text: "First featured post body used for navigation regression coverage.",
upvotes: 5,
downvotes: 0,
bookmarkedBy: [],
created: new Date().toISOString(),
url: "/post/general/e2e-featured-post-one/feat-e2e-1",
citationUrl: null,
creator: {
__typename: "User",
_id: "user-1",
name: "Featured Author",
username: "featured_author",
avatar: null,
contributorBadge: false,
},
votes: [],
comments: [{ __typename: "Comment", _id: "c1" }],
quotes: [],
messageRoom: null,
},
{
__typename: "Post",
_id: "feat-e2e-2",
userId: "user-2",
groupId: "climate",
title: "E2E Featured Post Two",
text: "Second featured post body used for navigation regression coverage.",
upvotes: 3,
downvotes: 1,
bookmarkedBy: [],
created: new Date().toISOString(),
url: "/post/climate/e2e-featured-post-two/feat-e2e-2",
citationUrl: null,
creator: {
__typename: "User",
_id: "user-2",
name: "Second Author",
username: "second_author",
avatar: null,
contributorBadge: false,
},
votes: [],
comments: [],
quotes: [{ __typename: "Quote", _id: "q1" }],
messageRoom: null,
},
] as const;

async function mockFeaturedPostsGraphQL(page: Page) {
await page.route("**/graphql", async (route) => {
const request = route.request();
if (request.method() !== "POST") {
await route.fallback();
return;
}

let query = "";
try {
const payload = request.postDataJSON() as { query?: string } | null;
query = payload?.query || "";
} catch {
await route.fallback();
return;
}

if (!query.includes("featuredPosts")) {
await route.fallback();
return;
}

await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
featuredPosts: {
__typename: "Posts",
entities: FEATURED_POSTS,
pagination: {
__typename: "Pagination",
total_count: FEATURED_POSTS.length,
limit: 10,
offset: 0,
},
},
},
}),
});
});
}

test.describe("RC1-004 Featured posts navigate to post detail", () => {
// Homepage featured posts are for logged-out visitors; clear stored auth.
test.use({ storageState: { cookies: [], origins: [] } });

test.beforeEach(async ({ page }) => {
await mockFeaturedPostsGraphQL(page);
});

test("clicking each featured post card opens the corresponding post page", async ({
page,
}) => {
await page.goto("/");

const cards = page.getByTestId("featured-post-card");
await expect(cards).toHaveCount(FEATURED_POSTS.length);

for (const post of FEATURED_POSTS) {
await page.goto("/");
await expect(cards).toHaveCount(FEATURED_POSTS.length);

const card = page.getByTestId("featured-post-card").filter({
hasText: post.title,
});
await expect(card).toBeVisible();
await expect(card).toHaveAttribute(
"href",
`/dashboard${post.url.replace(/\?/g, "")}`
);

await card.click();
await expect(page).toHaveURL(new RegExp(`/dashboard${post.url}$`));

await page.goBack();
await expect(page).toHaveURL(/\/$/);
await expect(cards.first()).toBeVisible();
}
});
});
83 changes: 83 additions & 0 deletions quotevote-frontend/src/__tests__/app/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,89 @@ describe('LandingPage', () => {
});
});

// ── Featured posts navigation (RC1-004) ────────────────────

describe('Featured posts navigation', () => {
const featuredPosts = [
{
_id: 'feat-1',
userId: 'u1',
title: 'Featured Democracy Debate',
text: 'A thoughtful take on civic discourse.',
created: new Date().toISOString(),
url: '/post/general/featured-democracy-debate/feat-1',
upvotes: 12,
downvotes: 1,
creator: { _id: 'u1', name: 'Alex Author', username: 'alex' },
comments: [{ _id: 'c1' }],
quotes: [],
},
{
_id: 'feat-2',
userId: 'u2',
title: 'Climate Action Now',
text: 'We need practical climate policy.',
created: new Date().toISOString(),
url: '/post/climate/climate-action-now/feat-2',
upvotes: 8,
downvotes: 0,
creator: { _id: 'u2', name: 'Sam Writer', username: 'sam' },
comments: [],
quotes: [{ _id: 'q1' }],
},
];

beforeEach(() => {
mockUseQuery.mockReturnValue({
loading: false,
error: undefined,
data: {
featuredPosts: {
entities: featuredPosts,
pagination: { total_count: 2, limit: 10, offset: 0 },
},
posts: { entities: [] },
searchUser: [],
},
});
});

it('renders featured post cards as links to post detail routes', () => {
renderLandingPage();

expect(
screen.getByRole('heading', { name: /featured posts/i })
).toBeInTheDocument();

const firstCard = screen.getByRole('link', {
name: /read featured post: featured democracy debate/i,
});
expect(firstCard).toHaveAttribute(
'href',
'/dashboard/post/general/featured-democracy-debate/feat-1'
);

const secondCard = screen.getByRole('link', {
name: /read featured post: climate action now/i,
});
expect(secondCard).toHaveAttribute(
'href',
'/dashboard/post/climate/climate-action-now/feat-2'
);
});

it('makes the full card the clickable navigation target', () => {
renderLandingPage();

const cards = screen.getAllByTestId('featured-post-card');
expect(cards).toHaveLength(2);
cards.forEach((card) => {
expect(card.tagName.toLowerCase()).toBe('a');
expect(card).toHaveAttribute('href');
});
});
});

// ── Auth redirect ──────────────────────────────────────────

describe('Auth redirect', () => {
Expand Down
Loading
Loading