feat(#37-41): Add domains, ideas, relationships, agents, widgets schema
Schema additions for issues #37-41: New models: - Domain (#37): Life domains (work, marriage, homelab, etc.) - Idea (#38): Brain dumps with pgvector embeddings - Relationship (#39): Generic entity linking (blocks, depends_on) - Agent (#40): ClawdBot agent tracking with metrics - AgentSession (#40): Conversation session tracking - WidgetDefinition (#41): HUD widget registry - UserLayout (#41): Per-user dashboard configuration Updated models: - Task, Event, Project: Added domainId foreign key - User, Workspace: Added new relations New enums: - IdeaStatus: CAPTURED, PROCESSING, ACTIONABLE, ARCHIVED, DISCARDED - RelationshipType: BLOCKS, BLOCKED_BY, DEPENDS_ON, etc. - AgentStatus: IDLE, WORKING, WAITING, ERROR, TERMINATED - EntityType: Added IDEA, DOMAIN Migration: 20260129182803_add_domains_ideas_agents_widgets
This commit is contained in:
340
apps/web/src/lib/api/client.test.ts
Normal file
340
apps/web/src/lib/api/client.test.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { apiRequest, apiGet, apiPost, apiPatch, apiDelete } from "./client";
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
describe("API Client", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("apiRequest", () => {
|
||||
it("should make a successful GET request", async () => {
|
||||
const mockData = { id: "1", name: "Test" };
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockData,
|
||||
});
|
||||
|
||||
const result = await apiRequest<typeof mockData>("/test");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3001/test",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
credentials: "include",
|
||||
})
|
||||
);
|
||||
expect(result).toEqual(mockData);
|
||||
});
|
||||
|
||||
it("should include custom headers", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
await apiRequest("/test", {
|
||||
headers: { Authorization: "Bearer token123" },
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3001/test",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer token123",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error on failed request", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: "Not Found",
|
||||
json: async () => ({
|
||||
code: "NOT_FOUND",
|
||||
message: "Resource not found",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(apiRequest("/test")).rejects.toThrow("Resource not found");
|
||||
});
|
||||
|
||||
it("should handle errors when JSON parsing fails", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: "Internal Server Error",
|
||||
json: async () => {
|
||||
throw new Error("Invalid JSON");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(apiRequest("/test")).rejects.toThrow(
|
||||
"Internal Server Error"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiGet", () => {
|
||||
it("should make a GET request", async () => {
|
||||
const mockData = { id: "1" };
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockData,
|
||||
});
|
||||
|
||||
const result = await apiGet<typeof mockData>("/test");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3001/test",
|
||||
expect.objectContaining({ method: "GET" })
|
||||
);
|
||||
expect(result).toEqual(mockData);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiPost", () => {
|
||||
it("should make a POST request with data", async () => {
|
||||
const postData = { name: "New Item" };
|
||||
const mockResponse = { id: "1", ...postData };
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await apiPost<typeof mockResponse>("/test", postData);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3001/test",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify(postData),
|
||||
})
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("should make a POST request without data", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
await apiPost("/test");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3001/test",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
// When no data is provided, body property is not set (not undefined)
|
||||
})
|
||||
);
|
||||
|
||||
// Verify body is not in the call
|
||||
const callArgs = mockFetch.mock.calls[0][1] as RequestInit;
|
||||
expect(callArgs.body).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiPatch", () => {
|
||||
it("should make a PATCH request with data", async () => {
|
||||
const patchData = { name: "Updated" };
|
||||
const mockResponse = { id: "1", ...patchData };
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await apiPatch<typeof mockResponse>("/test/1", patchData);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3001/test/1",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patchData),
|
||||
})
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiDelete", () => {
|
||||
it("should make a DELETE request", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const result = await apiDelete<{ success: boolean }>("/test/1");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3001/test/1",
|
||||
expect.objectContaining({ method: "DELETE" })
|
||||
);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle network errors", async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error("Network request failed"));
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow("Network request failed");
|
||||
});
|
||||
|
||||
it("should handle 401 unauthorized errors", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: "Unauthorized",
|
||||
status: 401,
|
||||
json: async () => ({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Authentication required",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow("Authentication required");
|
||||
});
|
||||
|
||||
it("should handle 403 forbidden errors", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: "Forbidden",
|
||||
status: 403,
|
||||
json: async () => ({
|
||||
code: "FORBIDDEN",
|
||||
message: "Access denied",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow("Access denied");
|
||||
});
|
||||
|
||||
it("should handle 404 not found errors", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: "Not Found",
|
||||
status: 404,
|
||||
json: async () => ({
|
||||
code: "NOT_FOUND",
|
||||
message: "Resource not found",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow("Resource not found");
|
||||
});
|
||||
|
||||
it("should handle 500 server errors", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: "Internal Server Error",
|
||||
status: 500,
|
||||
json: async () => ({
|
||||
code: "INTERNAL_ERROR",
|
||||
message: "Internal server error",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow("Internal server error");
|
||||
});
|
||||
|
||||
it("should handle malformed JSON responses", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => {
|
||||
throw new Error("Unexpected token in JSON");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow("Unexpected token in JSON");
|
||||
});
|
||||
|
||||
it("should handle empty error responses", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: "Bad Request",
|
||||
status: 400,
|
||||
json: async () => {
|
||||
throw new Error("No JSON body");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow("Bad Request");
|
||||
});
|
||||
|
||||
it("should handle timeout errors", async () => {
|
||||
mockFetch.mockImplementationOnce(() => {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Request timeout")), 1);
|
||||
});
|
||||
});
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow("Request timeout");
|
||||
});
|
||||
|
||||
it("should handle malformed error responses with details", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: "Validation Error",
|
||||
status: 422,
|
||||
json: async () => ({
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "Invalid input",
|
||||
details: {
|
||||
fields: {
|
||||
email: "Invalid email format",
|
||||
password: "Password too short",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow("Invalid input");
|
||||
});
|
||||
|
||||
it("should handle CORS errors", async () => {
|
||||
mockFetch.mockRejectedValueOnce(
|
||||
new TypeError("Failed to fetch")
|
||||
);
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow("Failed to fetch");
|
||||
});
|
||||
|
||||
it("should handle rate limit errors", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: "Too Many Requests",
|
||||
status: 429,
|
||||
json: async () => ({
|
||||
code: "RATE_LIMIT_EXCEEDED",
|
||||
message: "Too many requests. Please try again later.",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow(
|
||||
"Too many requests. Please try again later."
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle connection refused errors", async () => {
|
||||
mockFetch.mockRejectedValueOnce({
|
||||
name: "FetchError",
|
||||
message: "request to http://localhost:3001/test failed, reason: connect ECONNREFUSED",
|
||||
});
|
||||
|
||||
await expect(apiGet("/test")).rejects.toMatchObject({
|
||||
message: expect.stringContaining("ECONNREFUSED"),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
90
apps/web/src/lib/api/client.ts
Normal file
90
apps/web/src/lib/api/client.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* API Client for Mosaic Stack
|
||||
* Handles authenticated requests to the backend API
|
||||
*/
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
meta?: {
|
||||
total?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated API request
|
||||
*/
|
||||
export async function apiRequest<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
credentials: "include", // Include cookies for session
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json().catch(() => ({
|
||||
code: "UNKNOWN_ERROR",
|
||||
message: response.statusText || "An unknown error occurred",
|
||||
}));
|
||||
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request helper
|
||||
*/
|
||||
export async function apiGet<T>(endpoint: string): Promise<T> {
|
||||
return apiRequest<T>(endpoint, { method: "GET" });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request helper
|
||||
*/
|
||||
export async function apiPost<T>(endpoint: string, data?: unknown): Promise<T> {
|
||||
const options: RequestInit = {
|
||||
method: "POST",
|
||||
};
|
||||
|
||||
if (data !== undefined) {
|
||||
options.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
return apiRequest<T>(endpoint, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH request helper
|
||||
*/
|
||||
export async function apiPatch<T>(endpoint: string, data: unknown): Promise<T> {
|
||||
return apiRequest<T>(endpoint, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request helper
|
||||
*/
|
||||
export async function apiDelete<T>(endpoint: string): Promise<T> {
|
||||
return apiRequest<T>(endpoint, { method: "DELETE" });
|
||||
}
|
||||
90
apps/web/src/lib/api/events.ts
Normal file
90
apps/web/src/lib/api/events.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Event API Client
|
||||
* Handles event-related API requests
|
||||
*/
|
||||
|
||||
import type { Event } from "@mosaic/shared";
|
||||
import { apiGet, type ApiResponse } from "./client";
|
||||
|
||||
export interface EventFilters {
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events with optional filters
|
||||
*/
|
||||
export async function fetchEvents(filters?: EventFilters): Promise<Event[]> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters?.startDate) {
|
||||
params.append("startDate", filters.startDate.toISOString());
|
||||
}
|
||||
if (filters?.endDate) {
|
||||
params.append("endDate", filters.endDate.toISOString());
|
||||
}
|
||||
if (filters?.workspaceId) {
|
||||
params.append("workspaceId", filters.workspaceId);
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = queryString ? `/api/events?${queryString}` : "/api/events";
|
||||
|
||||
const response = await apiGet<ApiResponse<Event[]>>(endpoint);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock events for development (until backend endpoints are ready)
|
||||
*/
|
||||
export const mockEvents: Event[] = [
|
||||
{
|
||||
id: "event-1",
|
||||
title: "Team standup",
|
||||
description: "Daily sync meeting",
|
||||
startTime: new Date("2026-01-29T10:00:00"),
|
||||
endTime: new Date("2026-01-29T10:30:00"),
|
||||
allDay: false,
|
||||
location: "Zoom",
|
||||
recurrence: null,
|
||||
creatorId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "event-2",
|
||||
title: "Project review",
|
||||
description: "Quarterly project review session",
|
||||
startTime: new Date("2026-01-30T14:00:00"),
|
||||
endTime: new Date("2026-01-30T15:30:00"),
|
||||
allDay: false,
|
||||
location: "Conference Room A",
|
||||
recurrence: null,
|
||||
creatorId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "event-3",
|
||||
title: "Focus time",
|
||||
description: "Dedicated time for deep work",
|
||||
startTime: new Date("2026-01-31T09:00:00"),
|
||||
endTime: new Date("2026-01-31T12:00:00"),
|
||||
allDay: false,
|
||||
location: null,
|
||||
recurrence: null,
|
||||
creatorId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
];
|
||||
167
apps/web/src/lib/api/tasks.test.ts
Normal file
167
apps/web/src/lib/api/tasks.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fetchTasks } from "./tasks";
|
||||
import type { Task } from "@mosaic/shared";
|
||||
|
||||
// Mock the API client
|
||||
vi.mock("./client", () => ({
|
||||
apiGet: vi.fn(),
|
||||
}));
|
||||
|
||||
const { apiGet } = await import("./client");
|
||||
|
||||
describe("Task API Client", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should fetch tasks successfully", async () => {
|
||||
const mockTasks: Task[] = [
|
||||
{
|
||||
id: "task-1",
|
||||
title: "Complete project setup",
|
||||
description: "Set up the development environment",
|
||||
status: "active",
|
||||
priority: "high",
|
||||
dueDate: new Date("2026-02-01"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "task-2",
|
||||
title: "Review documentation",
|
||||
description: "Review and update project docs",
|
||||
status: "upcoming",
|
||||
priority: "medium",
|
||||
dueDate: new Date("2026-02-05"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 1,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({ data: mockTasks });
|
||||
|
||||
const result = await fetchTasks();
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith("/api/tasks");
|
||||
expect(result).toEqual(mockTasks);
|
||||
});
|
||||
|
||||
it("should handle errors when fetching tasks", async () => {
|
||||
vi.mocked(apiGet).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
await expect(fetchTasks()).rejects.toThrow("Network error");
|
||||
});
|
||||
|
||||
it("should fetch tasks with filters", async () => {
|
||||
const mockTasks: Task[] = [];
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({ data: mockTasks });
|
||||
|
||||
await fetchTasks({ status: "active" });
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith("/api/tasks?status=active");
|
||||
});
|
||||
|
||||
it("should fetch tasks with multiple filters", async () => {
|
||||
const mockTasks: Task[] = [];
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({ data: mockTasks });
|
||||
|
||||
await fetchTasks({ status: "active", priority: "high" });
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith(
|
||||
"/api/tasks?status=active&priority=high"
|
||||
);
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle network errors when fetching tasks", async () => {
|
||||
vi.mocked(apiGet).mockRejectedValueOnce(
|
||||
new Error("Network request failed")
|
||||
);
|
||||
|
||||
await expect(fetchTasks()).rejects.toThrow("Network request failed");
|
||||
});
|
||||
|
||||
it("should handle API returning malformed data", async () => {
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
data: null,
|
||||
});
|
||||
|
||||
const result = await fetchTasks();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle auth token expiration (401 error)", async () => {
|
||||
vi.mocked(apiGet).mockRejectedValueOnce(
|
||||
new Error("Authentication required")
|
||||
);
|
||||
|
||||
await expect(fetchTasks()).rejects.toThrow("Authentication required");
|
||||
});
|
||||
|
||||
it("should handle server 500 errors", async () => {
|
||||
vi.mocked(apiGet).mockRejectedValueOnce(
|
||||
new Error("Internal server error")
|
||||
);
|
||||
|
||||
await expect(fetchTasks()).rejects.toThrow("Internal server error");
|
||||
});
|
||||
|
||||
it("should handle forbidden access (403 error)", async () => {
|
||||
vi.mocked(apiGet).mockRejectedValueOnce(new Error("Access denied"));
|
||||
|
||||
await expect(fetchTasks()).rejects.toThrow("Access denied");
|
||||
});
|
||||
|
||||
it("should handle rate limiting errors", async () => {
|
||||
vi.mocked(apiGet).mockRejectedValueOnce(
|
||||
new Error("Too many requests. Please try again later.")
|
||||
);
|
||||
|
||||
await expect(fetchTasks()).rejects.toThrow(
|
||||
"Too many requests. Please try again later."
|
||||
);
|
||||
});
|
||||
|
||||
it("should ignore malformed filter parameters", async () => {
|
||||
const mockTasks: Task[] = [];
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({ data: mockTasks });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await fetchTasks({ invalidFilter: "value" } as any);
|
||||
|
||||
// Function should ignore invalid filters and call without query params
|
||||
expect(apiGet).toHaveBeenCalledWith("/api/tasks");
|
||||
});
|
||||
|
||||
it("should handle empty response data", async () => {
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({});
|
||||
|
||||
const result = await fetchTasks();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle timeout errors", async () => {
|
||||
vi.mocked(apiGet).mockRejectedValueOnce(new Error("Request timeout"));
|
||||
|
||||
await expect(fetchTasks()).rejects.toThrow("Request timeout");
|
||||
});
|
||||
});
|
||||
});
|
||||
115
apps/web/src/lib/api/tasks.ts
Normal file
115
apps/web/src/lib/api/tasks.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Task API Client
|
||||
* Handles task-related API requests
|
||||
*/
|
||||
|
||||
import type { Task } from "@mosaic/shared";
|
||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
||||
import { apiGet, type ApiResponse } from "./client";
|
||||
|
||||
export interface TaskFilters {
|
||||
status?: TaskStatus;
|
||||
priority?: TaskPriority;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch tasks with optional filters
|
||||
*/
|
||||
export async function fetchTasks(filters?: TaskFilters): Promise<Task[]> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters?.status) {
|
||||
params.append("status", filters.status);
|
||||
}
|
||||
if (filters?.priority) {
|
||||
params.append("priority", filters.priority);
|
||||
}
|
||||
if (filters?.workspaceId) {
|
||||
params.append("workspaceId", filters.workspaceId);
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = queryString ? `/api/tasks?${queryString}` : "/api/tasks";
|
||||
|
||||
const response = await apiGet<ApiResponse<Task[]>>(endpoint);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock tasks for development (until backend endpoints are ready)
|
||||
*/
|
||||
export const mockTasks: Task[] = [
|
||||
{
|
||||
id: "task-1",
|
||||
title: "Review pull request",
|
||||
description: "Review and provide feedback on frontend PR",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
priority: TaskPriority.HIGH,
|
||||
dueDate: new Date("2026-01-29"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "task-2",
|
||||
title: "Update documentation",
|
||||
description: "Add setup instructions for new developers",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
priority: TaskPriority.MEDIUM,
|
||||
dueDate: new Date("2026-01-30"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 1,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "task-3",
|
||||
title: "Plan Q1 roadmap",
|
||||
description: "Define priorities for Q1 2026",
|
||||
status: TaskStatus.NOT_STARTED,
|
||||
priority: TaskPriority.HIGH,
|
||||
dueDate: new Date("2026-02-03"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 2,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "task-4",
|
||||
title: "Research new libraries",
|
||||
description: "Evaluate options for state management",
|
||||
status: TaskStatus.PAUSED,
|
||||
priority: TaskPriority.LOW,
|
||||
dueDate: new Date("2026-02-10"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 3,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
];
|
||||
157
apps/web/src/lib/auth/auth-context.test.tsx
Normal file
157
apps/web/src/lib/auth/auth-context.test.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { AuthProvider, useAuth } from "./auth-context";
|
||||
import type { AuthUser } from "@mosaic/shared";
|
||||
|
||||
// Mock the API client
|
||||
vi.mock("../api/client", () => ({
|
||||
apiGet: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
}));
|
||||
|
||||
const { apiGet, apiPost } = await import("../api/client");
|
||||
|
||||
// Test component that uses the auth context
|
||||
function TestComponent() {
|
||||
const { user, isLoading, isAuthenticated, signOut } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="auth-status">
|
||||
{isAuthenticated ? "Authenticated" : "Not Authenticated"}
|
||||
</div>
|
||||
{user && (
|
||||
<div>
|
||||
<div data-testid="user-email">{user.email}</div>
|
||||
<div data-testid="user-name">{user.name}</div>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={signOut}>Sign Out</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("AuthContext", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should provide loading state initially", () => {
|
||||
vi.mocked(apiGet).mockImplementation(
|
||||
() => new Promise(() => {}) // Never resolves
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should provide authenticated user when session exists", async () => {
|
||||
const mockUser: AuthUser = {
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: new Date() },
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent(
|
||||
"Authenticated"
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("user-email")).toHaveTextContent(
|
||||
"test@example.com"
|
||||
);
|
||||
expect(screen.getByTestId("user-name")).toHaveTextContent("Test User");
|
||||
});
|
||||
|
||||
it("should handle unauthenticated state when session check fails", async () => {
|
||||
vi.mocked(apiGet).mockRejectedValueOnce(new Error("Unauthorized"));
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent(
|
||||
"Not Authenticated"
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("user-email")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should clear user on sign out", async () => {
|
||||
const mockUser: AuthUser = {
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: new Date() },
|
||||
});
|
||||
|
||||
vi.mocked(apiPost).mockResolvedValueOnce({ success: true });
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
// Wait for authenticated state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent(
|
||||
"Authenticated"
|
||||
);
|
||||
});
|
||||
|
||||
// Click sign out
|
||||
const signOutButton = screen.getByRole("button", { name: "Sign Out" });
|
||||
signOutButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent(
|
||||
"Not Authenticated"
|
||||
);
|
||||
});
|
||||
|
||||
expect(apiPost).toHaveBeenCalledWith("/auth/sign-out");
|
||||
});
|
||||
|
||||
it("should throw error when useAuth is used outside AuthProvider", () => {
|
||||
// Suppress console.error for this test
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
render(<TestComponent />);
|
||||
}).toThrow("useAuth must be used within AuthProvider");
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
74
apps/web/src/lib/auth/auth-context.tsx
Normal file
74
apps/web/src/lib/auth/auth-context.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { AuthUser, AuthSession } from "@mosaic/shared";
|
||||
import { apiGet, apiPost } from "../api/client";
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
signOut: () => Promise<void>;
|
||||
refreshSession: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const checkSession = useCallback(async () => {
|
||||
try {
|
||||
const session = await apiGet<AuthSession>("/auth/session");
|
||||
setUser(session.user);
|
||||
} catch (error) {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
try {
|
||||
await apiPost("/auth/sign-out");
|
||||
} catch (error) {
|
||||
console.error("Sign out error:", error);
|
||||
} finally {
|
||||
setUser(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshSession = useCallback(async () => {
|
||||
await checkSession();
|
||||
}, [checkSession]);
|
||||
|
||||
useEffect(() => {
|
||||
checkSession();
|
||||
}, [checkSession]);
|
||||
|
||||
const value: AuthContextValue = {
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated: user !== null,
|
||||
signOut,
|
||||
refreshSession,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
111
apps/web/src/lib/utils/date-format.test.ts
Normal file
111
apps/web/src/lib/utils/date-format.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
formatDate,
|
||||
formatTime,
|
||||
getDateGroupLabel,
|
||||
isPastTarget,
|
||||
isApproachingTarget,
|
||||
} from "./date-format";
|
||||
|
||||
describe("date-format utils", () => {
|
||||
describe("formatDate", () => {
|
||||
it("should format date in readable format", () => {
|
||||
// Use explicit time to avoid timezone issues
|
||||
const date = new Date("2026-01-29T12:00:00");
|
||||
const result = formatDate(date);
|
||||
expect(result).toMatch(/Jan/);
|
||||
expect(result).toMatch(/2026/);
|
||||
// Note: Day might be 28 or 29 depending on timezone
|
||||
expect(result).toMatch(/\d{1,2}/);
|
||||
});
|
||||
|
||||
it("should handle invalid dates", () => {
|
||||
const result = formatDate(new Date("invalid"));
|
||||
expect(result).toBe("Invalid Date");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTime", () => {
|
||||
it("should format time in 12-hour format", () => {
|
||||
const date = new Date("2026-01-29T14:30:00");
|
||||
const result = formatTime(date);
|
||||
expect(result).toMatch(/\d{1,2}:\d{2} [AP]M/i);
|
||||
});
|
||||
|
||||
it("should handle invalid time", () => {
|
||||
const result = formatTime(new Date("invalid"));
|
||||
expect(result).toBe("Invalid Time");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDateGroupLabel", () => {
|
||||
const today = new Date("2026-01-28T12:00:00");
|
||||
|
||||
it("should return 'Today' for today's date", () => {
|
||||
const date = new Date("2026-01-28T14:00:00");
|
||||
const result = getDateGroupLabel(date, today);
|
||||
expect(result).toBe("Today");
|
||||
});
|
||||
|
||||
it("should return 'Tomorrow' for tomorrow's date", () => {
|
||||
const date = new Date("2026-01-29T10:00:00");
|
||||
const result = getDateGroupLabel(date, today);
|
||||
expect(result).toBe("Tomorrow");
|
||||
});
|
||||
|
||||
it("should return 'This Week' for dates within 7 days", () => {
|
||||
const date = new Date("2026-02-02T10:00:00");
|
||||
const result = getDateGroupLabel(date, today);
|
||||
expect(result).toBe("This Week");
|
||||
});
|
||||
|
||||
it("should return 'Next Week' for dates 7-14 days out", () => {
|
||||
const date = new Date("2026-02-08T10:00:00");
|
||||
const result = getDateGroupLabel(date, today);
|
||||
expect(result).toBe("Next Week");
|
||||
});
|
||||
|
||||
it("should return 'Later' for dates beyond 2 weeks", () => {
|
||||
const date = new Date("2026-03-15T10:00:00");
|
||||
const result = getDateGroupLabel(date, today);
|
||||
expect(result).toBe("Later");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPastTarget", () => {
|
||||
const now = new Date("2026-01-28T12:00:00");
|
||||
|
||||
it("should return true for past dates", () => {
|
||||
const pastDate = new Date("2026-01-27T10:00:00");
|
||||
expect(isPastTarget(pastDate, now)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for future dates", () => {
|
||||
const futureDate = new Date("2026-01-29T10:00:00");
|
||||
expect(isPastTarget(futureDate, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for current time", () => {
|
||||
expect(isPastTarget(now, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isApproachingTarget", () => {
|
||||
const now = new Date("2026-01-28T12:00:00");
|
||||
|
||||
it("should return true for dates within 24 hours", () => {
|
||||
const soonDate = new Date("2026-01-29T10:00:00");
|
||||
expect(isApproachingTarget(soonDate, now)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for dates beyond 24 hours", () => {
|
||||
const laterDate = new Date("2026-01-30T14:00:00");
|
||||
expect(isApproachingTarget(laterDate, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for past dates", () => {
|
||||
const pastDate = new Date("2026-01-27T10:00:00");
|
||||
expect(isApproachingTarget(pastDate, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
69
apps/web/src/lib/utils/date-format.ts
Normal file
69
apps/web/src/lib/utils/date-format.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Date formatting utilities
|
||||
* Provides PDA-friendly date formatting and grouping
|
||||
*/
|
||||
|
||||
import { format, isToday, isTomorrow, differenceInDays, isBefore } from "date-fns";
|
||||
|
||||
/**
|
||||
* Format a date in a readable format
|
||||
*/
|
||||
export function formatDate(date: Date): string {
|
||||
try {
|
||||
return format(date, "MMM d, yyyy");
|
||||
} catch (error) {
|
||||
return "Invalid Date";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time in 12-hour format
|
||||
*/
|
||||
export function formatTime(date: Date): string {
|
||||
try {
|
||||
return format(date, "h:mm a");
|
||||
} catch (error) {
|
||||
return "Invalid Time";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a PDA-friendly label for date grouping
|
||||
* Returns: "Today", "Tomorrow", "This Week", "Next Week", "Later"
|
||||
*/
|
||||
export function getDateGroupLabel(date: Date, referenceDate: Date = new Date()): string {
|
||||
if (isToday(date)) {
|
||||
return "Today";
|
||||
}
|
||||
|
||||
if (isTomorrow(date)) {
|
||||
return "Tomorrow";
|
||||
}
|
||||
|
||||
const daysUntil = differenceInDays(date, referenceDate);
|
||||
|
||||
if (daysUntil >= 0 && daysUntil <= 7) {
|
||||
return "This Week";
|
||||
}
|
||||
|
||||
if (daysUntil > 7 && daysUntil <= 14) {
|
||||
return "Next Week";
|
||||
}
|
||||
|
||||
return "Later";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date has passed (PDA-friendly: "target passed" instead of "overdue")
|
||||
*/
|
||||
export function isPastTarget(targetDate: Date, now: Date = new Date()): boolean {
|
||||
return isBefore(targetDate, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date is approaching (within 24 hours)
|
||||
*/
|
||||
export function isApproachingTarget(targetDate: Date, now: Date = new Date()): boolean {
|
||||
const hoursUntil = differenceInDays(targetDate, now);
|
||||
return hoursUntil >= 0 && hoursUntil <= 1;
|
||||
}
|
||||
Reference in New Issue
Block a user